Skip to content

Commit 002a7ec

Browse files
authored
Merge pull request #5431 from learningequality/hotfixes
Release v2025.10.06
2 parents f538d9c + aeb632b commit 002a7ec

File tree

4 files changed

+162
-4
lines changed

4 files changed

+162
-4
lines changed

contentcuration/contentcuration/frontend/channelEdit/components/edit/DetailsTabView.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -694,7 +694,7 @@
694694
/* FORM FIELDS */
695695
title: generateGetterSetter('title'),
696696
description: generateGetterSetter('description'),
697-
randomizeOrder: generateExtraFieldsGetterSetter('randomize', true),
697+
randomizeOrder: generateExtraFieldsGetterSetter('randomize'),
698698
author: generateGetterSetter('author'),
699699
provider: generateGetterSetter('provider'),
700700
aggregator: generateGetterSetter('aggregator'),

contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/actions.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,10 @@ export function createContentNode(context, { parent, kind, ...payload }) {
220220
...payload,
221221
};
222222

223+
if (kind === ContentKindsNames.EXERCISE) {
224+
contentNodeData.extra_fields.randomize = true;
225+
}
226+
223227
contentNodeData.complete = isNodeComplete({
224228
nodeDetails: contentNodeData,
225229
assessmentItems: [],

contentcuration/contentcuration/tests/viewsets/test_contentnode.py

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ def test_deadlock_move_and_rebuild(self):
210210
for (node_id, target_id) in zip(
211211
root_children_ids, first_child_node_children_ids
212212
)
213-
)
213+
),
214214
)
215215

216216
for result in results:
@@ -236,7 +236,7 @@ def test_deadlock_create_and_rebuild(self):
236236
*(
237237
(create_contentnode, {"parent_id": node_id})
238238
for node_id in first_child_node_children_ids
239-
)
239+
),
240240
)
241241

242242
for result in results:
@@ -1903,6 +1903,132 @@ def test_delete_no_permission_prerequisite(self):
19031903
self.assertEqual(len(response.data["disallowed"]), 1)
19041904
self.assertTrue(contentnode.prerequisite.filter(id=prereq.id).exists())
19051905

1906+
def test_create_html5_contentnode_with_entry_validation(self):
1907+
"""
1908+
Regression test for HTML5 nodes validation failure when entry value is set in extra_fields.
1909+
1910+
This test verifies that newly created HTML5 content nodes with an "entry" value
1911+
in extra_fields.options.entry can be successfully validated and created.
1912+
"""
1913+
contentnode_data = self.contentnode_metadata
1914+
contentnode_data["kind"] = content_kinds.HTML5
1915+
contentnode_data["extra_fields"] = {"options": {"entry": "index.html"}}
1916+
1917+
response = self.sync_changes(
1918+
[
1919+
generate_create_event(
1920+
contentnode_data["id"],
1921+
CONTENTNODE,
1922+
contentnode_data,
1923+
channel_id=self.channel.id,
1924+
)
1925+
],
1926+
)
1927+
self.assertEqual(response.status_code, 200, response.content)
1928+
self.assertEqual(
1929+
len(response.data.get("errors", [])),
1930+
0,
1931+
f"Expected no validation errors, but got: {response.data.get('errors', [])}",
1932+
)
1933+
1934+
try:
1935+
new_node = models.ContentNode.objects.get(id=contentnode_data["id"])
1936+
except models.ContentNode.DoesNotExist:
1937+
self.fail("HTML5 ContentNode with entry value was not created")
1938+
1939+
self.assertEqual(new_node.parent_id, self.channel.main_tree_id)
1940+
self.assertEqual(new_node.kind_id, content_kinds.HTML5)
1941+
self.assertEqual(new_node.extra_fields["options"]["entry"], "index.html")
1942+
1943+
def test_create_exercise_contentnode_requires_randomize(self):
1944+
"""
1945+
Test that exercise content nodes require the randomize field in extra_fields.
1946+
"""
1947+
contentnode_data = self.contentnode_metadata
1948+
contentnode_data["kind"] = content_kinds.EXERCISE
1949+
# Deliberately omit randomize field
1950+
contentnode_data["extra_fields"] = {"options": {}}
1951+
1952+
response = self.sync_changes(
1953+
[
1954+
generate_create_event(
1955+
contentnode_data["id"],
1956+
CONTENTNODE,
1957+
contentnode_data,
1958+
channel_id=self.channel.id,
1959+
)
1960+
],
1961+
)
1962+
self.assertEqual(response.status_code, 200, response.content)
1963+
self.assertEqual(len(response.data.get("errors", [])), 1)
1964+
1965+
error = response.data["errors"][0]
1966+
1967+
self.assertIn("randomize", error["errors"]["extra_fields"])
1968+
self.assertEqual(
1969+
error["errors"]["extra_fields"]["randomize"][0],
1970+
"This field is required for exercise content.",
1971+
)
1972+
1973+
def test_create_exercise_contentnode_with_randomize_succeeds(self):
1974+
"""
1975+
Test that exercise content nodes with randomize field are created successfully.
1976+
"""
1977+
contentnode_data = self.contentnode_metadata
1978+
contentnode_data["kind"] = content_kinds.EXERCISE
1979+
contentnode_data["extra_fields"] = {"randomize": True, "options": {}}
1980+
1981+
response = self.sync_changes(
1982+
[
1983+
generate_create_event(
1984+
contentnode_data["id"],
1985+
CONTENTNODE,
1986+
contentnode_data,
1987+
channel_id=self.channel.id,
1988+
)
1989+
],
1990+
)
1991+
self.assertEqual(response.status_code, 200, response.content)
1992+
self.assertEqual(len(response.data.get("errors", [])), 0)
1993+
1994+
try:
1995+
new_node = models.ContentNode.objects.get(id=contentnode_data["id"])
1996+
except models.ContentNode.DoesNotExist:
1997+
self.fail("Exercise ContentNode with randomize field was not created")
1998+
1999+
self.assertEqual(new_node.kind_id, content_kinds.EXERCISE)
2000+
self.assertTrue(new_node.extra_fields["randomize"])
2001+
2002+
def test_cannot_update_contentnode_kind(self):
2003+
"""
2004+
Test that content node kind cannot be changed after creation.
2005+
"""
2006+
contentnode = models.ContentNode.objects.create(**self.contentnode_db_metadata)
2007+
original_kind = contentnode.kind_id
2008+
2009+
response = self.sync_changes(
2010+
[
2011+
generate_update_event(
2012+
contentnode.id,
2013+
CONTENTNODE,
2014+
{"kind": content_kinds.HTML5},
2015+
channel_id=self.channel.id,
2016+
)
2017+
],
2018+
)
2019+
self.assertEqual(response.status_code, 200, response.content)
2020+
self.assertEqual(len(response.data.get("errors", [])), 1)
2021+
2022+
error = response.data["errors"][0]
2023+
self.assertIn("kind", error["errors"])
2024+
self.assertEqual(
2025+
error["errors"]["kind"][0], "Content kind cannot be changed after creation"
2026+
)
2027+
2028+
# Verify kind was not changed
2029+
contentnode.refresh_from_db()
2030+
self.assertEqual(contentnode.kind_id, original_kind)
2031+
19062032

19072033
class CRUDTestCase(StudioAPITestCase):
19082034
def setUp(self):

contentcuration/contentcuration/viewsets/contentnode.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ class ExtraFieldsOptionsSerializer(JSONFieldDictSerializer):
297297
required=False,
298298
)
299299
completion_criteria = CompletionCriteriaSerializer(required=False)
300+
entry = CharField(required=False, allow_null=True)
300301

301302

302303
class InheritedMetadataSerializer(JSONFieldDictSerializer):
@@ -307,7 +308,7 @@ class InheritedMetadataSerializer(JSONFieldDictSerializer):
307308

308309

309310
class ExtraFieldsSerializer(JSONFieldDictSerializer):
310-
randomize = BooleanField()
311+
randomize = BooleanField(required=False)
311312
options = ExtraFieldsOptionsSerializer(required=False)
312313
suggested_duration_type = ChoiceField(
313314
choices=[completion_criteria.TIME, completion_criteria.APPROX_TIME],
@@ -428,11 +429,38 @@ def validate(self, data):
428429
raise ValidationError(
429430
{"parent": "This field should only be changed by a move operation"}
430431
)
432+
433+
# Prevent kind from being changed after creation
434+
if (
435+
self.instance is not None
436+
and "kind" in data
437+
and self.instance.kind != data["kind"]
438+
):
439+
raise ValidationError(
440+
{"kind": "Content kind cannot be changed after creation"}
441+
)
442+
431443
tags = data.get("tags")
432444
if tags is not None:
433445
for tag in tags:
434446
if len(tag) > 30:
435447
raise ValidationError("tag is greater than 30 characters")
448+
449+
# Conditional validation for randomize field on exercise creation
450+
if self.instance is None: # Only validate on creation
451+
kind = data.get("kind")
452+
if kind.kind == content_kinds.EXERCISE:
453+
extra_fields = data.get("extra_fields", {})
454+
if "randomize" not in extra_fields:
455+
raise ValidationError(
456+
{
457+
"extra_fields": {
458+
"randomize": [
459+
"This field is required for exercise content."
460+
]
461+
}
462+
}
463+
)
436464
return data
437465

438466
def _check_completion_criteria(self, kind, complete, validated_data):

0 commit comments

Comments
 (0)