Skip to content
Merged
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
8 changes: 5 additions & 3 deletions contentcuration/contentcuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2250,11 +2250,13 @@ def mark_complete(self): # noqa C901
| (
# A non-blank question
~Q(question="")
# Non-blank answers
& ~Q(answers="[]")
# With either an input question or one answer marked as correct
# Non-blank answers, unless it is a free response question
# (which is allowed to have no answers)
& (~Q(answers="[]") | Q(type=exercises.FREE_RESPONSE))
# With either an input or free response question or one answer marked as correct
& (
Q(type=exercises.INPUT_QUESTION)
| Q(type=exercises.FREE_RESPONSE)
| Q(answers__iregex=r'"correct":\s*true')
)
)
Expand Down
23 changes: 23 additions & 0 deletions contentcuration/contentcuration/tests/test_contentnodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1223,6 +1223,29 @@ def test_create_exercise_invalid_assessment_item_no_answers(self):
new_obj.mark_complete()
self.assertFalse(new_obj.complete)

def test_create_exercise_valid_assessment_item_free_response_no_answers(self):
licenses = list(
License.objects.filter(
copyright_holder_required=False, is_custom=False
).values_list("pk", flat=True)
)
channel = testdata.channel()
new_obj = ContentNode(
title="yes",
kind_id=content_kinds.EXERCISE,
parent=channel.main_tree,
license_id=licenses[0],
extra_fields=self.new_extra_fields,
)
new_obj.save()
AssessmentItem.objects.create(
contentnode=new_obj,
question="This is a question",
type=exercises.FREE_RESPONSE,
)
new_obj.mark_complete()
self.assertTrue(new_obj.complete)

def test_create_exercise_invalid_assessment_item_no_correct_answers(self):
licenses = list(
License.objects.filter(
Expand Down
86 changes: 85 additions & 1 deletion contentcuration/contentcuration/tests/test_exportchannel.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from kolibri_content.router import get_active_content_database
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.labels import accessibility_categories
from le_utils.constants.labels import learning_activities
from le_utils.constants.labels import levels
Expand All @@ -33,6 +34,7 @@
from .testdata import tree
from contentcuration import models as cc
from contentcuration.models import CustomTaskMetadata
from contentcuration.utils.assessment.qti.archive import hex_to_qti_id
from contentcuration.utils.celery.tasks import generate_task_signature
from contentcuration.utils.publish import ChannelIncompleteError
from contentcuration.utils.publish import convert_channel_thumbnail
Expand Down Expand Up @@ -209,6 +211,48 @@ def setUp(self):
ai.contentnode = legacy_exercise
ai.save()

# Add an exercise with free response question to test QTI generation
qti_extra_fields = {
"options": {
"completion_criteria": {
"model": "mastery",
"threshold": {
"m": 1,
"n": 2,
"mastery_model": exercises.M_OF_N,
},
}
}
}
qti_exercise = create_node(
{
"kind_id": "exercise",
"title": "QTI Free Response Exercise",
"extra_fields": qti_extra_fields,
}
)
qti_exercise.complete = True
qti_exercise.parent = current_exercise.parent
qti_exercise.save()

# Create a free response assessment item
cc.AssessmentItem.objects.create(
contentnode=qti_exercise,
assessment_id=uuid.uuid4().hex,
type=exercises.FREE_RESPONSE,
question="What is the capital of France?",
answers=json.dumps([{"answer": "Paris", "correct": True}]),
hints=json.dumps([]),
raw_data="{}",
order=4,
randomize=False,
)

for ai in current_exercise.assessment_items.all()[:2]:
ai.id = None
ai.contentnode = qti_exercise
ai.save()

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 @@ -400,7 +444,7 @@ def test_inherited_language(self):
parent_id=first_topic_node_id
)[1:]:
if child.kind == "topic":
self.assertIsNone(child.lang_id)
self.assertEqual(child.lang_id, self.content_channel.language_id)
self.assertEqual(child.children.first().lang_id, "fr")
else:
self.assertEqual(child.lang_id, "fr")
Expand Down Expand Up @@ -558,6 +602,46 @@ def test_publish_no_modify_legacy_exercise_extra_fields(self):
{"mastery_model": exercises.M_OF_N, "randomize": True, "m": 1, "n": 2},
)

def test_qti_exercise_generates_qti_archive(self):
"""Test that exercises with free response questions generate QTI archive files."""
qti_exercise = cc.ContentNode.objects.get(title="QTI Free Response Exercise")

# Check that a QTI archive file was created
qti_files = qti_exercise.files.filter(preset_id=format_presets.QTI_ZIP)
self.assertEqual(
qti_files.count(),
1,
"QTI exercise should have exactly one QTI archive file",
)

qti_file = qti_files.first()
self.assertIsNotNone(
qti_file.file_on_disk, "QTI file should have file_on_disk content"
)
self.assertTrue(
qti_file.original_filename.endswith(".zip"),
"QTI file should be a zip archive",
)

def test_qti_archive_contains_manifest_and_assessment_ids(self):

published_qti_exercise = kolibri_models.ContentNode.objects.get(
title="QTI Free Response Exercise"
)
assessment_ids = (
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

confirm that the published assessment_item_ids in the channel database have been properly coerced to our QTI compatible ids.

published_qti_exercise.assessmentmetadata.first().assessment_item_ids
)

# Should have exactly one assessment ID corresponding to our free response question
self.assertEqual(
len(assessment_ids), 3, "Should have exactly three assessment IDs"
)

# The assessment ID should match the one from our assessment item
qti_exercise = cc.ContentNode.objects.get(title="QTI Free Response Exercise")
for i, ai in enumerate(qti_exercise.assessment_items.order_by("order")):
self.assertEqual(assessment_ids[i], hex_to_qti_id(ai.assessment_id))


class EmptyChannelTestCase(StudioTestCase):
@classmethod
Expand Down
4 changes: 3 additions & 1 deletion contentcuration/contentcuration/tests/testdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,9 @@ def tree(parent=None):

def channel(name="testchannel"):
channel_creator = user()
channel = cc.Channel.objects.create(name=name, actor_id=channel_creator.id)
channel = cc.Channel.objects.create(
name=name, actor_id=channel_creator.id, language_id="en"
)
channel.save()

channel.main_tree = tree()
Expand Down
Empty file.
Loading