diff --git a/contentcuration/contentcuration/tests/viewsets/test_contentnode.py b/contentcuration/contentcuration/tests/viewsets/test_contentnode.py index d27b4304d9..184a1c544f 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_contentnode.py +++ b/contentcuration/contentcuration/tests/viewsets/test_contentnode.py @@ -210,7 +210,7 @@ def test_deadlock_move_and_rebuild(self): for (node_id, target_id) in zip( root_children_ids, first_child_node_children_ids ) - ) + ), ) for result in results: @@ -236,7 +236,7 @@ def test_deadlock_create_and_rebuild(self): *( (create_contentnode, {"parent_id": node_id}) for node_id in first_child_node_children_ids - ) + ), ) for result in results: @@ -1903,6 +1903,132 @@ def test_delete_no_permission_prerequisite(self): self.assertEqual(len(response.data["disallowed"]), 1) self.assertTrue(contentnode.prerequisite.filter(id=prereq.id).exists()) + def test_create_html5_contentnode_with_entry_validation(self): + """ + Regression test for HTML5 nodes validation failure when entry value is set in extra_fields. + + This test verifies that newly created HTML5 content nodes with an "entry" value + in extra_fields.options.entry can be successfully validated and created. + """ + contentnode_data = self.contentnode_metadata + contentnode_data["kind"] = content_kinds.HTML5 + contentnode_data["extra_fields"] = {"options": {"entry": "index.html"}} + + response = self.sync_changes( + [ + generate_create_event( + contentnode_data["id"], + CONTENTNODE, + contentnode_data, + channel_id=self.channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + len(response.data.get("errors", [])), + 0, + f"Expected no validation errors, but got: {response.data.get('errors', [])}", + ) + + try: + new_node = models.ContentNode.objects.get(id=contentnode_data["id"]) + except models.ContentNode.DoesNotExist: + self.fail("HTML5 ContentNode with entry value was not created") + + self.assertEqual(new_node.parent_id, self.channel.main_tree_id) + self.assertEqual(new_node.kind_id, content_kinds.HTML5) + self.assertEqual(new_node.extra_fields["options"]["entry"], "index.html") + + def test_create_exercise_contentnode_requires_randomize(self): + """ + Test that exercise content nodes require the randomize field in extra_fields. + """ + contentnode_data = self.contentnode_metadata + contentnode_data["kind"] = content_kinds.EXERCISE + # Deliberately omit randomize field + contentnode_data["extra_fields"] = {"options": {}} + + response = self.sync_changes( + [ + generate_create_event( + contentnode_data["id"], + CONTENTNODE, + contentnode_data, + channel_id=self.channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(len(response.data.get("errors", [])), 1) + + error = response.data["errors"][0] + + self.assertIn("randomize", error["errors"]["extra_fields"]) + self.assertEqual( + error["errors"]["extra_fields"]["randomize"][0], + "This field is required for exercise content.", + ) + + def test_create_exercise_contentnode_with_randomize_succeeds(self): + """ + Test that exercise content nodes with randomize field are created successfully. + """ + contentnode_data = self.contentnode_metadata + contentnode_data["kind"] = content_kinds.EXERCISE + contentnode_data["extra_fields"] = {"randomize": True, "options": {}} + + response = self.sync_changes( + [ + generate_create_event( + contentnode_data["id"], + CONTENTNODE, + contentnode_data, + channel_id=self.channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(len(response.data.get("errors", [])), 0) + + try: + new_node = models.ContentNode.objects.get(id=contentnode_data["id"]) + except models.ContentNode.DoesNotExist: + self.fail("Exercise ContentNode with randomize field was not created") + + self.assertEqual(new_node.kind_id, content_kinds.EXERCISE) + self.assertTrue(new_node.extra_fields["randomize"]) + + def test_cannot_update_contentnode_kind(self): + """ + Test that content node kind cannot be changed after creation. + """ + contentnode = models.ContentNode.objects.create(**self.contentnode_db_metadata) + original_kind = contentnode.kind_id + + response = self.sync_changes( + [ + generate_update_event( + contentnode.id, + CONTENTNODE, + {"kind": content_kinds.HTML5}, + channel_id=self.channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(len(response.data.get("errors", [])), 1) + + error = response.data["errors"][0] + self.assertIn("kind", error["errors"]) + self.assertEqual( + error["errors"]["kind"][0], "Content kind cannot be changed after creation" + ) + + # Verify kind was not changed + contentnode.refresh_from_db() + self.assertEqual(contentnode.kind_id, original_kind) + class CRUDTestCase(StudioAPITestCase): def setUp(self): diff --git a/contentcuration/contentcuration/viewsets/contentnode.py b/contentcuration/contentcuration/viewsets/contentnode.py index 6a0e1fa829..895f199167 100644 --- a/contentcuration/contentcuration/viewsets/contentnode.py +++ b/contentcuration/contentcuration/viewsets/contentnode.py @@ -297,6 +297,7 @@ class ExtraFieldsOptionsSerializer(JSONFieldDictSerializer): required=False, ) completion_criteria = CompletionCriteriaSerializer(required=False) + entry = CharField(required=False, allow_null=True) class InheritedMetadataSerializer(JSONFieldDictSerializer): @@ -307,7 +308,7 @@ class InheritedMetadataSerializer(JSONFieldDictSerializer): class ExtraFieldsSerializer(JSONFieldDictSerializer): - randomize = BooleanField() + randomize = BooleanField(required=False) options = ExtraFieldsOptionsSerializer(required=False) suggested_duration_type = ChoiceField( choices=[completion_criteria.TIME, completion_criteria.APPROX_TIME], @@ -428,11 +429,38 @@ def validate(self, data): raise ValidationError( {"parent": "This field should only be changed by a move operation"} ) + + # Prevent kind from being changed after creation + if ( + self.instance is not None + and "kind" in data + and self.instance.kind != data["kind"] + ): + raise ValidationError( + {"kind": "Content kind cannot be changed after creation"} + ) + tags = data.get("tags") if tags is not None: for tag in tags: if len(tag) > 30: raise ValidationError("tag is greater than 30 characters") + + # Conditional validation for randomize field on exercise creation + if self.instance is None: # Only validate on creation + kind = data.get("kind") + if kind.kind == content_kinds.EXERCISE: + extra_fields = data.get("extra_fields", {}) + if "randomize" not in extra_fields: + raise ValidationError( + { + "extra_fields": { + "randomize": [ + "This field is required for exercise content." + ] + } + } + ) return data def _check_completion_criteria(self, kind, complete, validated_data):