Skip to content

Commit 91ca5b3

Browse files
rtibblesclaude
andcommitted
Allow cross-dataset-authored quizzes and lessons to sync
Exam, Lesson and their assignments declared their author foreign key null=True but blank=False, and required a creator in pre_save on add. When a record authored by a superuser from a different dataset (whose author FK is nulled by pre_save) was deserialized on a receiving device, clean_fields rejected the null (blank=False) and the pre_save add-check fired, leaving the record dirty in the Store and absent from the device. Make the author foreign keys blank=True and route the pre_save checks through enforce_authoring_user_field, which permits a null author during deserialization. AttendanceSession had the same pre_save gap and is fixed too. Adds a serialize/deserialize regression test covering all three. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bd3e6a4 commit 91ca5b3

6 files changed

Lines changed: 195 additions & 65 deletions

File tree

kolibri/core/attendance/models.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from django.db import models
2-
from django.db.utils import IntegrityError
32

43
from kolibri.core.auth.constants import role_kinds
54
from kolibri.core.auth.models import AbstractFacilityDataModel
@@ -50,14 +49,7 @@ def __str__(self):
5049

5150
def pre_save(self, **kwargs):
5251
super().pre_save(**kwargs)
53-
if self._state.adding and self.created_by is None:
54-
raise IntegrityError("AttendanceSession must be saved with a creator")
55-
if self.created_by and self.created_by.dataset_id != self.dataset_id:
56-
if not self.created_by.is_superuser:
57-
raise IntegrityError(
58-
"AttendanceSession must have creator in the same dataset"
59-
)
60-
self.created_by = None
52+
self.enforce_authoring_user_field("created_by", **kwargs)
6153

6254
def infer_dataset(self, *args, **kwargs):
6355
return self.cached_related_dataset_lookup("collection")

kolibri/core/auth/test/test_morango_integration.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,11 @@
3333
from ..models import LearnerGroup
3434
from ..models import Membership
3535
from ..models import Role
36+
from .helpers import create_superuser
3637
from .helpers import DUMMY_PASSWORD
38+
from .helpers import provision_device
3739
from .sync_utils import multiple_kolibri_servers
40+
from kolibri.core.attendance.models import AttendanceSession
3841
from kolibri.core.auth.constants import role_kinds
3942
from kolibri.core.auth.management.utils import get_client_and_server_certs
4043
from kolibri.core.auth.utils.sync import find_soud_sync_session_for_resume
@@ -92,6 +95,106 @@ def test_deserializing_field(self):
9295
self.fail(e.message)
9396

9497

98+
class CrossDatasetSuperuserDeserializationTestCase(TransactionTestCase):
99+
"""
100+
Regression test for quizzes and lessons authored by a superuser that belongs to a
101+
different dataset than the facility the content lives in (e.g. a device's own super
102+
admin creating content in a facility that was synced onto the device).
103+
104+
In that case the models' ``pre_save`` deliberately nulls the creator/assigned_by
105+
foreign key. The record must still deserialize on a receiving device: previously the
106+
fields were ``blank=False``, so ``clean_fields()`` (which Morango runs during
107+
deserialization) rejected the null value and the record stayed dirty in the Store,
108+
never appearing on the receiving device.
109+
"""
110+
111+
def setUp(self):
112+
self.controller = MorangoProfileController(PROFILE_FACILITY_DATA)
113+
InstanceIDModel.get_or_create_current_instance()
114+
provision_device()
115+
# the facility the content lives in
116+
self.facility = Facility.objects.create(name="Synced")
117+
self.classroom = Classroom.objects.create(name="Class", parent=self.facility)
118+
# a superuser belonging to a different dataset
119+
self.other_facility = Facility.objects.create(name="Device")
120+
self.superuser = create_superuser(self.other_facility, username="deviceadmin")
121+
122+
def _assert_deserializes_cleanly(self, *instance_ids):
123+
self.controller.serialize_into_store()
124+
Store.objects.update(dirty_bit=True)
125+
self.controller.deserialize_from_store()
126+
for instance_id in instance_ids:
127+
store = Store.objects.get(id=instance_id)
128+
self.assertIsNone(
129+
store.deserialization_error,
130+
msg="{} failed to deserialize: {}".format(
131+
store.model_name, store.deserialization_error
132+
),
133+
)
134+
self.assertFalse(store.dirty_bit)
135+
136+
def test_exam_authored_by_cross_dataset_superuser_deserializes(self):
137+
exam = Exam.objects.create(
138+
title="Quiz",
139+
question_count=1,
140+
question_sources=[
141+
{
142+
"exercise_id": uuid.uuid4().hex,
143+
"question_id": uuid.uuid4().hex,
144+
"title": "a",
145+
}
146+
],
147+
collection=self.classroom,
148+
creator=self.superuser,
149+
active=True,
150+
)
151+
assignment = ExamAssignment.objects.create(
152+
exam=exam,
153+
collection=self.classroom,
154+
assigned_by=self.superuser,
155+
)
156+
# pre_save nulls the cross-dataset superuser foreign keys
157+
self.assertIsNone(Exam.objects.get(id=exam.id).creator_id)
158+
self.assertIsNone(ExamAssignment.objects.get(id=assignment.id).assigned_by_id)
159+
160+
self._assert_deserializes_cleanly(exam.id, assignment.id)
161+
162+
def test_lesson_authored_by_cross_dataset_superuser_deserializes(self):
163+
lesson = Lesson.objects.create(
164+
title="Lesson",
165+
resources=[
166+
{
167+
"contentnode_id": uuid.uuid4().hex,
168+
"content_id": uuid.uuid4().hex,
169+
"channel_id": uuid.uuid4().hex,
170+
}
171+
],
172+
collection=self.classroom,
173+
created_by=self.superuser,
174+
is_active=True,
175+
)
176+
assignment = LessonAssignment.objects.create(
177+
lesson=lesson,
178+
collection=self.classroom,
179+
assigned_by=self.superuser,
180+
)
181+
# pre_save nulls the cross-dataset superuser foreign keys
182+
self.assertIsNone(Lesson.objects.get(id=lesson.id).created_by_id)
183+
self.assertIsNone(LessonAssignment.objects.get(id=assignment.id).assigned_by_id)
184+
185+
self._assert_deserializes_cleanly(lesson.id, assignment.id)
186+
187+
def test_attendance_session_authored_by_cross_dataset_superuser_deserializes(self):
188+
session = AttendanceSession.objects.create(
189+
collection=self.classroom,
190+
created_by=self.superuser,
191+
)
192+
# pre_save nulls the cross-dataset superuser foreign key
193+
self.assertIsNone(AttendanceSession.objects.get(id=session.id).created_by_id)
194+
195+
self._assert_deserializes_cleanly(session.id)
196+
197+
95198
class MultipleServerTestCase(TestCase):
96199
"""
97200
A test case to do special teardown handling to prevent errors from our additional databases.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Generated by Django 3.2.25 on 2026-05-27 03:04
2+
import django.db.models.deletion
3+
from django.db import migrations
4+
from django.db import models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("exams", "0010_add_exam_report_visibility_field"),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name="draftexam",
16+
name="creator",
17+
field=models.ForeignKey(
18+
blank=True,
19+
null=True,
20+
on_delete=django.db.models.deletion.CASCADE,
21+
related_name="draftexams",
22+
to="kolibriauth.FacilityUser",
23+
),
24+
),
25+
migrations.AlterField(
26+
model_name="exam",
27+
name="creator",
28+
field=models.ForeignKey(
29+
blank=True,
30+
null=True,
31+
on_delete=django.db.models.deletion.CASCADE,
32+
related_name="exams",
33+
to="kolibriauth.FacilityUser",
34+
),
35+
),
36+
migrations.AlterField(
37+
model_name="examassignment",
38+
name="assigned_by",
39+
field=models.ForeignKey(
40+
blank=True,
41+
null=True,
42+
on_delete=django.db.models.deletion.CASCADE,
43+
related_name="assigned_exams",
44+
to="kolibriauth.FacilityUser",
45+
),
46+
),
47+
]

kolibri/core/exams/models.py

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ class Meta:
141141
creator = models.ForeignKey(
142142
FacilityUser,
143143
related_name="%(class)ss",
144-
blank=False,
144+
blank=True,
145145
null=True,
146146
on_delete=models.CASCADE,
147147
)
@@ -267,18 +267,7 @@ def delete(self, using=None, keep_parents=False):
267267

268268
def pre_save(self, **kwargs):
269269
super().pre_save(**kwargs)
270-
271-
# maintain stricter enforcement on when creator is allowed to be null
272-
if self._state.adding and self.creator is None:
273-
raise IntegrityError("Exam must be saved with an creator")
274-
275-
# validate that datasets match so this would be syncable
276-
if self.creator and self.creator.dataset_id != self.dataset_id:
277-
# the only time creator can be null is if it's a superuser
278-
# and if we set it to none HERE
279-
if not self.creator.is_superuser:
280-
raise IntegrityError("Exam must have creator in the same dataset")
281-
self.creator = None
270+
self.enforce_authoring_user_field("creator", **kwargs)
282271

283272
def save(self, *args, **kwargs):
284273
# If archive is True during the save op, but there is no date_archived then
@@ -342,7 +331,7 @@ class ExamAssignment(AbstractFacilityDataModel):
342331
assigned_by = models.ForeignKey(
343332
FacilityUser,
344333
related_name="assigned_exams",
345-
blank=False,
334+
blank=True,
346335
null=True,
347336
on_delete=models.CASCADE,
348337
)
@@ -360,21 +349,7 @@ def pre_save(self, **kwargs):
360349
"Exam assignment foreign models must be in same dataset"
361350
)
362351

363-
# maintain stricter enforcement on when assigned_by is allowed to be null
364-
# assignments aren't usually updated, but ensure only during creation
365-
if self._state.adding and self.assigned_by is None:
366-
raise IntegrityError("Exam assignment must be saved with an assigner")
367-
368-
# validate that datasets match so this would be syncable
369-
if self.assigned_by and self.assigned_by.dataset_id != self.dataset_id:
370-
# the only time assigned_by can be null is if it's a superuser
371-
# and if we set it to none HERE
372-
if not self.assigned_by.is_superuser:
373-
# maintain stricter enforcement on when assigned_by is allowed to be null
374-
raise IntegrityError(
375-
"Exam assignment must have assigner in the same dataset"
376-
)
377-
self.assigned_by = None
352+
self.enforce_authoring_user_field("assigned_by", **kwargs)
378353

379354
def infer_dataset(self, *args, **kwargs):
380355
# infer from exam so assignments align with exams
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Generated by Django 3.2.25 on 2026-05-27 03:04
2+
import django.db.models.deletion
3+
from django.db import migrations
4+
from django.db import models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("lessons", "0004_nullable_created_by_assigned_by"),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name="lesson",
16+
name="created_by",
17+
field=models.ForeignKey(
18+
blank=True,
19+
null=True,
20+
on_delete=django.db.models.deletion.CASCADE,
21+
related_name="lessons_created",
22+
to="kolibriauth.FacilityUser",
23+
),
24+
),
25+
migrations.AlterField(
26+
model_name="lessonassignment",
27+
name="assigned_by",
28+
field=models.ForeignKey(
29+
blank=True,
30+
null=True,
31+
on_delete=django.db.models.deletion.CASCADE,
32+
related_name="assigned_lessons",
33+
to="kolibriauth.FacilityUser",
34+
),
35+
),
36+
]

kolibri/core/lessons/models.py

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class Lesson(AbstractFacilityDataModel):
6565
created_by = models.ForeignKey(
6666
FacilityUser,
6767
related_name="lessons_created",
68-
blank=False,
68+
blank=True,
6969
null=True,
7070
on_delete=models.CASCADE,
7171
)
@@ -96,17 +96,7 @@ def __str__(self):
9696

9797
def pre_save(self, **kwargs):
9898
super().pre_save(**kwargs)
99-
100-
# maintain stricter enforcement on when assigned_by is allowed to be null
101-
if self._state.adding and self.created_by is None:
102-
raise IntegrityError("Lesson must be saved with a creator")
103-
104-
# if the created_by user is in a different dataset than the dataset we've determined, then
105-
# we'll unset it to prevent deserialization errors when syncing
106-
if self.created_by and self.created_by.dataset_id != self.dataset_id:
107-
if not self.created_by.is_superuser:
108-
raise IntegrityError("Lesson must have creator in the same dataset")
109-
self.created_by = None
99+
self.enforce_authoring_user_field("created_by", **kwargs)
110100

111101
def delete(self, using=None, keep_parents=False):
112102
"""
@@ -152,7 +142,7 @@ class LessonAssignment(AbstractFacilityDataModel):
152142
assigned_by = models.ForeignKey(
153143
FacilityUser,
154144
related_name="assigned_lessons",
155-
blank=False,
145+
blank=True,
156146
null=True,
157147
on_delete=models.CASCADE,
158148
)
@@ -178,20 +168,7 @@ def pre_save(self, **kwargs):
178168
"Lesson assignment foreign models must be in same dataset"
179169
)
180170

181-
# maintain stricter enforcement on when assigned_by is allowed to be null
182-
# assignments aren't usually updated, but ensure only during creation
183-
if self._state.adding and self.assigned_by is None:
184-
raise IntegrityError("Lesson assignment must be saved with an assigner")
185-
186-
# validate that datasets match so this would be syncable
187-
if self.assigned_by and self.assigned_by.dataset_id != self.dataset_id:
188-
# the only time assigned_by can be null is if it's a superuser
189-
# and if we set it to none HERE
190-
if not self.assigned_by.is_superuser:
191-
raise IntegrityError(
192-
"Lesson assignment must have assigner in the same dataset"
193-
)
194-
self.assigned_by = None
171+
self.enforce_authoring_user_field("assigned_by", **kwargs)
195172

196173
def infer_dataset(self, *args, **kwargs):
197174
# infer from lesson so assignments align with lessons

0 commit comments

Comments
 (0)