Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def _build_validator():
completion_criteria.APPROX_TIME,
completion_criteria.REFERENCE,
},
content_kinds.TOPIC: {completion_criteria.MASTERY},
}


Expand Down
106 changes: 106 additions & 0 deletions contentcuration/contentcuration/tests/test_exportchannel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 32 additions & 8 deletions contentcuration/contentcuration/utils/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -296,18 +313,19 @@ 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)
if not mastery_model:
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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down