@01-edu/shared 1.0.6 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/attrs-defs.js CHANGED
@@ -9,7 +9,6 @@ import {
9
9
  } from './event-utils.js'
10
10
  import { onboardingTypes } from './onboarding.js'
11
11
  import { getObjectFromRelativePath } from './path.js'
12
- import { supportedLang } from './programming-languages.js'
13
12
  import { gamesScoring } from './score.js'
14
13
  import { hasRequiredSkills, skillsSet } from './skill-definitions.js'
15
14
  import {
@@ -49,14 +48,14 @@ const numOrOne = x => (typeof x === 'number' && !Number.isNaN(x) ? x : 1)
49
48
  const _difficultyXpCoef = o =>
50
49
  o.attrs.difficulty * numOrOne(o.attrs.difficultyMod)
51
50
  const add = (a, b) => a + b
52
- const byValueIdx = ([ak, av], [bk, bv]) => av.index - bv.index
51
+ const byValueIdx = ([_ak, av], [_bk, bv]) => av.index - bv.index
53
52
 
54
53
  const translate = (object, user, key) =>
55
54
  object.attrs[`${key}-${user?.attrs?.language}`] ||
56
55
  object.attrs[`${key}-en`] ||
57
56
  key
58
57
 
59
- const firstOfGroup = (a, i, children) => {
58
+ const firstOfGroup = (a, _i, children) => {
60
59
  const { group } = a.attrs
61
60
  if (!group) return true
62
61
  return children.find(b => b.attrs.group === group) === a
@@ -65,7 +64,9 @@ const firstOfGroup = (a, i, children) => {
65
64
  const getCoreName = object => {
66
65
  const corePath = object.attrs.requirements?.core
67
66
  const core = _children(object.parent).find(
68
- child => child.id === getObjectFromRelativePath(corePath, object)?.id,
67
+ child =>
68
+ child.id ===
69
+ getObjectFromRelativePath(corePath, object, { throwError: false })?.id,
69
70
  )
70
71
  return core?.name || ''
71
72
  }
@@ -122,8 +123,9 @@ const TypeObject = def => ({
122
123
  // value*: // defaultValue ONLY IF the attribute is required, or when it is added,
123
124
  // functions: // if the attribute is associated to function(s) (if just one - it's this by default; if several, by default the user can choose in between them),
124
125
  // label: // text displayed in front end, instead of attribute name (client friendly),
126
+ // restrictive: // if true, the attribute will be defined only on the object itself and cannot be overridden in the relation to the parent object
125
127
  // instruction: // mention displayed under the label in the front end - to give precision that need to be seen,
126
- // description: complete explanation of what the attributes does - description is displayed only when the used hover the ℹ️ icon in the front end,
128
+ // description: complete explanation of what the attributes does - description is displayed only when the used hover the i icon in the front end,
127
129
  // required: // if the attribute is always there by default,
128
130
  // editable: // if it can be override by schools in the db (=/= choice in between several functions),
129
131
  // private: // if the attribute should not be seen in front end,
@@ -212,15 +214,13 @@ attrs.allowedFunctions = {
212
214
  const autoValidateLabel = 'Automatic success'
213
215
  const autoValidateWhereLabel = `${autoValidateLabel} if`
214
216
  const autoRejectWhereLabel = `Automatic reject if`
215
- const sharedAutoValidationOperator = (action, autoValidationLabel) => ({
217
+ const sharedAutoValidationOperator = action => ({
216
218
  type: 'string',
217
219
  restrictive: true,
218
220
  label: `${action} - number of conditions to fulfil`,
219
221
  ...Functions({ all: () => 'and', one: () => 'or' }),
220
222
  })
221
- attrs.autoRejectOperator = {
222
- games: sharedAutoValidationOperator('Reject', autoRejectWhereLabel),
223
- }
223
+ attrs.autoRejectOperator = { games: sharedAutoValidationOperator('Reject') }
224
224
 
225
225
  const games = Object.entries(gamesScoring)
226
226
  const gamesParameters = games.flatMap(([game, params]) => [
@@ -244,10 +244,6 @@ const allParameters = [
244
244
  ['level', { shouldFailUnder: 10, shouldSucceedFrom: 25 }],
245
245
  ...gamesParameters,
246
246
  ]
247
- const allParametersStr = allParameters
248
- .map(([parameter, _]) => breath(parameter))
249
- .join(', ')
250
- .slice(0, -2)
251
247
 
252
248
  // TODO: mv to a attrs-utils file
253
249
  const isntObjectOrIsEmpty = elem =>
@@ -286,7 +282,7 @@ const checkTextLength = (str, length) => {
286
282
  const checkValidURL = value => {
287
283
  try {
288
284
  return Boolean(new URL(value))
289
- } catch (err) {
285
+ } catch {
290
286
  throw Error('Invalid URL.')
291
287
  }
292
288
  }
@@ -350,9 +346,7 @@ attrs.autoValidate = {
350
346
  }),
351
347
  }
352
348
 
353
- attrs.autoValidateOperator = {
354
- games: sharedAutoValidationOperator('Success', autoValidateWhereLabel),
355
- }
349
+ attrs.autoValidateOperator = { games: sharedAutoValidationOperator('Success') }
356
350
 
357
351
  // none of these conditions are required
358
352
  types.autoValidateWhereConditions = Object.fromEntries(
@@ -554,20 +548,6 @@ attrs.capacity = {
554
548
  }
555
549
 
556
550
  const exerciseCategories = new Set(['required', 'optional', 'bonus'])
557
- const projectCategories = new Set(['synchronous', 'enrichment', 'asynchronous'])
558
- // to check: should we set dynamically the enrichment project category relying
559
- // on the fact that the object has a valid core project set in its requirements?
560
- // if so, in the admin interface we have to enforce to select a core project for the enrichment category to be applied
561
- const projectCategory = Literal('synchronous', {
562
- label: 'Category of the project',
563
- required: true,
564
- options: ['synchronous', 'asynchronous', 'enrichment'],
565
- check: value => {
566
- if (!projectCategories.has(value)) {
567
- throw Error('Must be "synchronous", "asynchronous" or "enrichment"')
568
- }
569
- },
570
- })
571
551
  relationAttrs.category = {
572
552
  // exercises in exams need the category for expected xp calculation
573
553
  exam: {
@@ -596,11 +576,7 @@ relationAttrs.category = {
596
576
  },
597
577
  }),
598
578
  },
599
- module: { project: { ...projectCategory, editable: true } },
600
- piscine: {
601
- project: { ...projectCategory, private: true },
602
- raid: Literal('required', { required: true, private: true }),
603
- },
579
+ piscine: { raid: Literal('required', { required: true, private: true }) },
604
580
  }
605
581
 
606
582
  // checkbox is not required - sign step can be used without, just to display a text
@@ -956,7 +932,10 @@ const getProjectName = object => {
956
932
  // only for projects with a core requirement set, and which name starts by the core name:
957
933
  // remove the core name from the name to avoid repetition
958
934
  const coreName = getCoreName(object)
959
- return name.startsWith(coreName) ? name.slice(coreName.length + 1) : name
935
+ // TODO: couldn't we just display the key?
936
+ return coreName && name.startsWith(coreName)
937
+ ? name.slice(coreName.length + 1)
938
+ : name
960
939
  }
961
940
  const sharedDisplayedName = Literal('', {
962
941
  editable: true,
@@ -981,7 +960,7 @@ attrs.displayedName = {
981
960
  }
982
961
 
983
962
  const isHackathon = object => object.attrs.special
984
- const getExerciseDuration = ({ parent, attrs }) =>
963
+ const getExerciseDuration = ({ parent }) =>
985
964
  isHackathon(parent)
986
965
  ? parent.attrs.duration / Object.keys(parent.children).length
987
966
  : 0
@@ -1803,33 +1782,63 @@ const sharedInput = {
1803
1782
  ...translatable,
1804
1783
  instruction: 'Required option: "type"',
1805
1784
  }
1806
- attrs.input = {
1807
- 'upload-step': {
1808
- ...sharedInput,
1809
- check: value => {
1810
- const [inputValues] = Object.values(value)
1811
- isntObjectOrIsEmpty(inputValues)
1812
- if (!inputValues.type || inputValues.type !== 'file') {
1813
- throw Error(
1814
- '"type":"file" property must be defined in the upload input.',
1815
- )
1816
- }
1817
- if (
1818
- inputValues.accept !== undefined &&
1819
- typeof inputValues.accept !== 'string'
1820
- ) {
1821
- throw Error(
1822
- '"accept" property (if added) must be a text. Example: "image/png, image/jpeg"',
1823
- )
1824
- }
1825
- if (
1826
- inputValues.required !== undefined &&
1827
- typeof inputValues.required !== 'boolean'
1828
- ) {
1829
- throw Error('"required" property (if added) must be a true or false.')
1830
- }
1831
- },
1785
+ const uploadInput = {
1786
+ ...sharedInput,
1787
+ editable: false,
1788
+ check: value => {
1789
+ const [inputValues] = Object.values(value)
1790
+ isntObjectOrIsEmpty(inputValues)
1791
+ if (!inputValues.type || inputValues.type !== 'file') {
1792
+ throw Error('"type":"file" property must be defined in the upload input.')
1793
+ }
1794
+ if (
1795
+ inputValues.accept !== undefined &&
1796
+ typeof inputValues.accept !== 'string'
1797
+ ) {
1798
+ throw Error(
1799
+ '"accept" property (if added) must be a text. Example: "image/png, image/jpeg"',
1800
+ )
1801
+ }
1802
+ if (
1803
+ inputValues.required !== undefined &&
1804
+ typeof inputValues.required !== 'boolean'
1805
+ ) {
1806
+ throw Error('"required" property (if added) must be a true or false.')
1807
+ }
1808
+ },
1809
+ }
1810
+
1811
+ const avatarInput = TypeObject({
1812
+ label: 'Avatar input',
1813
+ required: true,
1814
+ editable: false,
1815
+ value: {
1816
+ type: 'file',
1817
+ accept: 'image/png, image/jpeg',
1818
+ required: true,
1832
1819
  },
1820
+ type: {
1821
+ type: Literal('file', {
1822
+ label: 'Input type',
1823
+ required: true,
1824
+ editable: false,
1825
+ }),
1826
+ accept: Literal('image/png, image/jpeg', {
1827
+ label: 'Accepted file types',
1828
+ editable: false,
1829
+ required: true,
1830
+ }),
1831
+ required: Literal(true, {
1832
+ label: 'Required',
1833
+ editable: false,
1834
+ required: true,
1835
+ }),
1836
+ },
1837
+ })
1838
+
1839
+ attrs.input = {
1840
+ 'upload-step': uploadInput,
1841
+ 'avatar-step': avatarInput,
1833
1842
  'contact-validation-step': {
1834
1843
  ...sharedInput,
1835
1844
  check: value => {
@@ -1878,7 +1887,7 @@ attrs.input = {
1878
1887
  }
1879
1888
  try {
1880
1889
  return new RegExp(value)
1881
- } catch (err) {
1890
+ } catch {
1882
1891
  throw Error('Invalid Regular expression.')
1883
1892
  }
1884
1893
  }
@@ -1892,7 +1901,7 @@ const getInScope = ({ attrs }) => {
1892
1901
  }
1893
1902
  const getProjectInScope = ({ attrs }) => attrs.hasStarted
1894
1903
  const getExamExerciseInScope = ({ parent }) => parent.attrs.inScope
1895
- const getExerciseInScope = ({ parent, attrs, index }) => {
1904
+ const getExerciseInScope = ({ parent, attrs }) => {
1896
1905
  if (parent.attrs.inScope) {
1897
1906
  if (isHackathon(parent)) {
1898
1907
  const now = Date.now()
@@ -2020,35 +2029,66 @@ types.objectChildRelativePath = Literal('./', {
2020
2029
  .map(([key, _]) => `./${key}`),
2021
2030
  })
2022
2031
 
2032
+ const checkRelativePaths = (objects, object) => {
2033
+ if (!Array.isArray(objects) || !objects.length) {
2034
+ const error = new Error('Must be a non empty array')
2035
+ error.userFeedback =
2036
+ 'This list cannot be empty! Please add an item or remove the setting.'
2037
+ throw error
2038
+ }
2039
+ const uniques = [...new Set(objects)]
2040
+ if (objects.length !== uniques.length) {
2041
+ throw Error('Duplicates are not allowed.')
2042
+ }
2043
+
2044
+ const invalidObjectsRequirements = objects.filter(path => {
2045
+ const objectFromPath = getObjectFromRelativePath(path, object, {
2046
+ throwError: false,
2047
+ })
2048
+ return !objectFromPath
2049
+ })
2050
+
2051
+ if (invalidObjectsRequirements.length) {
2052
+ const paths = invalidObjectsRequirements.map(p => `'${p}'`).join(', ')
2053
+ const error = new Error(
2054
+ `Invalid objects requirements - no object found for the following relative paths: ${paths}`,
2055
+ )
2056
+ error.userFeedback = 'Some Contents are misconfigured, please update them!'
2057
+ console.error(error.message)
2058
+ throw error
2059
+ }
2060
+ }
2061
+
2023
2062
  types.objectRootRelativePath = Literal('../', {
2024
- label: 'Content relative path',
2063
+ label: 'Content relative path (Mandatory)',
2025
2064
  instruction: 'In same parent',
2026
2065
  editable: true,
2027
- check: getObjectFromRelativePath,
2066
+ check: (path, object) => {
2067
+ try {
2068
+ getObjectFromRelativePath(path, object)
2069
+ } catch (err) {
2070
+ err.userFeedback = `This content relative path (${path}) is invalid, please select a valid content in the list below or remove it.`
2071
+ throw err
2072
+ }
2073
+ },
2028
2074
  options: object => {
2029
2075
  if (!object?.parent?.children) return []
2030
2076
  const sorted = Object.entries(object.parent.children).sort(byValueIdx)
2031
- const index = sorted.findIndex(([k, v]) => k === object.key)
2077
+ const index = sorted.findIndex(([k]) => k === object.key)
2032
2078
  if (index < 1) return sorted.map(([key, _]) => `../${key}`)
2033
2079
  const before = sorted.slice(0, index)
2034
- // TODO: add a filter on category and let possibility to select the async after?
2035
- // const after = sorted.slice(index + 1)
2036
- // .filter(([k, v]) => v.attrs?.category === 'asynchronous')
2037
- // return [...before, ...after].map(([key, _]) => `../${key}`)
2038
2080
  return before.map(([key, _]) => `../${key}`).reverse()
2039
2081
  },
2040
2082
  })
2083
+
2084
+ // NOTE: objects requirements is declared here
2041
2085
  types.sharedObjectList = {
2042
- label: 'Contents', // synonyms: item, material
2086
+ label: 'Contents required', // synonyms: item, material
2043
2087
  editable: true,
2044
- check: objects => {
2045
- if (!Array.isArray(objects) || !objects.length) {
2046
- throw Error('Must be a non empty array')
2047
- }
2048
- const uniques = [...new Set(objects)]
2049
- if (objects.length !== uniques.length) {
2050
- throw Error('Duplicates are not allowed.')
2051
- }
2088
+ check: (objects, object) => {
2089
+ // objects are split into alternative paths or mandatory paths
2090
+ // for the check we will just flat the array so that we check every path the same way
2091
+ checkRelativePaths(objects.flat(), object)
2052
2092
  },
2053
2093
  }
2054
2094
 
@@ -2067,6 +2107,25 @@ const contentRequirements = {
2067
2107
  if (!skills && !objects && !core) {
2068
2108
  throw Error('Empty requirements, should be removed')
2069
2109
  }
2110
+
2111
+ const allObjects = [...(objects || []), core || []].flat()
2112
+ const invalidObjectsRequirements = allObjects.filter(path => {
2113
+ const objectFromPath = getObjectFromRelativePath(path, object, {
2114
+ throwError: false,
2115
+ })
2116
+ return !objectFromPath
2117
+ })
2118
+
2119
+ if (invalidObjectsRequirements.length) {
2120
+ const paths = invalidObjectsRequirements.map(p => `'${p}'`).join(', ')
2121
+ const error = new Error(
2122
+ `Invalid objects requirements - no object found for the following relative paths: ${paths}`,
2123
+ )
2124
+ error.userFeedback =
2125
+ 'You have some misconfigured Access conditions, please update them!'
2126
+ console.error(error.message)
2127
+ throw error
2128
+ }
2070
2129
  },
2071
2130
  }
2072
2131
 
@@ -2075,7 +2134,7 @@ const levelRequirements = {
2075
2134
  instruction: 'Conditions to unlock and earn this level',
2076
2135
  description:
2077
2136
  'Sets the requirements that have to be met for a level to be unlocked and earned by a student.',
2078
- check: (requirements, object) => {
2137
+ check: requirements => {
2079
2138
  const { skills, objects, ...rest } = requirements
2080
2139
  if (Object.keys(rest).length) {
2081
2140
  throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
@@ -2086,6 +2145,9 @@ const levelRequirements = {
2086
2145
  },
2087
2146
  }
2088
2147
 
2148
+ // NOTE: does level definition support the multiple pathways??
2149
+ // i think it should not for now, the multiple pathways was defined for the students to get to a content throughout different ways
2150
+ // not for the level be defined different ways
2089
2151
  types.levelDefinition = TypeObject({
2090
2152
  label: 'Level definition',
2091
2153
  type: {
@@ -2185,6 +2247,38 @@ attrs.link = {
2185
2247
  },
2186
2248
  ...translatable, // in case there are different versions of the doc to dl
2187
2249
  }),
2250
+ 'avatar-step': TypeObject({
2251
+ label: 'Link to the legal page',
2252
+ editable: false,
2253
+ required: true,
2254
+ value: {
2255
+ href: '/legal',
2256
+ label: '> Privacy policy',
2257
+ target: '_blank',
2258
+ },
2259
+ type: {
2260
+ href: Literal('/legal', {
2261
+ editable: false,
2262
+ }),
2263
+ label: Literal('> Privacy policy', {
2264
+ editable: false,
2265
+ }),
2266
+ target: Literal('_blank', {
2267
+ editable: false,
2268
+ }),
2269
+ },
2270
+ }),
2271
+ }
2272
+
2273
+ relationAttrs.mandatory = {
2274
+ module: {
2275
+ project: {
2276
+ label: 'Mandatory content to validate the curriculum',
2277
+ type: 'boolean',
2278
+ required: false,
2279
+ editable: true,
2280
+ },
2281
+ },
2188
2282
  }
2189
2283
 
2190
2284
  // TODO: should be required for exam exercises.
@@ -2209,7 +2303,9 @@ const sharedName = Literal('', {
2209
2303
  })
2210
2304
  const attrsNameObj = {}
2211
2305
  for (const type of onboardingTypes) {
2212
- attrsNameObj[type] = sharedName
2306
+ if (type !== 'avatar-step') {
2307
+ attrsNameObj[type] = sharedName
2308
+ }
2213
2309
  }
2214
2310
  attrs.name = {
2215
2311
  signup: sharedName,
@@ -2351,7 +2447,7 @@ const getCaptainLogin = ({ group, parent }) => {
2351
2447
  }
2352
2448
  const getRepositoryPath = ({ name, group, parent }, user) =>
2353
2449
  `${getCaptainLogin({ group, parent }) || user.login}/${name}`
2354
- const getExerciseRepositoryPath = ({ attrs, parent, path, group }, user) => {
2450
+ const getExerciseRepositoryPath = ({ attrs, parent, group }, user) => {
2355
2451
  const captainLogin = getCaptainLogin({ parent, group })
2356
2452
  return captainLogin
2357
2453
  ? `${captainLogin}/${parent.path.replace(/\/+/g, '-')}`
@@ -2384,6 +2480,30 @@ attrs.requiredAuditRatio = {
2384
2480
  }),
2385
2481
  }
2386
2482
 
2483
+ types.pathwaysRequirementObjects = {
2484
+ label: 'Multiple content choices',
2485
+ instruction:
2486
+ 'Adding this will create a new path way for the project being edit.',
2487
+ check: (objects, object) => {
2488
+ // objects are split into alternative paths or mandatory paths
2489
+ // for the check we will just flat the array so that we check every path the same way
2490
+ checkRelativePaths(objects.flat(), object)
2491
+ },
2492
+ required: false,
2493
+ editable: true,
2494
+ type: [
2495
+ {
2496
+ ...types.objectRootRelativePath,
2497
+ label: 'Content relative path (optional)',
2498
+ },
2499
+ ],
2500
+ value: (...args) => {
2501
+ const option = types.objectRootRelativePath.options(...args)?.[0]
2502
+ return option ? [option] : []
2503
+ },
2504
+ }
2505
+
2506
+ // NOTE: relation attribute requirements objects
2387
2507
  const sharedContentRequirementsForMainAttr = TypeObject({
2388
2508
  ...contentRequirements,
2389
2509
  type: {
@@ -2397,10 +2517,11 @@ const sharedContentRequirementsForMainAttr = TypeObject({
2397
2517
  ...types.sharedObjectList,
2398
2518
  value: (...args) => {
2399
2519
  const option = types.objectRootRelativePath.options(...args)?.[0]
2400
- return option ? [option] : []
2520
+ const pathways = types.pathwaysRequirementObjects.value(...args)
2521
+ return option ? [option, pathways] : []
2401
2522
  },
2402
- type: [types.objectRootRelativePath],
2403
- instruction: 'Items to be succeeded',
2523
+ type: [types.objectRootRelativePath, types.pathwaysRequirementObjects],
2524
+ instruction: 'Content required to unlock the current one',
2404
2525
  },
2405
2526
  },
2406
2527
  })
@@ -2775,7 +2896,7 @@ const getProgressStatus = progress => {
2775
2896
  }
2776
2897
 
2777
2898
  const examExerciseStatus = object => {
2778
- const { prev, attrs, progress, parent, index, event } = object
2899
+ const { attrs, progress, parent } = object
2779
2900
  const progressStatus = getProgressStatus(progress)
2780
2901
  if (progressStatus) return progressStatus
2781
2902
  if (!parent) return 'available'
@@ -2795,7 +2916,7 @@ const examExerciseStatus = object => {
2795
2916
  return prevValidated ? 'available' : 'blocked'
2796
2917
  }
2797
2918
  const questExerciseStatus = object => {
2798
- const { prev, attrs, progress, parent, index, event } = object
2919
+ const { prev, attrs, progress, parent } = object
2799
2920
  const progressStatus = getProgressStatus(progress)
2800
2921
  if (progressStatus) return progressStatus
2801
2922
  if (!parent) return 'available'
@@ -2830,6 +2951,20 @@ const questExerciseStatus = object => {
2830
2951
  return 'available'
2831
2952
  }
2832
2953
 
2954
+ const isPathStatusSucceeded = (path, object) => {
2955
+ try {
2956
+ const obj = getObjectFromRelativePath(path, object)
2957
+ return obj?.attrs.status === 'succeeded'
2958
+ } catch {
2959
+ // NOTE: should we make the requirement unblocked if the admin did not set the right requirement ????
2960
+
2961
+ // consider the requirement unlocked if an error is thrown by getObjectFromRelativePath,
2962
+ // because it would mean an admin wrongly set the requirement, probably manually in the db
2963
+ // (it is not possible to set an invalid requirement from the admin configuration interface)
2964
+ return true
2965
+ }
2966
+ }
2967
+
2833
2968
  /**
2834
2969
  * @throws This function throws an error if any of the required objects is invalid
2835
2970
  * @returns {bool} true if all the required objects are succeeded, false otherwise
@@ -2837,28 +2972,39 @@ const questExerciseStatus = object => {
2837
2972
  */
2838
2973
  const hasSucceededRequiredObjects = (requirements, object) => {
2839
2974
  if (!requirements) return true
2975
+ // TODO: in the near future remove the core attribute
2840
2976
  const { core, objects } = requirements
2841
2977
  if ((!objects || !objects.length) && !core) return true
2842
2978
 
2843
2979
  // aggregate core object and regular required objects in the list of objects to check
2844
- const requiredObjects = [...(objects || []), core].filter(Boolean) // only keep values that are not undefined
2845
- let objectFromPath
2846
- return requiredObjects.every(relativePath => {
2847
- try {
2848
- objectFromPath = getObjectFromRelativePath(relativePath, object)
2849
- } catch {
2850
- // consider the requirement locked if an error is thrown by getObjectFromRelativePath
2851
- return false
2852
- }
2853
- return objectFromPath?.attrs.status === 'succeeded'
2854
- })
2980
+ // only keep values that are not undefined
2981
+ // required objects are the ones that need to be done to unblock the current object
2982
+ const requiredObjects = [...(objects || []), core].filter(
2983
+ p => !Array.isArray(p) && Boolean(p),
2984
+ )
2985
+ // pathway objects represent the choices a student can take to unblock the current object
2986
+ // note: at least one object from each pathway must be successfully completed
2987
+ const pathWayObjects = objects?.filter(p => Array.isArray(p) && Boolean(p))
2988
+
2989
+ const hasSeccededRequiredObjects = requiredObjects.every(path =>
2990
+ isPathStatusSucceeded(path, object),
2991
+ )
2992
+
2993
+ // it's possible to have a pathway with just one relative path
2994
+ // in this case it will be considered as a required object
2995
+ const hasSucceededPathWays = pathWayObjects?.every(paths =>
2996
+ paths.some(path => isPathStatusSucceeded(path, object)),
2997
+ )
2998
+
2999
+ return hasSucceededPathWays !== undefined
3000
+ ? hasSeccededRequiredObjects && hasSucceededPathWays
3001
+ : hasSeccededRequiredObjects
2855
3002
  }
2856
3003
 
2857
3004
  // check if the requirements are met for a given object
2858
3005
  const meetsRequirements = ({ requirements, object, user, progress }) => {
2859
3006
  // check if there's already a progress, to not block students who began the project before implementing the requirements feature
2860
3007
  if ((progress && Object.keys(progress).length) || !requirements) return true
2861
-
2862
3008
  // check if the required skills have been earned & the required objects have been succeeded
2863
3009
  const hasSkills = hasRequiredSkills(requirements.skills, user.skills)
2864
3010
  const hasSucceededObjects = hasSucceededRequiredObjects(requirements, object)
@@ -2924,17 +3070,20 @@ const questStatus = object => {
2924
3070
  : getProgressStatus(progress) || 'available'
2925
3071
  }
2926
3072
 
3073
+ const getMeetsRequirements = (object, user) => {
3074
+ const { progress, attrs } = object
3075
+ const { requirements } = attrs
3076
+
3077
+ return meetsRequirements({ object, requirements, user, progress })
3078
+ }
2927
3079
  const getPiscineStatus = (object, user) => {
2928
3080
  const { progress, event, attrs } = object
2929
3081
  const { requirements } = attrs
2930
3082
  const progressStatus = getProgressStatus(progress)
2931
3083
  if (progressStatus) return progressStatus
2932
-
2933
- if (!event) return 'blocked'
2934
- const registrationNotStarted = Date.now() < event.registrationStartAt
2935
3084
  if (
2936
- !meetsRequirements({ object, requirements, user, progress }) ||
2937
- registrationNotStarted
3085
+ !meetsRequirements({ object, requirements, user, progress }) &&
3086
+ !event?.registeredPosition // in case a learner was force added to a registration through hasura by an admin
2938
3087
  ) {
2939
3088
  return 'blocked'
2940
3089
  }
@@ -3078,6 +3227,19 @@ relationAttrs.status = {
3078
3227
  },
3079
3228
  }
3080
3229
 
3230
+ types.meetRequirements = Literal(false, {
3231
+ label: 'User meet requirement',
3232
+ restrictive: true,
3233
+ required: true,
3234
+ ...Functions({ 'by requirements': getMeetsRequirements }),
3235
+ })
3236
+ relationAttrs.meetsRequirements = {
3237
+ module: {
3238
+ piscine: types.meetRequirements,
3239
+ project: types.meetRequirements,
3240
+ },
3241
+ }
3242
+
3081
3243
  const getSubject = object => {
3082
3244
  const path = `subjects/${getObjectPath(object)}/README.md`
3083
3245
  return `/markdown/root/public/${path}`
@@ -3095,10 +3257,77 @@ attrs.subject = {
3095
3257
  raid: sharedSubject,
3096
3258
  }
3097
3259
 
3098
- const sharedText = Literal('', {
3260
+ const sharedText = Literal('', { editable: true, ...translatable })
3261
+
3262
+ types.teamworkRankName = Literal('', {
3263
+ label: 'Rank name',
3264
+ type: 'string',
3099
3265
  editable: true,
3100
- ...translatable,
3266
+ required: true,
3267
+ primary: true,
3268
+ check: (name, object) => {
3269
+ const { teamworkRanks } = object.attrs
3270
+ const definitionsWithSameName = teamworkRanks?.filter(r => r.name === name)
3271
+ if (definitionsWithSameName?.length > 1) {
3272
+ throw Error(
3273
+ `Name "${name}" is already set for a teamwork rank definition! A given name can only be attributed once to a rank.`,
3274
+ )
3275
+ }
3276
+ },
3277
+ })
3278
+
3279
+ types.teamworkRankParticipations = Literal(0, {
3280
+ label: 'Group Participations',
3281
+ instruction:
3282
+ 'The number of users the student has to work with, to unlock this rank.',
3283
+ editable: true,
3284
+ required: true,
3285
+ options: arrayOf(150, 0),
3286
+ type: 'number',
3287
+ check: (groups, object) => {
3288
+ if (!Number.isInteger(groups)) throw Error('Must be a whole number')
3289
+ const definitionsWithSameLevel = object.attrs.teamworkRanks?.filter(
3290
+ rankDefinition => rankDefinition.groups === groups,
3291
+ )
3292
+ if (definitionsWithSameLevel?.length > 1) {
3293
+ throw Error(
3294
+ `There is already a rank with ${groups} users required for the rank`,
3295
+ )
3296
+ }
3297
+ },
3298
+ })
3299
+
3300
+ types.teamworkRanks = TypeObject({
3301
+ type: {
3302
+ name: types.teamworkRankName,
3303
+ groups: types.teamworkRankParticipations,
3304
+ },
3101
3305
  })
3306
+
3307
+ attrs.teamworkRanks = {
3308
+ campus: {
3309
+ label: 'Teamwork ranks',
3310
+ instruction: 'List of teamwork ranks',
3311
+ type: [types.teamworkRanks],
3312
+ // this setting is required for practical UX reasons. It is not
3313
+ // required for platform to work properly, but as there are no other
3314
+ // settings for the campus, it avoid hiding it in "more settings to add"
3315
+ // section and make it more visible/easy to configure for admins (as displayed by default)
3316
+ required: true,
3317
+ editable: true,
3318
+ value: (...args) => [
3319
+ mapValues(types.teamworkRanks.type, subDef =>
3320
+ getDefaultValue(subDef, ...args),
3321
+ ),
3322
+ ],
3323
+ check: teamworkRanks => {
3324
+ if (!teamworkRanks?.length || !Array.isArray(teamworkRanks)) {
3325
+ throw Error('Must be a non empty array')
3326
+ }
3327
+ },
3328
+ },
3329
+ }
3330
+
3102
3331
  // TODO: rename it documentToSign, or something more specific than 'text'
3103
3332
  attrs.text = {
3104
3333
  'upload-step': {
@@ -3114,6 +3343,10 @@ attrs.text = {
3114
3343
  ...sharedText,
3115
3344
  label: 'Resume', // TODO: mv it to attrs.resume
3116
3345
  },
3346
+ 'avatar-step': {
3347
+ ...sharedText,
3348
+ label: 'Resume', // TODO: mv it to attrs.resume
3349
+ },
3117
3350
  }
3118
3351
 
3119
3352
  types.timelineChunk = TypeObject({
@@ -3242,6 +3475,7 @@ types.adminSelectionValidation = TypeObject({
3242
3475
  private: true,
3243
3476
  primary: true,
3244
3477
  label: 'Type',
3478
+ options: ['admin_selection'],
3245
3479
  }),
3246
3480
  },
3247
3481
  label: 'Admin selection evaluation',
@@ -3252,7 +3486,12 @@ const getTesterImage = ({ attrs }) =>
3252
3486
  types.testerValidation = TypeObject({
3253
3487
  label: 'Tester evaluation',
3254
3488
  type: {
3255
- type: Literal('tester', { required: true, private: true, label: 'Type' }),
3489
+ type: Literal('tester', {
3490
+ required: true,
3491
+ private: true,
3492
+ label: 'Type',
3493
+ options: ['tester'],
3494
+ }),
3256
3495
  testImage: {
3257
3496
  type: 'string',
3258
3497
  label: 'Docker image',
@@ -3281,12 +3520,18 @@ const sharedMatchWhere = {
3281
3520
  }
3282
3521
  // matchWhere for user_audit validations
3283
3522
  // NB: user 1 should always be excluded because he has admin role that can't be removed
3523
+ const usersOfCampusWithAdmins = ({ attrs }) => ({
3524
+ campus: { _eq: attrs.campus },
3525
+ })
3284
3526
  const userInCampus = ({ attrs }) => ({
3285
3527
  campus: { _eq: attrs.campus },
3286
3528
  _not: { roles: { slug: { _in: ['admin', `campus_admin_${attrs.campus}`] } } },
3287
3529
  })
3530
+ const usersInEventWithAdmins = ({ attrs }) => ({
3531
+ events: { eventId: { _eq: attrs.eventId } },
3532
+ })
3288
3533
  const userInEvent = ({ attrs }) => ({
3289
- ...userInCampus({ attrs }),
3534
+ _not: { roles: { slug: { _in: ['admin', `campus_admin_${attrs.campus}`] } } },
3290
3535
  events: { eventId: { _eq: attrs.eventId } },
3291
3536
  })
3292
3537
 
@@ -3313,14 +3558,19 @@ const noAttribution = () => isUser01
3313
3558
 
3314
3559
  export const getAuditPath = object => {
3315
3560
  const path = `subjects/${getObjectPath(object)}/audit/README.md`
3316
- return `/markdown/root/public/${path}`
3561
+ // TODO: the new content system will no longer use public, in the future we should change this
3562
+ // now we have dedicated repos on gitea for: modules, piscine, ....
3563
+ // so the repo should be something more dynamic depending on the object.type?
3564
+ // for now lets use the raw markdown
3565
+ return `/markdown/raw/root/public/${path}`
3317
3566
  }
3318
3567
  const sharedTypesForm = {
3319
3568
  required: true,
3320
3569
  editable: true,
3321
3570
  type: 'string',
3322
- label: 'Audit form url',
3323
- instruction: 'List of questions asked by the auditor during the audit.',
3571
+ label: 'Audit form URL',
3572
+ instruction:
3573
+ 'List of questions asked by the auditor during the audit. The URL should return raw markdown',
3324
3574
  ...Functions({ 'README in audit folder': getAuditPath }),
3325
3575
  }
3326
3576
  types.adminAuditValidationDelay = Literal(0, {
@@ -3355,6 +3605,7 @@ types.adminAuditValidation = TypeObject({
3355
3605
  private: true,
3356
3606
  primary: true,
3357
3607
  label: 'Type',
3608
+ options: ['admin_audit'],
3358
3609
  }),
3359
3610
  delay: types.adminAuditValidationDelay,
3360
3611
  required: types.adminAuditValidationRequired,
@@ -3395,6 +3646,35 @@ types.userAuditValidationRatio = Literal(2, {
3395
3646
  },
3396
3647
  })
3397
3648
 
3649
+ types.matchInfluence = TypeObject({
3650
+ label: 'Match Algorithm Influence',
3651
+ editable: true,
3652
+ required: true,
3653
+ instruction: 'Adjust weights in the Match algorithm',
3654
+ type: {
3655
+ auditsRatio: Literal(2.0, {
3656
+ label: 'Current Audit Ratio',
3657
+ editable: true,
3658
+ required: true,
3659
+ }),
3660
+ auditsAssigned: Literal(2.0, {
3661
+ editable: true,
3662
+ required: true,
3663
+ label: 'Fewest Pending Audits',
3664
+ }),
3665
+ levelProximity: Literal(2.0, {
3666
+ editable: true,
3667
+ required: true,
3668
+ label: 'Level Proximity',
3669
+ }),
3670
+ lastAuditAttributed: Literal(2.0, {
3671
+ editable: true,
3672
+ required: true,
3673
+ label: 'Last Audit Attributed',
3674
+ }),
3675
+ },
3676
+ })
3677
+
3398
3678
  types.userAuditValidation = TypeObject({
3399
3679
  label: 'User audit evaluation',
3400
3680
  type: {
@@ -3403,15 +3683,19 @@ types.userAuditValidation = TypeObject({
3403
3683
  private: true,
3404
3684
  primary: true,
3405
3685
  label: 'Type',
3686
+ options: ['user_audit'],
3406
3687
  }),
3407
3688
  delay: types.userAuditValidationDelay,
3408
3689
  required: types.userAuditValidationRequired,
3409
3690
  ratio: types.userAuditValidationRatio,
3691
+ matchInfluence: types.matchInfluence,
3410
3692
  matchWhere: {
3411
3693
  ...sharedMatchWhere,
3412
3694
  ...Functions({
3413
3695
  'any user in same campus': userInCampus,
3414
3696
  'any user in same event': userInEvent,
3697
+ 'any user in same campus (with admins)': usersOfCampusWithAdmins,
3698
+ 'any user in same event (with admins)': usersInEventWithAdmins,
3415
3699
  }),
3416
3700
  },
3417
3701
  form: sharedTypesForm,
@@ -3438,6 +3722,7 @@ types.raidAuditorValidation = TypeObject({
3438
3722
  private: true,
3439
3723
  primary: true,
3440
3724
  label: 'Type',
3725
+ options: ['dedicated_auditors_for_event'],
3441
3726
  }),
3442
3727
  delay: types.adminAuditValidationDelay, // no limit to audit?
3443
3728
  required: types.adminAuditValidationRequired,
@@ -3519,6 +3804,18 @@ attrs.videos = {
3519
3804
  }),
3520
3805
  }
3521
3806
 
3807
+ attrs.legalText = {
3808
+ 'avatar-step': Literal(
3809
+ "Please make sure to upload a photograph that complies with the training center's internal regulations and standards of decency. This photo will be visible to the teaching staff to support essential individual academic monitoring, as well as to other learners to facilitate peer-to-peer collaboration. Any request for deletion or modification must be submitted to the management.",
3810
+ {
3811
+ type: 'string',
3812
+ label: 'Legal text',
3813
+ editable: false,
3814
+ required: true,
3815
+ },
3816
+ ),
3817
+ }
3818
+
3522
3819
  const getWeek = ({ attrs }) => {
3523
3820
  if (!attrs.startDay) return undefined // for exams in module for example
3524
3821
  const diff = attrs.startDay / 7
@@ -3556,7 +3853,7 @@ const sharedZeroXpIndex = Literal(0, {
3556
3853
  private: true,
3557
3854
  })
3558
3855
  const parentXpIndex = ({ parent }) => parent.attrs.xpIndex
3559
- const getXpIndex = ({ id, name, prev, parent, attrs, type }) => {
3856
+ const getXpIndex = ({ prev, parent }) => {
3560
3857
  if (prev) return (prev.attrs.xpIndex || 0) + (_difficultyXpCoef(prev) || 1)
3561
3858
  return parent?.attrs.xpIndex || 0
3562
3859
  }