Skip to content

Commit ebececf

Browse files
committed
Conditional publishing of QTI archives instead of perseus.
1 parent fd1f1ac commit ebececf

3 files changed

Lines changed: 220 additions & 14 deletions

File tree

contentcuration/contentcuration/tests/test_exportchannel.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from kolibri_content.router import get_active_content_database
1616
from kolibri_content.router import set_active_content_database
1717
from le_utils.constants import exercises
18+
from le_utils.constants import format_presets
1819
from le_utils.constants.labels import accessibility_categories
1920
from le_utils.constants.labels import learning_activities
2021
from le_utils.constants.labels import levels
@@ -33,6 +34,7 @@
3334
from .testdata import tree
3435
from contentcuration import models as cc
3536
from contentcuration.models import CustomTaskMetadata
37+
from contentcuration.utils.assessment.qti.archive import hex_to_qti_id
3638
from contentcuration.utils.celery.tasks import generate_task_signature
3739
from contentcuration.utils.publish import ChannelIncompleteError
3840
from contentcuration.utils.publish import convert_channel_thumbnail
@@ -209,6 +211,48 @@ def setUp(self):
209211
ai.contentnode = legacy_exercise
210212
ai.save()
211213

214+
# Add an exercise with free response question to test QTI generation
215+
qti_extra_fields = {
216+
"options": {
217+
"completion_criteria": {
218+
"model": "mastery",
219+
"threshold": {
220+
"m": 1,
221+
"n": 2,
222+
"mastery_model": exercises.M_OF_N,
223+
},
224+
}
225+
}
226+
}
227+
qti_exercise = create_node(
228+
{
229+
"kind_id": "exercise",
230+
"title": "QTI Free Response Exercise",
231+
"extra_fields": qti_extra_fields,
232+
}
233+
)
234+
qti_exercise.complete = True
235+
qti_exercise.parent = current_exercise.parent
236+
qti_exercise.save()
237+
238+
# Create a free response assessment item
239+
cc.AssessmentItem.objects.create(
240+
contentnode=qti_exercise,
241+
assessment_id=uuid.uuid4().hex,
242+
type=exercises.FREE_RESPONSE,
243+
question="What is the capital of France?",
244+
answers=json.dumps([{"answer": "Paris", "correct": True}]),
245+
hints=json.dumps([]),
246+
raw_data="{}",
247+
order=4,
248+
randomize=False,
249+
)
250+
251+
for ai in current_exercise.assessment_items.all()[:2]:
252+
ai.id = None
253+
ai.contentnode = qti_exercise
254+
ai.save()
255+
212256
first_topic = self.content_channel.main_tree.get_descendants().first()
213257

214258
# Add a publishable topic to ensure it does not inherit but that its children do
@@ -558,6 +602,46 @@ def test_publish_no_modify_legacy_exercise_extra_fields(self):
558602
{"mastery_model": exercises.M_OF_N, "randomize": True, "m": 1, "n": 2},
559603
)
560604

605+
def test_qti_exercise_generates_qti_archive(self):
606+
"""Test that exercises with free response questions generate QTI archive files."""
607+
qti_exercise = cc.ContentNode.objects.get(title="QTI Free Response Exercise")
608+
609+
# Check that a QTI archive file was created
610+
qti_files = qti_exercise.files.filter(preset_id=format_presets.QTI_ZIP)
611+
self.assertEqual(
612+
qti_files.count(),
613+
1,
614+
"QTI exercise should have exactly one QTI archive file",
615+
)
616+
617+
qti_file = qti_files.first()
618+
self.assertIsNotNone(
619+
qti_file.file_on_disk, "QTI file should have file_on_disk content"
620+
)
621+
self.assertTrue(
622+
qti_file.original_filename.endswith(".zip"),
623+
"QTI file should be a zip archive",
624+
)
625+
626+
def test_qti_archive_contains_manifest_and_assessment_ids(self):
627+
628+
published_qti_exercise = kolibri_models.ContentNode.objects.get(
629+
title="QTI Free Response Exercise"
630+
)
631+
assessment_ids = (
632+
published_qti_exercise.assessmentmetadata.first().assessment_item_ids
633+
)
634+
635+
# Should have exactly one assessment ID corresponding to our free response question
636+
self.assertEqual(
637+
len(assessment_ids), 3, "Should have exactly three assessment IDs"
638+
)
639+
640+
# The assessment ID should match the one from our assessment item
641+
qti_exercise = cc.ContentNode.objects.get(title="QTI Free Response Exercise")
642+
for i, ai in enumerate(qti_exercise.assessment_items.order_by("order")):
643+
self.assertEqual(assessment_ids[i], hex_to_qti_id(ai.assessment_id))
644+
561645

562646
class EmptyChannelTestCase(StudioTestCase):
563647
@classmethod

contentcuration/contentcuration/utils/assessment/qti/imsmanifest.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
import re
2+
import zipfile
13
from typing import Annotated
24
from typing import List
35
from typing import Optional
6+
from xml.etree import ElementTree as ET
47

58
from pydantic import Field
69

710
from contentcuration.utils.assessment.qti.base import generate_coerced_string_type
811
from contentcuration.utils.assessment.qti.base import TextType
912
from contentcuration.utils.assessment.qti.base import XMLElement
13+
from contentcuration.utils.assessment.qti.constants import ResourceType
1014

1115

1216
IMSCPIdentifier = Annotated[
@@ -100,3 +104,74 @@ class Manifest(XMLElement):
100104
organizations: Organizations = Field(default_factory=Organizations)
101105
resources: Resources = Field(default_factory=Resources)
102106
manifests: List["Manifest"] = Field(default_factory=list)
107+
108+
109+
def _get_item_ids_from_assessment_test(zip_file, test_href):
110+
"""Extract assessment item identifiers from an assessment test file."""
111+
try:
112+
with zip_file.open(test_href) as test_file:
113+
test_content = test_file.read()
114+
test_root = ET.fromstring(test_content)
115+
116+
# Look for both item references and inline items
117+
qti_ns = {"qti": "http://www.imsglobal.org/xsd/imsqti_v3p0"}
118+
item_refs = test_root.findall(".//qti:qti-assessment-item-ref", qti_ns)
119+
# TODO: Add handling for assessment sections and assessment section refs.
120+
121+
all_items = list(item_refs)
122+
123+
return [
124+
item.get("identifier") for item in all_items if item.get("identifier")
125+
]
126+
except (KeyError, ET.ParseError):
127+
return []
128+
129+
130+
namespace_re = re.compile("\\{([^}]+)\\}")
131+
132+
133+
def get_assessment_ids_from_manifest(zip_file_handle):
134+
try:
135+
with zipfile.ZipFile(zip_file_handle, "r") as zip_file:
136+
137+
# Read and parse the manifest
138+
with zip_file.open("imsmanifest.xml") as manifest_file:
139+
manifest_content = manifest_file.read()
140+
141+
# Parse the XML
142+
root = ET.fromstring(manifest_content)
143+
144+
namespace = namespace_re.search(root.tag).group(1)
145+
146+
# Define namespace map for IMS Content Packaging
147+
namespaces = {"imscp": namespace}
148+
149+
# Find all resources
150+
resources = root.findall(".//imscp:resource", namespaces)
151+
152+
assessment_ids = []
153+
154+
# First, collect direct assessment item resources
155+
for resource in resources:
156+
resource_type = resource.get("type", "")
157+
resource_identifier = resource.get("identifier")
158+
if (
159+
resource_type == ResourceType.ASSESSMENT_ITEM.value
160+
and resource_identifier
161+
):
162+
assessment_ids.append(resource_identifier)
163+
164+
if resource_type == ResourceType.ASSESSMENT_TEST.value:
165+
assessment_ids.extend(
166+
_get_item_ids_from_assessment_test(
167+
zip_file, resource.get("href")
168+
)
169+
)
170+
171+
return assessment_ids
172+
except ET.ParseError:
173+
raise ValueError("Invalid XML in manifest")
174+
except zipfile.BadZipFile:
175+
raise ValueError("File is not a valid zip archive")
176+
except KeyError:
177+
raise ValueError("No IMS Manifest found in zip file")

contentcuration/contentcuration/utils/publish.py

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@
4444
from contentcuration import models as ccmodels
4545
from contentcuration.decorators import delay_user_storage_calculation
4646
from contentcuration.utils.assessment.perseus import PerseusExerciseGenerator
47+
from contentcuration.utils.assessment.qti.archive import QTIExerciseGenerator
48+
from contentcuration.utils.assessment.qti.imsmanifest import (
49+
get_assessment_ids_from_manifest,
50+
)
4751
from contentcuration.utils.cache import delete_public_channel_cache_keys
4852
from contentcuration.utils.files import create_thumbnail_from_base64
4953
from contentcuration.utils.files import get_thumbnail_encoding
@@ -319,20 +323,43 @@ def recurse_nodes(self, node, inherited_fields): # noqa C901
319323
)
320324

321325
if node.kind_id == content_kinds.EXERCISE:
322-
exercise_data = process_assessment_metadata(node, kolibrinode)
326+
exercise_data = process_assessment_metadata(node)
327+
any_free_response = any(
328+
t == exercises.FREE_RESPONSE
329+
for t in exercise_data["assessment_mapping"].values()
330+
)
331+
generator_class = (
332+
QTIExerciseGenerator
333+
if any_free_response
334+
else PerseusExerciseGenerator
335+
)
336+
337+
# If this exercise previously had a file generated by a different
338+
# generator, make sure we clean it up here.
339+
stale_presets = {
340+
PerseusExerciseGenerator.preset,
341+
QTIExerciseGenerator.preset,
342+
} - {generator_class.preset}
343+
344+
# Remove archives produced by the previously-used generator
345+
node.files.filter(preset_id__in=stale_presets).delete()
346+
323347
if (
324348
self.force_exercises
325349
or node.changed
326-
or not node.files.filter(preset_id=format_presets.EXERCISE).exists()
350+
or not node.files.filter(preset_id=generator_class.preset).exists()
327351
):
328-
generator = PerseusExerciseGenerator(
352+
353+
generator = generator_class(
329354
node,
330355
exercise_data,
331356
self.channel_id,
332357
self.default_language.lang_code,
333358
user_id=self.user_id,
334359
)
335360
generator.create_exercise_archive()
361+
362+
create_kolibri_assessment_metadata(node, kolibrinode)
336363
elif node.kind_id == content_kinds.SLIDESHOW:
337364
create_slideshow_manifest(node, user_id=self.user_id)
338365
elif node.kind_id == content_kinds.TOPIC:
@@ -625,11 +652,7 @@ def parse_assessment_metadata(ccnode):
625652
)
626653

627654

628-
def process_assessment_metadata(ccnode, kolibrinode):
629-
# Get mastery model information, set to default if none provided
630-
assessment_items = ccnode.assessment_items.all().order_by("order")
631-
assessment_item_ids = [a.assessment_id for a in assessment_items]
632-
655+
def _get_exercise_data_from_ccnode(ccnode, num_assessment_items):
633656
randomize, mastery_criteria = parse_assessment_metadata(ccnode)
634657

635658
exercise_data = deepcopy(mastery_criteria)
@@ -638,14 +661,14 @@ def process_assessment_metadata(ccnode, kolibrinode):
638661
mastery_model = {"type": exercise_data_type or exercises.M_OF_N}
639662
if mastery_model["type"] == exercises.M_OF_N:
640663
mastery_model.update(
641-
{"n": exercise_data.get("n") or min(5, assessment_items.count()) or 1}
664+
{"n": exercise_data.get("n") or min(5, num_assessment_items) or 1}
642665
)
643666
mastery_model.update(
644-
{"m": exercise_data.get("m") or min(5, assessment_items.count()) or 1}
667+
{"m": exercise_data.get("m") or min(5, num_assessment_items) or 1}
645668
)
646669
elif mastery_model["type"] == exercises.DO_ALL:
647670
mastery_model.update(
648-
{"n": assessment_items.count() or 1, "m": assessment_items.count() or 1}
671+
{"n": num_assessment_items or 1, "m": num_assessment_items or 1}
649672
)
650673
elif mastery_model["type"] == exercises.NUM_CORRECT_IN_A_ROW_2:
651674
mastery_model.update({"n": 2, "m": 2})
@@ -655,6 +678,17 @@ def process_assessment_metadata(ccnode, kolibrinode):
655678
mastery_model.update({"n": 5, "m": 5})
656679
elif mastery_model["type"] == exercises.NUM_CORRECT_IN_A_ROW_10:
657680
mastery_model.update({"n": 10, "m": 10})
681+
return randomize, exercise_data, mastery_model
682+
683+
684+
def process_assessment_metadata(ccnode):
685+
# Get mastery model information, set to default if none provided
686+
assessment_items = ccnode.assessment_items.all().order_by("order")
687+
assessment_item_ids = [a.assessment_id for a in assessment_items]
688+
689+
randomize, exercise_data, mastery_model = _get_exercise_data_from_ccnode(
690+
ccnode, len(assessment_item_ids)
691+
)
658692

659693
exercise_data.update(
660694
{
@@ -673,18 +707,31 @@ def process_assessment_metadata(ccnode, kolibrinode):
673707
}
674708
)
675709

710+
return exercise_data
711+
712+
713+
def create_kolibri_assessment_metadata(ccnode, kolibrinode):
714+
assessment_items = ccnode.assessment_items.all().order_by("order")
715+
assessment_item_ids = [a.assessment_id for a in assessment_items]
716+
randomize, _, mastery_model = _get_exercise_data_from_ccnode(
717+
ccnode, len(assessment_item_ids)
718+
)
719+
qti_file = ccnode.files.filter(preset_id=format_presets.QTI_ZIP).first()
720+
if qti_file:
721+
# Open the zip file from Django storage
722+
with qti_file.file_on_disk.open("rb") as file_handle:
723+
assessment_item_ids = get_assessment_ids_from_manifest(file_handle)
724+
676725
kolibrimodels.AssessmentMetaData.objects.create(
677726
id=uuid.uuid4(),
678727
contentnode=kolibrinode,
679728
assessment_item_ids=assessment_item_ids,
680-
number_of_assessments=assessment_items.count(),
729+
number_of_assessments=len(assessment_item_ids),
681730
mastery_model=mastery_model,
682731
randomize=randomize,
683732
is_manipulable=ccnode.kind_id == content_kinds.EXERCISE,
684733
)
685734

686-
return exercise_data
687-
688735

689736
def map_prerequisites(root_node):
690737

0 commit comments

Comments
 (0)