diff --git a/contentcuration/contentcuration/constants/completion_criteria.py b/contentcuration/contentcuration/constants/completion_criteria.py index 1a8c101e38..2aabe53262 100644 --- a/contentcuration/contentcuration/constants/completion_criteria.py +++ b/contentcuration/contentcuration/constants/completion_criteria.py @@ -52,6 +52,7 @@ def _build_validator(): completion_criteria.APPROX_TIME, completion_criteria.REFERENCE, }, + content_kinds.TOPIC: {completion_criteria.MASTERY}, } diff --git a/contentcuration/contentcuration/tests/test_exportchannel.py b/contentcuration/contentcuration/tests/test_exportchannel.py index 5c850597d7..b87b310344 100644 --- a/contentcuration/contentcuration/tests/test_exportchannel.py +++ b/contentcuration/contentcuration/tests/test_exportchannel.py @@ -16,6 +16,7 @@ from kolibri_content.router import set_active_content_database from le_utils.constants import exercises from le_utils.constants import format_presets +from le_utils.constants import modalities from le_utils.constants.labels import accessibility_categories from le_utils.constants.labels import learning_activities from le_utils.constants.labels import levels @@ -304,6 +305,83 @@ def setUp(self): } first_topic_first_child.save() + # Add a UNIT topic with directly attached assessment items + unit_assessment_id_1 = uuid.uuid4().hex + unit_assessment_id_2 = uuid.uuid4().hex + + unit_topic = create_node( + {"kind_id": "topic", "title": "Test Unit Topic", "children": []}, + parent=self.content_channel.main_tree, + ) + unit_topic.extra_fields = { + "options": { + "modality": modalities.UNIT, + "completion_criteria": { + "model": "mastery", + "threshold": { + "mastery_model": exercises.PRE_POST_TEST, + "pre_post_test": { + "assessment_item_ids": [ + unit_assessment_id_1, + unit_assessment_id_2, + ], + "version_a_item_ids": [unit_assessment_id_1], + "version_b_item_ids": [unit_assessment_id_2], + }, + }, + }, + } + } + unit_topic.save() + + cc.AssessmentItem.objects.create( + contentnode=unit_topic, + assessment_id=unit_assessment_id_1, + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=json.dumps( + [ + {"answer": "4", "correct": True, "order": 1}, + {"answer": "3", "correct": False, "order": 2}, + ] + ), + hints=json.dumps([]), + raw_data="{}", + order=1, + randomize=False, + ) + + cc.AssessmentItem.objects.create( + contentnode=unit_topic, + assessment_id=unit_assessment_id_2, + type=exercises.SINGLE_SELECTION, + question="What is 3+3?", + answers=json.dumps( + [ + {"answer": "6", "correct": True, "order": 1}, + {"answer": "5", "correct": False, "order": 2}, + ] + ), + hints=json.dumps([]), + raw_data="{}", + order=2, + randomize=False, + ) + + # Add a LESSON child topic under the UNIT with a video child + lesson_topic = create_node( + { + "kind_id": "topic", + "title": "Test Lesson Topic", + "children": [ + {"kind_id": "video", "title": "Unit Lesson Video", "children": []}, + ], + }, + parent=unit_topic, + ) + lesson_topic.extra_fields = {"options": {"modality": modalities.LESSON}} + lesson_topic.save() + set_channel_icon_encoding(self.content_channel) self.tempdb = create_content_database( self.content_channel, True, self.admin_user.id, True @@ -348,6 +426,10 @@ def test_contentnode_incomplete_not_published(self): assert incomplete_nodes.count() > 0 for node in complete_nodes: + # Skip nodes that are known to fail validation and not be published: + # - "Bad mastery test" exercise has no mastery model (checked separately below) + if node.title == "Bad mastery test": + continue # if a parent node is incomplete, this node is excluded as well. if node.get_ancestors().filter(complete=False).count() == 0: assert kolibri_nodes.filter(pk=node.node_id).count() == 1 @@ -642,6 +724,30 @@ def test_qti_archive_contains_manifest_and_assessment_ids(self): for i, ai in enumerate(qti_exercise.assessment_items.order_by("order")): self.assertEqual(assessment_ids[i], hex_to_qti_id(ai.assessment_id)) + def test_unit_topic_publishes_with_exercise_zip(self): + """Test that a TOPIC node with UNIT modality gets its directly + attached assessment items compiled into a zip file during publishing.""" + unit_topic = cc.ContentNode.objects.get(title="Test Unit Topic") + + # Assert UNIT topic has exercise file in Studio + unit_files = cc.File.objects.filter( + contentnode=unit_topic, + preset_id=format_presets.EXERCISE, + ) + self.assertEqual( + unit_files.count(), + 1, + "UNIT topic should have exactly one exercise archive file", + ) + + # Assert NO assessment metadata in Kolibri export for UNIT topics + # UNIT topics store assessment config in options/completion_criteria instead + published_unit = kolibri_models.ContentNode.objects.get(title="Test Unit Topic") + self.assertFalse( + published_unit.assessmentmetadata.exists(), + "UNIT topic should NOT have assessment metadata", + ) + class EmptyChannelTestCase(StudioTestCase): @classmethod diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index 3e28f2d0e0..9e9e190105 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -35,6 +35,7 @@ from le_utils.constants import exercises from le_utils.constants import file_formats from le_utils.constants import format_presets +from le_utils.constants import modalities from le_utils.constants import roles from search.models import ChannelFullTextSearch from search.models import ContentNodeFullTextSearch @@ -227,6 +228,22 @@ def assign_license_to_contentcuration_nodes(channel, license): ] +def has_assessments(node): + """Check if a node should have its assessment items published. + + Returns True for EXERCISE nodes and TOPIC nodes with UNIT modality + that have assessment items. + """ + if node.kind_id == content_kinds.EXERCISE: + return True + if node.kind_id == content_kinds.TOPIC: + options = node.extra_fields.get("options", {}) if node.extra_fields else {} + if options.get("modality") == modalities.UNIT: + # Only return True if the UNIT has assessment items + return node.assessment_items.filter(deleted=False).exists() + return False + + class TreeMapper: def __init__( self, @@ -296,9 +313,10 @@ def recurse_nodes(self, node, inherited_fields): # noqa C901 # Only process nodes that are either non-topics or have non-topic descendants if node.is_publishable(): - # early validation to make sure we don't have any exercises without mastery models - # which should be unlikely when the node is complete, but just in case - if node.kind_id == content_kinds.EXERCISE: + # early validation to make sure we don't have any nodes with assessments + # without mastery models, which should be unlikely when the node is complete, + # but just in case + if has_assessments(node): try: # migrates and extracts the mastery model from the exercise _, mastery_model = parse_assessment_metadata(node) @@ -306,8 +324,8 @@ def recurse_nodes(self, node, inherited_fields): # noqa C901 raise ValueError("Exercise does not have a mastery model") except Exception as e: logging.warning( - "Unable to parse exercise {id} mastery model: {error}".format( - id=node.pk, error=str(e) + "Unable to parse exercise {id} {title} mastery model: {error}".format( + id=node.pk, title=node.title, error=str(e) ) ) return @@ -322,7 +340,7 @@ def recurse_nodes(self, node, inherited_fields): # noqa C901 metadata, ) - if node.kind_id == content_kinds.EXERCISE: + if has_assessments(node): exercise_data = process_assessment_metadata(node) any_free_response = any( t == exercises.FREE_RESPONSE @@ -359,10 +377,16 @@ def recurse_nodes(self, node, inherited_fields): # noqa C901 ) generator.create_exercise_archive() - create_kolibri_assessment_metadata(node, kolibrinode) + # Only create assessment metadata for exercises, not UNIT topics + # UNIT topics store their assessment config in options/completion_criteria + if node.kind_id == content_kinds.EXERCISE: + create_kolibri_assessment_metadata(node, kolibrinode) elif node.kind_id == content_kinds.SLIDESHOW: create_slideshow_manifest(node, user_id=self.user_id) - elif node.kind_id == content_kinds.TOPIC: + + # TOPIC nodes need to recurse into children, including UNIT topics + # that also had their assessments processed above + if node.kind_id == content_kinds.TOPIC: for child in node.children.all(): self.recurse_nodes(child, metadata) create_associated_file_objects(kolibrinode, node) diff --git a/requirements.in b/requirements.in index 27ca67281c..c86a1d37c3 100644 --- a/requirements.in +++ b/requirements.in @@ -5,7 +5,7 @@ djangorestframework==3.15.1 psycopg2-binary==2.9.10 django-js-reverse==0.10.2 django-registration==3.4 -le-utils>=0.2.12 +le-utils==0.2.14 gunicorn==23.0.0 django-postmark==0.1.6 jsonfield==3.1.0 diff --git a/requirements.txt b/requirements.txt index 9863c77097..2e227661d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -162,7 +162,7 @@ language-data==1.3.0 # via langcodes latex2mathml==3.78.0 # via -r requirements.in -le-utils==0.2.12 +le-utils==0.2.14 # via -r requirements.in marisa-trie==1.2.1 # via language-data