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
248 changes: 241 additions & 7 deletions contentcuration/contentcuration/tests/test_exportchannel.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import io
import json
import os
import random
Expand Down Expand Up @@ -53,6 +54,7 @@
from contentcuration.utils.publish import convert_channel_thumbnail
from contentcuration.utils.publish import create_content_database
from contentcuration.utils.publish import create_draft_channel_version
from contentcuration.utils.publish import create_kolibri_assessment_metadata
from contentcuration.utils.publish import create_slideshow_manifest
from contentcuration.utils.publish import fill_published_fields
from contentcuration.utils.publish import map_prerequisites
Expand Down Expand Up @@ -85,6 +87,9 @@
# Perseus input questions are numeric-only, so this publishes QTI only.
STRING_ENTRY_QTI_ITEM = _text_item("single", ["cat"], base_type="string")

# Legacy input answers Perseus can't render as a finite number.
UNREADABLE_PERSEUS_INPUT_ANSWERS = ["Sphere", "+3", "1e400"]


# Larger than the signed 32-bit maximum (2_147_483_647); ~3 GB.
LARGE_FILE_SIZE = 3 * 1024 ** 3
Expand Down Expand Up @@ -455,6 +460,85 @@ def setUp(self):
randomize=False,
)

# Perseus packaging with an input question that has no accepted answer
perseus_answerless_input_exercise = create_node(
{
"kind_id": "exercise",
"title": "Perseus Answerless Input Exercise",
"extra_fields": qti_extra_fields,
}
)
perseus_answerless_input_exercise.complete = True
perseus_answerless_input_exercise.parent = current_exercise.parent
perseus_answerless_input_exercise.save()
cc.AssessmentItem.objects.create(
contentnode=perseus_answerless_input_exercise,
assessment_id=uuid.uuid4().hex,
type=exercises.PERSEUS_QUESTION,
question="",
answers="[]",
hints="[]",
raw_data="{}",
order=1,
randomize=False,
)
cc.AssessmentItem.objects.create(
contentnode=perseus_answerless_input_exercise,
assessment_id=uuid.uuid4().hex,
type=exercises.INPUT_QUESTION,
question="What is 2+3?",
answers=json.dumps([{"answer": "", "correct": True, "order": 1}]),
hints="[]",
raw_data="{}",
order=2,
randomize=False,
)

# Dual-published: derivable native QTI item plus an answerless input question
native_qti_answerless_input_exercise = create_node(
{
"kind_id": "exercise",
"title": "Native QTI Answerless Input Exercise",
"extra_fields": qti_extra_fields,
}
)
native_qti_answerless_input_exercise.complete = True
native_qti_answerless_input_exercise.parent = current_exercise.parent
native_qti_answerless_input_exercise.save()
cc.AssessmentItem.objects.create(
contentnode=native_qti_answerless_input_exercise,
assessment_id=uuid.uuid4().hex,
type=exercises.QTI,
question="",
answers="[]",
hints="[]",
raw_data=VALID_CHOICE_ITEM,
order=1,
randomize=False,
)
cc.AssessmentItem.objects.create(
contentnode=native_qti_answerless_input_exercise,
assessment_id=uuid.uuid4().hex,
type=exercises.INPUT_QUESTION,
question="What is 2+3?",
answers=json.dumps([{"answer": "", "correct": True, "order": 1}]),
hints="[]",
raw_data="{}",
order=2,
randomize=False,
)
cc.AssessmentItem.objects.create(
contentnode=native_qti_answerless_input_exercise,
assessment_id=uuid.uuid4().hex,
type=exercises.INPUT_QUESTION,
question="What is 2+3?",
answers=json.dumps([{"answer": "5", "correct": True, "order": 1}]),
hints="[]",
raw_data="{}",
order=3,
randomize=False,
)

# A node mixing a native QTI item with a raw perseus_question item ->
# must route to a single QTI package (Perseus embedded as custom
# interactions), not a separate Perseus archive.
Expand Down Expand Up @@ -525,6 +609,41 @@ def setUp(self):
randomize=False,
)

# Mixed nodes whose legacy input answer Perseus cannot read -> QTI only
for answer in UNREADABLE_PERSEUS_INPUT_ANSWERS:
unreadable_input_exercise = create_node(
{
"kind_id": "exercise",
"title": f"Native QTI + Input {answer!r} Mixed Exercise",
"extra_fields": qti_extra_fields,
}
)
unreadable_input_exercise.complete = True
unreadable_input_exercise.parent = current_exercise.parent
unreadable_input_exercise.save()
cc.AssessmentItem.objects.create(
contentnode=unreadable_input_exercise,
assessment_id=uuid.uuid4().hex,
type=exercises.QTI,
question="",
answers="[]",
hints="[]",
raw_data=VALID_CHOICE_ITEM,
order=1,
randomize=False,
)
cc.AssessmentItem.objects.create(
contentnode=unreadable_input_exercise,
assessment_id=uuid.uuid4().hex,
type=exercises.INPUT_QUESTION,
question="What is the answer?",
answers=json.dumps([{"answer": answer, "correct": True, "order": 1}]),
hints=json.dumps([]),
raw_data="{}",
order=2,
randomize=False,
)

first_topic = self.content_channel.main_tree.get_descendants().first()

# Add a publishable topic to ensure it does not inherit but that its children do
Expand Down Expand Up @@ -1104,18 +1223,24 @@ def item_contents(title):
self.assertTrue(commented)
self.assertEqual(commented, item_contents("Native QTI Exercise"))

def test_native_qti_perseus_ids_match_assessment_metadata(self):
"""The derived Perseus item JSON filenames must equal the ids recorded
in the published node's ``AssessmentMetaData.assessment_item_ids`` (the
QTI manifest ``K``-ids), so older Kolibri resolves the derived items."""
node = cc.ContentNode.objects.get(title="Native QTI Exercise")
def _read_perseus_archive(self, node):
exercise_file = node.files.get(preset_id=format_presets.EXERCISE)
with exercise_file.file_on_disk.open("rb") as file_handle:
with exercise_file.file_on_disk.open("rb") as file_handle, zipfile.ZipFile(
file_handle
) as archive:
item_stems = {
name[: -len(".json")]
for name in zipfile.ZipFile(file_handle).namelist()
for name in archive.namelist()
if name.endswith(".json") and name != "exercise.json"
}
return item_stems, json.loads(archive.read("exercise.json"))

def test_native_qti_perseus_ids_match_assessment_metadata(self):
"""The derived Perseus item JSON filenames must equal the ids recorded
in the published node's ``AssessmentMetaData.assessment_item_ids`` (the
QTI manifest ``K``-ids), so older Kolibri resolves the derived items."""
node = cc.ContentNode.objects.get(title="Native QTI Exercise")
item_stems, _ = self._read_perseus_archive(node)

published_node = kolibri_models.ContentNode.objects.get(
title="Native QTI Exercise"
Expand Down Expand Up @@ -1154,6 +1279,33 @@ def test_mixed_native_legacy_perseus_ids_match_assessment_metadata(self):
set(exercise_data["assessment_mapping"]), set(assessment_item_ids)
)

def test_dual_published_numeric_inputs_have_answers(self):
dual_published = cc.ContentNode.objects.filter(
files__preset_id=format_presets.QTI_ZIP
).filter(files__preset_id=format_presets.EXERCISE)
mixed_answers = []
for node in dual_published:
exercise_file = node.files.get(preset_id=format_presets.EXERCISE)
with exercise_file.file_on_disk.open("rb") as file_handle, zipfile.ZipFile(
file_handle
) as archive:
for name in archive.namelist():
if not name.endswith(".json") or name == "exercise.json":
continue
widgets = json.loads(archive.read(name))["question"]["widgets"]
for widget in widgets.values():
if widget["type"] != "numeric-input":
continue
answers = [
answer["value"] for answer in widget["options"]["answers"]
]
with self.subTest(node=node.title, item=name):
self.assertTrue(answers)
if node.title == "Native QTI + Legacy Mixed Exercise":
mixed_answers.append(answers)

self.assertEqual(mixed_answers, [[4]])

def test_native_qti_item_declares_the_node_language(self):
"""The editor has no language of its own to write, so publishing supplies it.

Expand Down Expand Up @@ -1196,6 +1348,19 @@ def test_native_qti_string_entry_publishes_qti_only(self):
self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
self.assertFalse(node.files.filter(preset_id=format_presets.EXERCISE).exists())

def test_mixed_native_unreadable_input_publishes_qti_only(self):
for answer in UNREADABLE_PERSEUS_INPUT_ANSWERS:
with self.subTest(answer=answer):
node = cc.ContentNode.objects.get(
title=f"Native QTI + Input {answer!r} Mixed Exercise"
)
self.assertTrue(
node.files.filter(preset_id=format_presets.QTI_ZIP).exists()
)
self.assertFalse(
node.files.filter(preset_id=format_presets.EXERCISE).exists()
)

def test_legacy_items_without_perseus_question_route_to_qti_packaging(self):
node = cc.ContentNode.objects.get(title="Legacy No Perseus Exercise")
self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
Expand All @@ -1206,6 +1371,75 @@ def test_perseus_question_item_routes_to_perseus_packaging(self):
self.assertTrue(node.files.filter(preset_id=format_presets.EXERCISE).exists())
self.assertFalse(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())

def test_answerless_input_left_out_of_perseus_archive_and_metadata(self):
node = cc.ContentNode.objects.get(title="Perseus Answerless Input Exercise")
perseus_id = node.assessment_items.get(
type=exercises.PERSEUS_QUESTION
).assessment_id
self.assertFalse(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
item_stems, exercise_data = self._read_perseus_archive(node)
self.assertEqual(item_stems, {perseus_id})
self.assertEqual(exercise_data["all_assessment_items"], [perseus_id])

published_node = kolibri_models.ContentNode.objects.get(
title="Perseus Answerless Input Exercise"
)
self.assertEqual(
published_node.assessmentmetadata.first().assessment_item_ids,
[perseus_id],
)

def test_unchanged_republish_metadata_leaves_out_answerless_input(self):
# An unchanged node keeps its archive, which may predate the skip.
node = cc.ContentNode.objects.get(title="Perseus Answerless Input Exercise")
all_ids = list(
node.assessment_items.order_by("order").values_list(
"assessment_id", flat=True
)
)
stale_archive = io.BytesIO()
with zipfile.ZipFile(stale_archive, "w") as archive:
archive.writestr(
"exercise.json", json.dumps({"all_assessment_items": all_ids})
)
for assessment_id in all_ids:
archive.writestr(f"{assessment_id}.json", "{}")
node.files.filter(preset_id=format_presets.EXERCISE).delete()
stale_file = create_studio_file(
stale_archive.getvalue(), preset=format_presets.EXERCISE, ext="perseus"
)["db_file"]
stale_file.contentnode = node
stale_file.save()
published_node = kolibri_models.ContentNode.objects.get(
title="Perseus Answerless Input Exercise"
)
published_node.assessmentmetadata.all().delete()

create_kolibri_assessment_metadata(node, published_node)

self.assertEqual(
published_node.assessmentmetadata.get().assessment_item_ids,
[all_ids[0]],
)

def test_answerless_input_left_out_of_dual_published_archives(self):
node = cc.ContentNode.objects.get(title="Native QTI Answerless Input Exercise")
answerless_id = node.assessment_items.get(
type=exercises.INPUT_QUESTION, order=2
).assessment_id
item_stems, exercise_data = self._read_perseus_archive(node)
self.assertEqual(len(item_stems), 2)
self.assertNotIn(hex_to_qti_id(answerless_id), item_stems)
self.assertEqual(set(exercise_data["all_assessment_items"]), item_stems)

published_node = kolibri_models.ContentNode.objects.get(
title="Native QTI Answerless Input Exercise"
)
self.assertEqual(
set(published_node.assessmentmetadata.first().assessment_item_ids),
item_stems,
)

def test_mixed_perseus_and_native_qti_routes_to_qti(self):
node = cc.ContentNode.objects.get(title="Perseus + Native QTI Mixed Exercise")
self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
Expand Down
Loading
Loading