Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions contentcuration/contentcuration/constants/completion_criteria.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
from jsonschema.validators import validator_for
from le_utils.constants import completion_criteria
from le_utils.constants import content_kinds
from le_utils.constants import exercises
from le_utils.constants import mastery_criteria
from le_utils.constants import modalities


def _build_validator():
Expand Down Expand Up @@ -52,10 +54,11 @@ def _build_validator():
completion_criteria.APPROX_TIME,
completion_criteria.REFERENCE,
},
content_kinds.TOPIC: {completion_criteria.MASTERY},
}


def check_model_for_kind(data, kind):
def check_model_for_kind(data, kind, modality=None):
model = data.get("model")
if kind is None or model is None or kind not in ALLOWED_MODELS_PER_KIND:
return
Expand All @@ -68,11 +71,37 @@ def check_model_for_kind(data, kind):
)
)

if kind == content_kinds.TOPIC:
check_topic_completion_criteria(data, modality)

def validate(data, kind=None):

def check_topic_completion_criteria(data, modality):
"""
Validates topic-specific completion criteria rules:
- Topics can only have completion criteria if modality is UNIT
- Topics can only use PRE_POST_TEST mastery model
"""
# Topics can only have completion criteria with UNIT modality
if modality != modalities.UNIT:
raise ValidationError(
"Topics can only have completion criteria with UNIT modality"
)

# Topics can only use PRE_POST_TEST mastery model
threshold = data.get("threshold", {})
mastery_model = threshold.get("mastery_model")
if mastery_model is not None and mastery_model != exercises.PRE_POST_TEST:
raise ValidationError(
"mastery_model '{}' is invalid for topic content kind; "
"only '{}' is allowed".format(mastery_model, exercises.PRE_POST_TEST)
)


def validate(data, kind=None, modality=None):
"""
:param data: Dictionary of data to validate
:param kind: A str of the node content kind
:param modality: A str of the node modality (required for topics with completion criteria)
:raises: ValidationError: When invalid
"""
# empty dicts are okay
Expand Down Expand Up @@ -104,4 +133,4 @@ def validate(data, kind=None):
e.error_list.extend(error_descriptions)
raise e

check_model_for_kind(data, kind)
check_model_for_kind(data, kind, modality)
34 changes: 18 additions & 16 deletions contentcuration/contentcuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from le_utils.constants import file_formats
from le_utils.constants import format_presets
from le_utils.constants import languages
from le_utils.constants import modalities
from le_utils.constants import roles
from model_utils import FieldTracker
from mptt.models import MPTTModel
Expand Down Expand Up @@ -2290,22 +2291,23 @@ def mark_complete(self): # noqa C901
)
if not (self.extra_fields.get("mastery_model") or criterion):
errors.append("Missing mastery criterion")
if criterion:
try:
completion_criteria.validate(
criterion, kind=content_kinds.EXERCISE
)
except completion_criteria.ValidationError:
errors.append("Mastery criterion is defined but is invalid")
else:
criterion = self.extra_fields and self.extra_fields.get(
"options", {}
).get("completion_criteria", {})
if criterion:
try:
completion_criteria.validate(criterion, kind=self.kind_id)
except completion_criteria.ValidationError:
errors.append("Completion criterion is defined but is invalid")
options = self.extra_fields and self.extra_fields.get("options", {}) or {}
criterion = options.get("completion_criteria", {})
modality = options.get("modality")
# UNIT modality topics must have completion criteria
if (
self.kind_id == content_kinds.TOPIC
and modality == modalities.UNIT
and not criterion
):
errors.append("UNIT modality topics must have completion criteria")
if criterion:
try:
completion_criteria.validate(
criterion, kind=self.kind_id, modality=modality
)
except completion_criteria.ValidationError:
errors.append("Completion criterion is defined but is invalid")
self.complete = not errors
return errors

Expand Down
73 changes: 73 additions & 0 deletions contentcuration/contentcuration/tests/test_completion_criteria.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
from django.test import SimpleTestCase
from le_utils.constants import completion_criteria
from le_utils.constants import content_kinds
from le_utils.constants import exercises
from le_utils.constants import mastery_criteria
from le_utils.constants import modalities

from contentcuration.constants.completion_criteria import validate

Expand Down Expand Up @@ -40,3 +42,74 @@ def test_validate__content_kind(self):
},
kind=content_kinds.DOCUMENT,
)

def _make_preposttest_threshold(self):
"""Helper to create a valid pre_post_test threshold structure."""
# UUIDs must be 32 hex characters
uuid_a = "a" * 32
uuid_b = "b" * 32
return {
"mastery_model": exercises.PRE_POST_TEST,
"pre_post_test": {
"assessment_item_ids": [uuid_a, uuid_b],
"version_a_item_ids": [uuid_a],
"version_b_item_ids": [uuid_b],
},
}

def test_validate__topic_with_unit_modality_and_preposttest__success(self):
"""Topic with UNIT modality and PRE_POST_TEST mastery model should pass validation."""
validate(
{
"model": completion_criteria.MASTERY,
"threshold": self._make_preposttest_threshold(),
},
kind=content_kinds.TOPIC,
modality=modalities.UNIT,
)

def test_validate__topic_with_unit_modality_and_wrong_mastery_model__fail(self):
"""Topic with UNIT modality but non-PRE_POST_TEST mastery model should fail."""
with self.assertRaisesRegex(
ValidationError, "mastery_model.*invalid for.*topic"
):
validate(
{
"model": completion_criteria.MASTERY,
"threshold": {
"mastery_model": mastery_criteria.M_OF_N,
"m": 3,
"n": 5,
},
},
kind=content_kinds.TOPIC,
modality=modalities.UNIT,
)

def test_validate__topic_with_non_unit_modality_and_completion_criteria__fail(self):
"""Topic with non-UNIT modality (e.g., LESSON) should not have completion criteria."""
with self.assertRaisesRegex(
ValidationError, "only.*completion criteria.*UNIT modality"
):
validate(
{
"model": completion_criteria.MASTERY,
"threshold": self._make_preposttest_threshold(),
},
kind=content_kinds.TOPIC,
modality=modalities.LESSON,
)

def test_validate__topic_with_no_modality_and_completion_criteria__fail(self):
"""Topic with no modality should not have completion criteria."""
with self.assertRaisesRegex(
ValidationError, "only.*completion criteria.*UNIT modality"
):
validate(
{
"model": completion_criteria.MASTERY,
"threshold": self._make_preposttest_threshold(),
},
kind=content_kinds.TOPIC,
modality=None,
)
107 changes: 107 additions & 0 deletions contentcuration/contentcuration/tests/test_contentnodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from le_utils.constants import content_kinds
from le_utils.constants import exercises
from le_utils.constants import format_presets
from le_utils.constants import modalities
from mixer.backend.django import mixer
from mock import patch

Expand Down Expand Up @@ -1481,3 +1482,109 @@ def test_create_video_null_extra_fields(self):
new_obj.mark_complete()
except AttributeError:
self.fail("Null extra_fields not handled")

def _make_preposttest_extra_fields(self, modality):
"""Helper to create extra_fields with valid pre_post_test completion criteria."""
uuid_a = "a" * 32
uuid_b = "b" * 32
return {
"options": {
"modality": modality,
"completion_criteria": {
"model": completion_criteria.MASTERY,
"threshold": {
"mastery_model": exercises.PRE_POST_TEST,
"pre_post_test": {
"assessment_item_ids": [uuid_a, uuid_b],
"version_a_item_ids": [uuid_a],
"version_b_item_ids": [uuid_b],
},
},
},
}
}

def test_create_topic_unit_modality_valid_preposttest_complete(self):
"""Topic with UNIT modality and valid PRE_POST_TEST completion criteria should be complete."""
channel = testdata.channel()
new_obj = ContentNode(
title="Unit Topic",
kind_id=content_kinds.TOPIC,
parent=channel.main_tree,
extra_fields=self._make_preposttest_extra_fields(modalities.UNIT),
)
new_obj.save()
new_obj.mark_complete()
self.assertTrue(new_obj.complete)

def test_create_topic_unit_modality_wrong_mastery_model_incomplete(self):
"""Topic with UNIT modality but M_OF_N mastery model should be incomplete."""
channel = testdata.channel()
new_obj = ContentNode(
title="Unit Topic",
kind_id=content_kinds.TOPIC,
parent=channel.main_tree,
extra_fields={
"options": {
"modality": modalities.UNIT,
"completion_criteria": {
"model": completion_criteria.MASTERY,
"threshold": {
"mastery_model": exercises.M_OF_N,
"m": 3,
"n": 5,
},
},
}
},
)
new_obj.save()
new_obj.mark_complete()
self.assertFalse(new_obj.complete)

def test_create_topic_lesson_modality_with_completion_criteria_incomplete(self):
"""Topic with LESSON modality should not have completion criteria."""
channel = testdata.channel()
new_obj = ContentNode(
title="Lesson Topic",
kind_id=content_kinds.TOPIC,
parent=channel.main_tree,
extra_fields=self._make_preposttest_extra_fields(modalities.LESSON),
)
new_obj.save()
new_obj.mark_complete()
self.assertFalse(new_obj.complete)

def test_create_topic_no_modality_with_completion_criteria_incomplete(self):
"""Topic with no modality should not have completion criteria."""
channel = testdata.channel()
extra_fields = self._make_preposttest_extra_fields(modalities.UNIT)
# Remove the modality
del extra_fields["options"]["modality"]
new_obj = ContentNode(
title="Topic Without Modality",
kind_id=content_kinds.TOPIC,
parent=channel.main_tree,
extra_fields=extra_fields,
)
new_obj.save()
new_obj.mark_complete()
self.assertFalse(new_obj.complete)

def test_create_topic_unit_modality_without_completion_criteria_incomplete(self):
"""Topic with UNIT modality MUST have completion criteria - it's not optional."""
channel = testdata.channel()
new_obj = ContentNode(
title="Unit Topic Without Criteria",
kind_id=content_kinds.TOPIC,
parent=channel.main_tree,
extra_fields={
"options": {
"modality": modalities.UNIT,
# No completion_criteria
}
},
)
new_obj.save()
new_obj.mark_complete()
self.assertFalse(new_obj.complete)
Loading