forked from learningequality/kolibri
-
Notifications
You must be signed in to change notification settings - Fork 0
/
models.py
398 lines (336 loc) · 13.9 KB
/
models.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
import json
from django.db import models
from django.db.utils import IntegrityError
from django.utils import timezone
from .permissions import UserCanReadExamAssignmentData
from .permissions import UserCanReadExamData
from kolibri.core.auth.constants import role_kinds
from kolibri.core.auth.models import AbstractFacilityDataModel
from kolibri.core.auth.models import Collection
from kolibri.core.auth.models import FacilityUser
from kolibri.core.auth.permissions.base import RoleBasedPermissions
from kolibri.core.content.utils.assignment import ContentAssignmentManager
from kolibri.core.fields import JSONField
from kolibri.core.notifications.models import LearnerProgressNotification
def exam_assignment_lookup(question_sources):
"""
Lookup function for the ContentAssignmentManager
:param question_sources: a list of dicts from an Exam
:return: a tuple of contentnode_id and metadata
"""
for question_source in question_sources:
if "section_id" in question_source:
questions = question_source.get("questions")
if questions is not None:
for question in question_source["questions"]:
yield (question["exercise_id"], None)
else:
yield (question_source["exercise_id"], None)
class Exam(AbstractFacilityDataModel):
"""
This class stores metadata about teacher-created quizzes to test current student knowledge.
"""
morango_model_name = "exam"
permissions = (
RoleBasedPermissions(
target_field="collection",
can_be_created_by=(role_kinds.ADMIN, role_kinds.COACH),
can_be_read_by=(role_kinds.ADMIN, role_kinds.COACH),
can_be_updated_by=(role_kinds.ADMIN, role_kinds.COACH),
can_be_deleted_by=(role_kinds.ADMIN, role_kinds.COACH),
)
| UserCanReadExamData()
)
title = models.CharField(max_length=200)
# Total number of questions in the exam. Equal to the length of the question_sources array.
question_count = models.IntegerField()
"""
The `question_sources` field contains different values depending on the 'data_model_version' field.
V3:
Represents a list of questions of V2 objects each of which are now a "Exam/Quiz Section"
and extends it with an additional description field. The `learners_see_fixed_order` field
will now be persisted within each section itself, rather than for the whole quiz.
# Exam
[
# Section 1
{
"section_id": <a uuid unique to this section>,
"section_title": <section title>,
"description": <section description>,
"resource_pool": [ <contentnode_ids of pool of resources> ],
"question_count": <number of questions in section>,
"learners_see_fixed_order": <bool>,
"questions": [
{
"exercise_id": <exercise_pk>,
"question_id": <item_id_within_exercise>,
"title": <title of question>,
"counter_in_exercise": <unique_count_for_question>,
},
]
},
# Section 2
{...}
]
V2:
Similar to V1, but with a `counter_in_exercise` field
[
{
"exercise_id": <exercise_pk>,
"question_id": <item_id_within_exercise>,
"title": <exercise_title>,
"counter_in_exercise": <unique_count_for_question>
},
...
]
V1:
JSON array describing the questions in this exam and the exercises they come from:
[
{
"exercise_id": <exercise_pk>,
"question_id": <item_id_within_exercise>,
"title": <exercise_title>,
},
...
]
V0:
JSON array describing exercise nodes this exam draws questions from,
how many from each, and the node titles at the time of exam creation:
[
{
"exercise_id": <exercise_pk>,
"number_of_questions": 6,
"title": <exercise_title>
},
...
]
"""
question_sources = JSONField(default=[], blank=True)
"""
This field is interpreted differently depending on the 'data_model_version' field.
V1:
Used to help select new questions from exercises at quiz creation time
V0:
Used to decide which questions are in an exam at runtime.
See convertExamQuestionSourcesV0V2 in exams/utils.js for details.
"""
seed = models.IntegerField(default=1)
# When True, learners see questions in the order they appear in 'question_sources'.
# When False, each learner sees questions in a random (but consistent) order seeded
# by their user's UUID.
learners_see_fixed_order = models.BooleanField(default=False)
# Is this exam currently active and visible to students to whom it is assigned?
active = models.BooleanField(default=False)
# Exams are scoped to a particular class (usually) as they are associated with a Coach
# who creates them in the context of their class, this stores that relationship but does
# not assign exam itself to the class - for that see the ExamAssignment model.
collection = models.ForeignKey(
Collection,
related_name="exams",
blank=False,
null=False,
on_delete=models.CASCADE,
)
creator = models.ForeignKey(
FacilityUser,
related_name="exams",
blank=False,
null=True,
on_delete=models.CASCADE,
)
# To be set True when the quiz is first set to active=True
date_activated = models.DateTimeField(default=None, null=True, blank=True)
date_created = models.DateTimeField(auto_now_add=True, null=True)
# archive will be used on the frontend to indicate if a quiz is "closed"
archive = models.BooleanField(default=False)
date_archived = models.DateTimeField(default=None, null=True, blank=True)
content_assignments = ContentAssignmentManager(
# one exam can contain multiple questions from multiple exercises,
# hence multiple content nodes
one_to_many=True,
filters=dict(active=True),
lookup_field="question_sources",
lookup_func=exam_assignment_lookup,
)
def delete(self, using=None, keep_parents=False):
"""
We delete all notifications objects whose quiz is this exam id.
"""
LearnerProgressNotification.objects.filter(quiz_id=self.id).delete()
super(Exam, self).delete(using, keep_parents)
def pre_save(self):
super(Exam, self).pre_save()
# maintain stricter enforcement on when creator is allowed to be null
if self._state.adding and self.creator is None:
raise IntegrityError("Exam must be saved with an creator")
# validate that datasets match so this would be syncable
if self.creator and self.creator.dataset_id != self.dataset_id:
# the only time creator can be null is if it's a superuser
# and if we set it to none HERE
if not self.creator.is_superuser:
raise IntegrityError("Exam must have creator in the same dataset")
self.creator = None
def save(self, *args, **kwargs):
# If archive is True during the save op, but there is no date_archived then
# this is the save that is archiving the object and we need to datestamp it
if getattr(self, "archive", False) is True:
if getattr(self, "date_archived") is None:
self.date_archived = timezone.now()
super(Exam, self).save(*args, **kwargs)
"""
As we evolve this model in ways that migrations can't handle, certain fields may
become deprecated, and other fields may need to be interpreted differently. This
may happen when multiple versions of the model need to coexist in the same database.
The 'data_model_version' field is used to keep track of the version of the model.
Certain fields that are only relevant for older model versions get prefixed
with their version numbers.
"""
data_model_version = models.SmallIntegerField(default=3)
def infer_dataset(self, *args, **kwargs):
return self.cached_related_dataset_lookup("collection")
def calculate_partition(self):
return self.dataset_id
def __str__(self):
return self.title
def get_questions(self):
"""
Returns a list of all questions from all sections in the exam.
"""
questions = []
if self.data_model_version == 3:
for section in self.question_sources:
for question in section.get("questions", []):
questions.append(question)
else:
for question in self.question_sources:
questions.append(question)
return questions
class ExamAssignment(AbstractFacilityDataModel):
"""
This class acts as an intermediary to handle assignment of an exam to particular collections
classes, groups, etc.
"""
morango_model_name = "examassignment"
permissions = (
RoleBasedPermissions(
target_field="collection",
can_be_created_by=(role_kinds.ADMIN, role_kinds.COACH),
can_be_read_by=(role_kinds.ADMIN, role_kinds.COACH),
can_be_updated_by=(),
can_be_deleted_by=(role_kinds.ADMIN, role_kinds.COACH),
)
| UserCanReadExamAssignmentData()
)
exam = models.ForeignKey(
Exam,
related_name="assignments",
blank=False,
null=False,
on_delete=models.CASCADE,
)
collection = models.ForeignKey(
Collection,
related_name="assigned_exams",
blank=False,
null=False,
on_delete=models.CASCADE,
)
assigned_by = models.ForeignKey(
FacilityUser,
related_name="assigned_exams",
blank=False,
null=True,
on_delete=models.CASCADE,
)
def pre_save(self):
super(ExamAssignment, self).pre_save()
# this shouldn't happen
if (
self.exam
and self.collection
and self.exam.dataset_id != self.collection.dataset_id
):
raise IntegrityError(
"Exam assignment foreign models must be in same dataset"
)
# maintain stricter enforcement on when assigned_by is allowed to be null
# assignments aren't usually updated, but ensure only during creation
if self._state.adding and self.assigned_by is None:
raise IntegrityError("Exam assignment must be saved with an assigner")
# validate that datasets match so this would be syncable
if self.assigned_by and self.assigned_by.dataset_id != self.dataset_id:
# the only time assigned_by can be null is if it's a superuser
# and if we set it to none HERE
if not self.assigned_by.is_superuser:
# maintain stricter enforcement on when assigned_by is allowed to be null
raise IntegrityError(
"Exam assignment must have assigner in the same dataset"
)
self.assigned_by = None
def infer_dataset(self, *args, **kwargs):
# infer from exam so assignments align with exams
return self.cached_related_dataset_lookup("exam")
def calculate_source_id(self):
return "{exam_id}:{collection_id}".format(
exam_id=self.exam_id, collection_id=self.collection_id
)
def calculate_partition(self):
return self.dataset_id
def individual_exam_assignment_lookup(serialized_exam):
"""
Lookup function for the ContentAssignmentManager
:param serialized_exam: the exam in form of a dictionary
:return: a tuple of contentnode_id and metadata
"""
try:
question_sources = json.loads(serialized_exam.get("question_sources", "[]"))
return exam_assignment_lookup(question_sources)
except json.JSONDecodeError:
return []
class IndividualSyncableExam(AbstractFacilityDataModel):
"""
Represents a Exam and its assignment to a particular user
in such a way that it can be synced to a single-user device.
Note: This is not the canonical representation of a user's
relation to an exam (which is captured in an ExamAssignment
combined with a user's Membership in an associated Collection;
the purpose of this model is as a derived/denormalized
representation of a specific user's exam assignments).
"""
morango_model_name = "individualsyncableexam"
user = models.ForeignKey(FacilityUser, on_delete=models.CASCADE)
collection = models.ForeignKey(Collection, on_delete=models.CASCADE)
exam_id = models.UUIDField()
serialized_exam = JSONField()
content_assignments = ContentAssignmentManager(
# one exam can contain multiple questions from multiple exercises,
# hence multiple content nodes
one_to_many=True,
lookup_field="serialized_exam",
lookup_func=individual_exam_assignment_lookup,
)
def infer_dataset(self, *args, **kwargs):
return self.cached_related_dataset_lookup("user")
def calculate_source_id(self):
return self.exam_id
def calculate_partition(self):
return "{dataset_id}:user-ro:{user_id}".format(
dataset_id=self.dataset_id, user_id=self.user_id
)
@classmethod
def serialize_exam(cls, exam):
serialized = exam.serialize()
for key in [
"active",
"creator_id",
"date_created",
"date_activated",
"collection_id",
]:
serialized.pop(key, None)
return serialized
@classmethod
def deserialize_exam(cls, serialized_exam):
exam = Exam.deserialize(serialized_exam)
exam.active = True
return exam