Skip to content

Commit 5c34bdb

Browse files
committed
feat: convert still-legacy assessment items to QTI on read
consolidate() replaces each legacy row's type and raw_data with the converter's output, so the client only ever sees QTI. QTI and perseus_question rows pass through. Items are tagged with the content node's language, falling back to the channel's, matching what publish writes. An item that cannot be converted is logged and left as the legacy row it is, so one bad row does not cost the whole contentnode__in list; the editor renders it as unsupported. Goes away with the global backfill (#6007).
1 parent 1578bcb commit 5c34bdb

2 files changed

Lines changed: 275 additions & 0 deletions

File tree

‎contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py‎

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from contentcuration.tests.viewsets.base import generate_delete_event
1515
from contentcuration.tests.viewsets.base import generate_update_event
1616
from contentcuration.tests.viewsets.base import SyncTestMixin
17+
from contentcuration.utils.assessment.qti.validation import validate_qti_item
1718
from contentcuration.viewsets.sync.constants import ASSESSMENTITEM
1819

1920

@@ -35,6 +36,14 @@
3536
"</qti-choice-interaction>",
3637
)
3738

39+
CHOICE_ANSWERS = json.dumps(
40+
[
41+
{"answer": "4", "correct": True, "order": 1},
42+
{"answer": "5", "correct": False, "order": 2},
43+
]
44+
)
45+
TEXT_ANSWERS = json.dumps([{"answer": "4", "correct": True, "order": 1}])
46+
3847

3948
class SyncTestCase(SyncTestMixin, StudioAPITestCase):
4049
@property
@@ -1177,6 +1186,220 @@ def test_delete_assessmentitem(self):
11771186
self.assertEqual(response.status_code, 405, response.content)
11781187

11791188

1189+
class DualReadTestCase(StudioAPITestCase):
1190+
def setUp(self):
1191+
super(DualReadTestCase, self).setUp()
1192+
self.channel = testdata.channel()
1193+
self.user = testdata.user()
1194+
self.channel.editors.add(self.user)
1195+
self.node = (
1196+
self.channel.main_tree.get_descendants()
1197+
.filter(kind_id=content_kinds.EXERCISE)
1198+
.first()
1199+
)
1200+
self.client.force_authenticate(user=self.user)
1201+
1202+
def _create_item(self, node=None, **kwargs):
1203+
return models.AssessmentItem.objects.create(
1204+
contentnode=node or self.node, assessment_id=uuid.uuid4().hex, **kwargs
1205+
)
1206+
1207+
def _list_items(self, **query):
1208+
# The fixture node carries its own assessment items, so key the response
1209+
# by assessment_id rather than indexing it.
1210+
response = self.client.get(reverse("assessmentitem-list"), query)
1211+
self.assertEqual(response.status_code, 200, response.content)
1212+
return {item["assessment_id"]: item for item in response.json()}
1213+
1214+
def _get_item(self, assessment_id):
1215+
return self._list_items(contentnode=self.node.id)[assessment_id]
1216+
1217+
def test_supported_legacy_types_returned_as_qti(self):
1218+
cases = [
1219+
(exercises.SINGLE_SELECTION, CHOICE_ANSWERS, "qti-choice-interaction"),
1220+
(exercises.MULTIPLE_SELECTION, CHOICE_ANSWERS, "qti-choice-interaction"),
1221+
("true_false", CHOICE_ANSWERS, "qti-choice-interaction"),
1222+
(exercises.INPUT_QUESTION, TEXT_ANSWERS, "qti-text-entry-interaction"),
1223+
(exercises.FREE_RESPONSE, TEXT_ANSWERS, "qti-text-entry-interaction"),
1224+
]
1225+
created = {
1226+
item_type: self._create_item(
1227+
type=item_type,
1228+
question="What is 2+2?",
1229+
answers=answers,
1230+
hints=json.dumps([{"hint": "Count.", "order": 1}]),
1231+
).assessment_id
1232+
for item_type, answers, _ in cases
1233+
}
1234+
1235+
items = self._list_items(contentnode=self.node.id)
1236+
1237+
for item_type, _, interaction in cases:
1238+
with self.subTest(type=item_type):
1239+
item = items[created[item_type]]
1240+
self.assertEqual(item["type"], exercises.QTI)
1241+
self.assertTrue(validate_qti_item(item["raw_data"]).is_valid)
1242+
self.assertIn(interaction, item["raw_data"])
1243+
1244+
def test_answerless_choice_item_is_returned_as_valid_qti(self):
1245+
# The shape the editor writes for every newly added question.
1246+
assessment_id = self._create_item(type=exercises.SINGLE_SELECTION).assessment_id
1247+
1248+
item = self._get_item(assessment_id)
1249+
1250+
self.assertEqual(item["type"], exercises.QTI)
1251+
self.assertTrue(validate_qti_item(item["raw_data"]).is_valid)
1252+
1253+
def test_converted_item_has_no_legacy_field_content(self):
1254+
assessment_id = self._create_item(
1255+
type=exercises.SINGLE_SELECTION,
1256+
question="What is 2+2?",
1257+
answers=CHOICE_ANSWERS,
1258+
hints=json.dumps([{"hint": "Count.", "order": 1}]),
1259+
).assessment_id
1260+
1261+
item = self._get_item(assessment_id)
1262+
1263+
self.assertEqual(item["question"], "")
1264+
self.assertEqual(item["answers"], "[]")
1265+
self.assertEqual(item["hints"], "[]")
1266+
# The node language is only in the values tuple to feed the conversion.
1267+
self.assertNotIn("contentnode__language__lang_code", item)
1268+
1269+
def test_converted_items_are_tagged_with_their_own_node_language(self):
1270+
# A contentnode__in read spans several nodes, so each item must pick up its own
1271+
# node's language. pt-BR's bare lang_code differs from its primary key, so the
1272+
# assertions also pin which of the two publish tags an item with.
1273+
self.node.language = models.Language.objects.get(id="pt-BR")
1274+
self.node.save()
1275+
other_node = models.ContentNode.objects.create(
1276+
id=uuid.uuid4().hex,
1277+
title="Exercise 2",
1278+
kind_id=content_kinds.EXERCISE,
1279+
parent=self.node.parent,
1280+
language=models.Language.objects.get(id="fr"),
1281+
)
1282+
first = self._create_item(
1283+
type=exercises.SINGLE_SELECTION,
1284+
question="What is 2+2?",
1285+
answers=CHOICE_ANSWERS,
1286+
)
1287+
second = self._create_item(
1288+
node=other_node,
1289+
type=exercises.SINGLE_SELECTION,
1290+
question="What is 3+3?",
1291+
answers=CHOICE_ANSWERS,
1292+
)
1293+
1294+
items = self._list_items(
1295+
contentnode__in=f"{self.node.id},{other_node.id}",
1296+
)
1297+
1298+
self.assertIn('xml:lang="pt"', items[first.assessment_id]["raw_data"])
1299+
self.assertNotIn("pt-BR", items[first.assessment_id]["raw_data"])
1300+
self.assertIn('xml:lang="fr"', items[second.assessment_id]["raw_data"])
1301+
1302+
def test_converted_item_falls_back_to_the_channel_language(self):
1303+
# What publish does for a node with no language of its own.
1304+
self.channel.language = models.Language.objects.get(id="pt-BR")
1305+
self.channel.save()
1306+
assessment_id = self._create_item(
1307+
type=exercises.SINGLE_SELECTION,
1308+
question="What is 2+2?",
1309+
answers=CHOICE_ANSWERS,
1310+
).assessment_id
1311+
1312+
item = self._get_item(assessment_id)
1313+
1314+
self.assertIn('xml:lang="pt"', item["raw_data"])
1315+
self.assertNotIn("pt-BR", item["raw_data"])
1316+
1317+
def test_converted_item_defaults_to_english_without_any_language(self):
1318+
self.channel.language = None
1319+
self.channel.save()
1320+
assessment_id = self._create_item(
1321+
type=exercises.SINGLE_SELECTION,
1322+
question="What is 2+2?",
1323+
answers=CHOICE_ANSWERS,
1324+
).assessment_id
1325+
1326+
item = self._get_item(assessment_id)
1327+
1328+
self.assertIn('xml:lang="en"', item["raw_data"])
1329+
1330+
def test_perseus_question_returned_unchanged(self):
1331+
raw_data = '{"question": {"content": "raw perseus"}}'
1332+
assessment_id = self._create_item(
1333+
type=exercises.PERSEUS_QUESTION, raw_data=raw_data
1334+
).assessment_id
1335+
1336+
item = self._get_item(assessment_id)
1337+
1338+
self.assertEqual(item["type"], exercises.PERSEUS_QUESTION)
1339+
self.assertEqual(item["raw_data"], raw_data)
1340+
# A passed-through row must shed the node language too.
1341+
self.assertNotIn("contentnode__language__lang_code", item)
1342+
1343+
def test_native_qti_item_returned_unchanged(self):
1344+
assessment_id = self._create_item(
1345+
type=exercises.QTI, raw_data=VALID_CHOICE_ITEM
1346+
).assessment_id
1347+
1348+
item = self._get_item(assessment_id)
1349+
1350+
self.assertEqual(item["type"], exercises.QTI)
1351+
self.assertEqual(item["raw_data"], VALID_CHOICE_ITEM)
1352+
1353+
def test_detail_route_converts(self):
1354+
assessmentitem = self._create_item(
1355+
type=exercises.SINGLE_SELECTION,
1356+
question="What is 2+2?",
1357+
answers=CHOICE_ANSWERS,
1358+
)
1359+
1360+
response = self.client.get(
1361+
reverse("assessmentitem-detail", kwargs={"pk": assessmentitem.id})
1362+
)
1363+
1364+
self.assertEqual(response.status_code, 200, response.content)
1365+
self.assertEqual(response.json()["type"], exercises.QTI)
1366+
self.assertTrue(validate_qti_item(response.json()["raw_data"]).is_valid)
1367+
1368+
def test_unconvertible_item_is_left_legacy_and_the_rest_convert(self):
1369+
unconvertible = self._create_item(
1370+
type="not_a_real_type", question="What is 2+2?"
1371+
)
1372+
convertible = self._create_item(
1373+
type=exercises.SINGLE_SELECTION,
1374+
question="What is 3+3?",
1375+
answers=CHOICE_ANSWERS,
1376+
)
1377+
1378+
with self.assertLogs(
1379+
"contentcuration.viewsets.assessmentitem", level="ERROR"
1380+
) as logs:
1381+
items = self._list_items(contentnode=self.node.id)
1382+
1383+
self.assertIn(unconvertible.assessment_id, logs.output[0])
1384+
self.assertEqual(items[unconvertible.assessment_id]["type"], "not_a_real_type")
1385+
self.assertEqual(items[unconvertible.assessment_id]["question"], "What is 2+2?")
1386+
self.assertEqual(items[convertible.assessment_id]["type"], exercises.QTI)
1387+
1388+
def test_unconvertible_item_is_not_a_404_on_detail_route(self):
1389+
# Only this route goes through serialize_object(), which turns an error raised
1390+
# here into "No AssessmentItem matches the given query".
1391+
assessmentitem = self._create_item(
1392+
type="not_a_real_type", question="What is 2+2?"
1393+
)
1394+
1395+
response = self.client.get(
1396+
reverse("assessmentitem-detail", kwargs={"pk": assessmentitem.id})
1397+
)
1398+
1399+
self.assertEqual(response.status_code, 200, response.content)
1400+
self.assertEqual(response.json()["type"], "not_a_real_type")
1401+
1402+
11801403
class ContentIDTestCase(SyncTestMixin, StudioAPITestCase):
11811404
def setUp(self):
11821405
super(ContentIDTestCase, self).setUp()

‎contentcuration/contentcuration/viewsets/assessmentitem.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import json
2+
import logging
23
import re
34

45
from django.db import transaction
6+
from django.db.models import OuterRef
7+
from django.db.models import Subquery
58
from le_utils.constants import exercises
69
from le_utils.constants import format_presets
710
from rest_framework import serializers
811
from rest_framework.permissions import IsAuthenticated
912
from rest_framework.serializers import ValidationError
1013

1114
from contentcuration.models import AssessmentItem
15+
from contentcuration.models import Channel
1216
from contentcuration.models import ContentNode
1317
from contentcuration.models import File
1418
from contentcuration.models import generate_object_storage_name
19+
from contentcuration.utils.assessment.qti.ingest import convert_legacy_question_to_qti
1520
from contentcuration.utils.assessment.qti.media import get_qti_media_references
1621
from contentcuration.utils.assessment.qti.validation import validate_qti_item
1722
from contentcuration.viewsets.base import BulkCreateMixin
@@ -25,12 +30,18 @@
2530
from contentcuration.viewsets.common import UUIDRegexField
2631

2732

33+
logger = logging.getLogger(__name__)
34+
2835
exercise_image_filename_regex = re.compile(
2936
r"\!\[[^]]*\]\(\${placeholder}/([a-f0-9]{{32}}\.[0-9a-z]+)\)".format(
3037
placeholder=exercises.CONTENT_STORAGE_PLACEHOLDER
3138
)
3239
)
3340

41+
# Everything else is a legacy type, converted to QTI on read until the global
42+
# backfill (#6007) makes that permanent and consolidate() goes away.
43+
PASSTHROUGH_TYPES = (exercises.QTI, exercises.PERSEUS_QUESTION)
44+
3445

3546
class AssessmentItemFilter(RequiredFilterSet):
3647
contentnode__in = UUIDInFilter(field_name="contentnode")
@@ -332,8 +343,49 @@ class AssessmentItemViewSet(BulkCreateMixin, BulkUpdateMixin, ValuesViewset):
332343
"source_url",
333344
"randomize",
334345
"deleted",
346+
# Only consumed by consolidate(), which pops them back off. Publish tags an
347+
# item with the bare lang_code of its content node's language, falling back to
348+
# the channel's (utils/assessment/qti/archive.py), so the read path matches.
349+
"contentnode__language__lang_code",
350+
"channel_lang_code",
335351
)
336352

337353
field_map = {
338354
"contentnode": "contentnode_id",
339355
}
356+
357+
def annotate_queryset(self, queryset):
358+
return queryset.annotate(
359+
channel_lang_code=Subquery(
360+
Channel.objects.filter(
361+
main_tree__tree_id=OuterRef("contentnode__tree_id")
362+
).values("language__lang_code")[:1]
363+
)
364+
)
365+
366+
def consolidate(self, items, queryset):
367+
for item in items:
368+
node_language = item.pop("contentnode__language__lang_code")
369+
channel_language = item.pop("channel_lang_code")
370+
language = node_language or channel_language
371+
if item["type"] in PASSTHROUGH_TYPES:
372+
continue
373+
try:
374+
# A new dict, so the language does not leak into the response.
375+
result = convert_legacy_question_to_qti(dict(item, language=language))
376+
except Exception:
377+
# One item Studio cannot represent in QTI must not take out every other
378+
# item on the node, so leave it as the legacy row it is; the editor
379+
# renders a non-QTI item as unsupported.
380+
logger.exception(
381+
"Could not convert assessment item %s to QTI", item["assessment_id"]
382+
)
383+
continue
384+
item.update(
385+
type=exercises.QTI,
386+
raw_data=result.xml,
387+
question="",
388+
answers="[]",
389+
hints="[]",
390+
)
391+
return items

0 commit comments

Comments
 (0)