Skip to content

Commit f11eaa7

Browse files
authored
Merge pull request #3318 from bjester/completion-criteria
Support adding completion criteria and validating its structure
2 parents db94302 + 8431cec commit f11eaa7

File tree

20 files changed

+413
-39
lines changed

20 files changed

+413
-39
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import jsonschema
2+
from django.core.exceptions import ValidationError
3+
from le_utils.constants import completion_criteria
4+
from le_utils.constants import content_kinds
5+
6+
7+
def validate(data, kind=None):
8+
"""
9+
:param data: Dictionary of data to validate
10+
:param kind: A str of the node content kind
11+
:raises: ValidationError: When invalid
12+
"""
13+
# empty dicts are okay
14+
if isinstance(data, (dict,)) and not data:
15+
return
16+
17+
try:
18+
jsonschema.validate(instance=data, schema=completion_criteria.SCHEMA)
19+
except jsonschema.ValidationError as e:
20+
raise ValidationError("Completion criteria does not conform to schema") from e
21+
22+
model = data.get("model")
23+
if kind is None or model is None:
24+
return
25+
26+
# validate that content kind is allowed for the completion criteria model
27+
if (model == completion_criteria.PAGES and kind != content_kinds.DOCUMENT) or (
28+
model == completion_criteria.MASTERY and kind != content_kinds.EXERCISE
29+
):
30+
raise ValidationError(
31+
"Completion criteria model '{}' is invalid for content kind '{}'".format(
32+
model, kind
33+
)
34+
)

contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@
317317
import Checkbox from 'shared/views/form/Checkbox';
318318
import { ContentKindsNames } from 'shared/leUtils/ContentKinds';
319319
import { NEW_OBJECT, FeatureFlagKeys, ContentModalities } from 'shared/constants';
320+
import { validate as validateCompletionCriteria } from 'shared/leUtils/CompletionCriteria';
320321
321322
// Define an object to act as the place holder for non unique values.
322323
const nonUniqueValue = {};
@@ -497,6 +498,23 @@
497498
this.updateExtraFields({ options });
498499
},
499500
},
501+
// TODO remove eslint disable when `completionCriteria` is utilized
502+
/* eslint-disable-next-line kolibri/vue-no-unused-properties */
503+
completionCriteria: {
504+
get() {
505+
const options = this.getExtraFieldsValueFromNodes('options') || {};
506+
return options.completion_criteria || {};
507+
},
508+
set(completion_criteria) {
509+
// TODO Remove validation if unnecessary after implementing `completionCriteria`
510+
if (validateCompletionCriteria(completion_criteria)) {
511+
const options = { completion_criteria };
512+
this.updateExtraFields({ options });
513+
} else {
514+
console.warn('Invalid completion criteria', [...validateCompletionCriteria.errors]);
515+
}
516+
},
517+
},
500518
501519
/* COMPUTED PROPS */
502520
disableAuthEdits() {

contentcuration/contentcuration/frontend/shared/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export const ValidationErrors = {
152152
QUESTION_REQUIRED: 'QUESTION_REQUIRED',
153153
INVALID_NUMBER_OF_CORRECT_ANSWERS: 'INVALID_NUMBER_OF_CORRECT_ANSWERS',
154154
NO_VALID_PRIMARY_FILES: 'NO_VALID_PRIMARY_FILES',
155+
INVALID_COMPLETION_CRITERIA_MODEL: 'INVALID_COMPLETION_CRITERIA_MODEL',
155156
...fileErrors,
156157
};
157158

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import CompletionCriteriaModels, { SCHEMA } from 'kolibri-constants/CompletionCriteria';
2+
import { ContentKindsNames } from 'shared/leUtils/ContentKinds';
3+
import { compile } from 'shared/utils/jsonSchema';
4+
import { ValidationErrors } from 'shared/constants';
5+
6+
/**
7+
* @type {Function<boolean>|ValidateFunction}
8+
*/
9+
const _validate = compile(SCHEMA);
10+
11+
/**
12+
* @param {Object} criteria
13+
* @param {String} contentKind
14+
* @return {boolean}
15+
*/
16+
export function validate(criteria, contentKind = null) {
17+
// Mimic ajv validation for simplicity
18+
validate.errors = [];
19+
20+
if (!_validate(criteria)) {
21+
validate.errors = _validate.errors;
22+
return false;
23+
}
24+
25+
// Validate certain models only apply to certain content kinds
26+
if (
27+
(criteria.model === CompletionCriteriaModels.PAGES &&
28+
contentKind !== ContentKindsNames.DOCUMENT) ||
29+
(criteria.model === CompletionCriteriaModels.MASTERY &&
30+
contentKind !== ContentKindsNames.EXERCISE)
31+
) {
32+
validate.errors = [ValidationErrors.INVALID_COMPLETION_CRITERIA_MODEL];
33+
return false;
34+
}
35+
36+
return true;
37+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Ajv from 'ajv';
2+
3+
const ajv = new Ajv({
4+
keywords: [
5+
{
6+
keyword: '$exportConstants',
7+
schemaType: 'string',
8+
},
9+
],
10+
});
11+
12+
export function compile(schema) {
13+
return ajv.compile(schema);
14+
}

contentcuration/contentcuration/frontend/shared/utils/validation.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import get from 'lodash/get';
12
import { AssessmentItemTypes, ValidationErrors } from '../constants';
23
import translator from '../translator';
34
import Licenses from 'shared/leUtils/Licenses';
45
import { MasteryModelsNames } from 'shared/leUtils/MasteryModels';
56
import { ContentKindsNames } from 'shared/leUtils/ContentKinds';
7+
import { validate as validateCompletionCriteria } from 'shared/leUtils/CompletionCriteria';
68

79
/**
810
* Topic and resource
@@ -29,6 +31,10 @@ import { ContentKindsNames } from 'shared/leUtils/ContentKinds';
2931
* It must have at least one question
3032
* A question must have right answers
3133
*
34+
* Non-topics
35+
* ----------
36+
* Completion criteria is validated to ensure it conforms to the schema
37+
* and is valid for the content kind
3238
*/
3339
export function isNodeComplete({ nodeDetails, assessmentItems, files }) {
3440
if (!nodeDetails) {
@@ -56,6 +62,12 @@ export function isNodeComplete({ nodeDetails, assessmentItems, files }) {
5662
return false;
5763
}
5864
}
65+
if (nodeDetails.kind !== ContentKindsNames.TOPIC) {
66+
const completionCriteria = get(nodeDetails, 'extra_fields.options.completion_criteria');
67+
if (completionCriteria && !validateCompletionCriteria(completionCriteria, nodeDetails.kind)) {
68+
return false;
69+
}
70+
}
5971
if (nodeDetails.kind === ContentKindsNames.EXERCISE) {
6072
if (!assessmentItems.length) {
6173
return false;

contentcuration/contentcuration/frontend/shared/utils/validation.spec.js

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import each from 'jest-each';
2-
2+
import CompletionCriteriaModels from 'kolibri-constants/CompletionCriteria';
33
import { AssessmentItemTypes, ValidationErrors } from '../constants';
44
import {
55
translateValidator,
@@ -349,6 +349,12 @@ describe('channelEdit utils', () => {
349349
license: { id: 8 },
350350
extra_fields: {
351351
mastery_model: MasteryModelsNames.DO_ALL,
352+
options: {
353+
completion_criteria: {
354+
model: CompletionCriteriaModels.TIME,
355+
threshold: 10,
356+
},
357+
},
352358
},
353359
};
354360
assessmentItems = [
@@ -400,11 +406,43 @@ describe('channelEdit utils', () => {
400406
).toBe(false);
401407
});
402408

409+
it('returns false if completion_criteria is invalid', () => {
410+
const details = {
411+
...nodeDetails,
412+
extra_fields: {
413+
...nodeDetails.extra_fields,
414+
options: {
415+
completion_criteria: {
416+
// pages model not allowed for exercise
417+
model: CompletionCriteriaModels.PAGES,
418+
threshold: 12,
419+
},
420+
},
421+
},
422+
};
423+
expect(isNodeComplete({ nodeDetails: details, assessmentItems })).toBe(false);
424+
});
425+
403426
it(`
404427
returns true if node details are valid,
405428
there is at least one assessment items,
406429
and all assessment items are valid`, () => {
407-
expect(isNodeComplete({ nodeDetails, assessmentItems })).toBe(true);
430+
const details = {
431+
...nodeDetails,
432+
extra_fields: {
433+
...nodeDetails.extra_fields,
434+
options: {
435+
completion_criteria: {
436+
// mastery model only allowed for exercise
437+
model: CompletionCriteriaModels.MASTERY,
438+
threshold: {
439+
mastery_model: 'do_all',
440+
},
441+
},
442+
},
443+
},
444+
};
445+
expect(isNodeComplete({ nodeDetails: details, assessmentItems })).toBe(true);
408446
});
409447
});
410448

@@ -419,6 +457,14 @@ describe('channelEdit utils', () => {
419457
title: 'A node',
420458
license: { id: 8 },
421459
kind,
460+
extra_fields: {
461+
options: {
462+
completion_criteria: {
463+
model: CompletionCriteriaModels.TIME,
464+
threshold: 10,
465+
},
466+
},
467+
},
422468
};
423469
files = [
424470
{
@@ -456,6 +502,39 @@ describe('channelEdit utils', () => {
456502
expect(isNodeComplete({ nodeDetails, files: [...files, invalidFile] })).toBe(false);
457503
});
458504

505+
it('returns false if completion_criteria is invalid', () => {
506+
const details = {
507+
...nodeDetails,
508+
extra_fields: {
509+
options: {
510+
completion_criteria: {
511+
model: 'pages',
512+
threshold: -1,
513+
},
514+
},
515+
},
516+
};
517+
expect(isNodeComplete({ nodeDetails: details, files })).toBe(false);
518+
});
519+
520+
it('returns false if completion_criteria is invalid for kind', () => {
521+
const details = {
522+
...nodeDetails,
523+
extra_fields: {
524+
options: {
525+
completion_criteria: {
526+
// mastery model only allowed for exercise
527+
model: CompletionCriteriaModels.MASTERY,
528+
threshold: {
529+
mastery_model: 'do_all',
530+
},
531+
},
532+
},
533+
},
534+
};
535+
expect(isNodeComplete({ nodeDetails: details, files })).toBe(false);
536+
});
537+
459538
it('returns true if node details and all files are valid', () => {
460539
expect(isNodeComplete({ nodeDetails, files })).toBe(true);
461540
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Django 3.2.5 on 2022-02-23 18:42
2+
from django.db import migrations
3+
from django.db import models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("contentcuration", "0133_auto_20220124_2149"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="contentkind",
15+
name="kind",
16+
field=models.CharField(
17+
choices=[
18+
("topic", "Topic"),
19+
("video", "Video"),
20+
("audio", "Audio"),
21+
("exercise", "Exercise"),
22+
("document", "Document"),
23+
("html5", "HTML5 App"),
24+
("slideshow", "Slideshow"),
25+
("h5p", "H5P"),
26+
("zim", "Zim"),
27+
("quiz", "Quiz"),
28+
],
29+
max_length=200,
30+
primary_key=True,
31+
serialize=False,
32+
),
33+
),
34+
]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from django.core.exceptions import ValidationError
2+
from django.test import SimpleTestCase
3+
from le_utils.constants import completion_criteria
4+
from le_utils.constants import content_kinds
5+
6+
from contentcuration.constants.completion_criteria import validate
7+
8+
9+
class CompletionCriteriaTestCase(SimpleTestCase):
10+
def test_validate__success(self):
11+
validate({"model": completion_criteria.REFERENCE, "learner_managed": True})
12+
13+
def test_validate__success__empty(self):
14+
validate({})
15+
16+
def test_validate__fail(self):
17+
with self.assertRaises(ValidationError):
18+
validate({"model": "does not exist"})
19+
20+
def test_validate__content_kind(self):
21+
with self.assertRaises(ValidationError):
22+
validate({"model": completion_criteria.PAGES}, kind=content_kinds.EXERCISE)
23+
with self.assertRaises(ValidationError):
24+
validate({"model": completion_criteria.MASTERY}, kind=content_kinds.DOCUMENT)

contentcuration/contentcuration/tests/test_serializers.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import absolute_import
22

33
from django.db.models.query import QuerySet
4+
from django.test.testcases import SimpleTestCase
45

56
from .base import BaseAPITestCase
67
from contentcuration.models import Channel
@@ -9,6 +10,7 @@
910
from contentcuration.viewsets.channel import ChannelSerializer as BaseChannelSerializer
1011
from contentcuration.viewsets.common import ContentDefaultsSerializer
1112
from contentcuration.viewsets.contentnode import ContentNodeSerializer
13+
from contentcuration.viewsets.contentnode import ExtraFieldsOptionsSerializer
1214

1315

1416
def ensure_no_querysets_in_serializer(object):
@@ -21,6 +23,27 @@ def ensure_no_querysets_in_serializer(object):
2123
)
2224

2325

26+
class ExtraFieldsOptionsSerializerTestCase(SimpleTestCase):
27+
def setUp(self):
28+
super(ExtraFieldsOptionsSerializerTestCase, self).setUp()
29+
self.data = dict(modality="QUIZ")
30+
31+
@property
32+
def serializer(self):
33+
return ExtraFieldsOptionsSerializer(data=self.data)
34+
35+
def test_no_completion_criteria(self):
36+
self.assertTrue(self.serializer.is_valid())
37+
38+
def test_completion_criteria__valid(self):
39+
self.data.update(completion_criteria={"model": "time", "threshold": 10, "learner_managed": True})
40+
self.assertTrue(self.serializer.is_valid())
41+
42+
def test_completion_criteria__invalid(self):
43+
self.data.update(completion_criteria={"model": "time", "threshold": "test"})
44+
self.assertFalse(self.serializer.is_valid())
45+
46+
2447
class ContentNodeSerializerTestCase(BaseAPITestCase):
2548
def test_repr_doesnt_evaluate_querysets(self):
2649
node_ids = [

0 commit comments

Comments
 (0)