cfl-common 5.3.0__py3-none-any.whl → 8.9.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. cfl_common-8.9.15.dist-info/METADATA +47 -0
  2. cfl_common-8.9.15.dist-info/RECORD +99 -0
  3. {cfl_common-5.3.0.dist-info → cfl_common-8.9.15.dist-info}/WHEEL +1 -1
  4. common/app_settings.py +35 -5
  5. common/csp_config.py +85 -0
  6. common/fixtures/aimmo_characters.json +30 -30
  7. common/fixtures/aimmo_characters2.json +1 -1
  8. common/fixtures/aimmo_characters3.json +35 -0
  9. common/helpers/data_migration_loader.py +3 -4
  10. common/helpers/emails.py +228 -108
  11. common/helpers/generators.py +1 -1
  12. common/helpers/organisation.py +10 -0
  13. common/mail.py +201 -0
  14. common/migrations/0002_emailverification.py +1 -3
  15. common/migrations/0005_add_worksheets.py +2 -13
  16. common/migrations/0007_add_pdf_names_to_first_two_worksheets.py +2 -14
  17. common/migrations/0008_unlock_worksheet_3.py +1 -6
  18. common/migrations/0011_student_login_id.py +3 -3
  19. common/migrations/0012_usersession.py +39 -0
  20. common/migrations/0013_class_school.py +42 -0
  21. common/migrations/0014_login_type.py +29 -0
  22. common/migrations/0015_dailyactivity.py +31 -0
  23. common/migrations/0016_joinreleasestudent.py +42 -0
  24. common/migrations/0017_copy_email_to_username.py +18 -0
  25. common/migrations/0018_update_aimmo_character_image_path.py +15 -0
  26. common/migrations/0019_aimmocharacter_alt.py +16 -0
  27. common/migrations/0020_class_is_active_and_null_access_code.py +23 -0
  28. common/migrations/0021_school_is_active.py +28 -0
  29. common/migrations/0022_school_cleanup.py +29 -0
  30. common/migrations/0023_userprofile_aimmo_badges.py +22 -0
  31. common/migrations/0024_teacher_invited_by.py +25 -0
  32. common/migrations/0025_schoolteacherinvitation.py +47 -0
  33. common/migrations/0026_teacher_remove_join_request.py +22 -0
  34. common/migrations/0027_class_created_by.py +25 -0
  35. common/migrations/0028_coding_club_downloads.py +23 -0
  36. common/migrations/0029_dynamicelement.py +22 -0
  37. common/migrations/0030_add_maintenance_banner.py +25 -0
  38. common/migrations/0031_improve_admin_panel.py +56 -0
  39. common/migrations/0032_dailyactivity_level_control_submits.py +18 -0
  40. common/migrations/0033_password_reset_tracking_fields.py +23 -0
  41. common/migrations/0034_dailyactivity_daily_school_student_lockout_reset.py +18 -0
  42. common/migrations/0035_rename_lockout_fields.py +27 -0
  43. common/migrations/0036_rename_awaiting_email_verification_userprofile_is_verified.py +17 -0
  44. common/migrations/0037_migrate_email_verification.py +21 -0
  45. common/migrations/0038_delete_emailverification.py +16 -0
  46. common/migrations/0039_copy_email_to_username.py +18 -0
  47. common/migrations/0040_school_county.py +18 -0
  48. common/migrations/0041_populate_gb_counties.py +27 -0
  49. common/migrations/0042_totalactivity.py +25 -0
  50. common/migrations/0043_add_total_activity.py +30 -0
  51. common/migrations/0044_update_activity_models.py +33 -0
  52. common/migrations/0045_otp.py +23 -0
  53. common/migrations/0046_alter_school_country.py +19 -0
  54. common/migrations/0047_delete_school_postcode.py +16 -0
  55. common/migrations/0048_unique_school_names.py +42 -0
  56. common/migrations/0049_anonymise_orphan_users.py +29 -0
  57. common/migrations/0050_anonymise_orphan_schools.py +30 -0
  58. common/migrations/0051_verify_returning_users.py +26 -0
  59. common/migrations/0052_add_cse_fields.py +68 -0
  60. common/migrations/0053_clean_class_data.py +24 -0
  61. common/migrations/0054_delete_aimmo_models.py +20 -0
  62. common/migrations/0055_alter_schoolteacherinvitation_token.py +18 -0
  63. common/migrations/0056_set_non_school_teachers_as_non_admins.py +25 -0
  64. common/migrations/0057_teacher_teacher__is_admin.py +19 -0
  65. common/migrations/0058_userprofile_google_refresh_token_and_more.py +24 -0
  66. common/models.py +347 -63
  67. common/permissions.py +20 -8
  68. common/static/common/img/RR_logo.svg +336 -0
  69. common/static/common/img/brain.svg +1 -0
  70. common/templates/common/onetrust_cookies_consent_notice.html +6 -6
  71. common/tests/test_migration_anonymise_orphan_schools.py +30 -0
  72. common/tests/test_migration_anonymise_orphan_users.py +30 -0
  73. common/tests/test_migration_blocked_time.py +3 -11
  74. common/tests/test_migration_remove_teacher_title.py +1 -3
  75. common/tests/test_migration_unique_school_names.py +33 -0
  76. common/tests/test_migration_verify_returning_users.py +59 -0
  77. common/tests/test_models.py +49 -43
  78. common/tests/utils/classes.py +1 -3
  79. common/tests/utils/email.py +11 -49
  80. common/tests/utils/organisation.py +10 -14
  81. common/tests/utils/student.py +14 -67
  82. common/tests/utils/teacher.py +16 -38
  83. common/tests/utils/user.py +1 -3
  84. cfl_common-5.3.0.dist-info/METADATA +0 -20
  85. cfl_common-5.3.0.dist-info/RECORD +0 -48
  86. common/email_messages.py +0 -218
  87. common/fixtures/unlock_worksheet3.json +0 -20
  88. common/fixtures/worksheets.json +0 -98
  89. common/fixtures/worksheets2.json +0 -110
  90. common/tests/test_migration_aimmo_characters.py +0 -31
  91. common/tests/test_migration_worksheets.py +0 -49
  92. {cfl_common-5.3.0.dist-info → cfl_common-8.9.15.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,19 @@
1
+ # Generated by Django 4.2.17 on 2025-01-13 17:34
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("common", "0056_set_non_school_teachers_as_non_admins"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddConstraint(
14
+ model_name="teacher",
15
+ constraint=models.CheckConstraint(
16
+ check=models.Q(("is_admin", True), ("school__isnull", True), _negated=True), name="teacher__is_admin"
17
+ ),
18
+ ),
19
+ ]
@@ -0,0 +1,24 @@
1
+ # Generated by Django 5.1.10 on 2025-08-12 12:51
2
+
3
+ import common.models
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ("common", "0057_teacher_teacher__is_admin"),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name="userprofile",
16
+ name="google_refresh_token",
17
+ field=common.models.EncryptedCharField(blank=True, max_length=1004, null=True),
18
+ ),
19
+ migrations.AddField(
20
+ model_name="userprofile",
21
+ name="google_sub",
22
+ field=models.CharField(blank=True, max_length=255, null=True),
23
+ ),
24
+ ]
common/models.py CHANGED
@@ -1,21 +1,102 @@
1
1
  import re
2
+ import typing as t
2
3
  from datetime import timedelta
3
4
  from uuid import uuid4
4
5
 
6
+ from cryptography.fernet import Fernet
7
+ from django.conf import settings
5
8
  from django.contrib.auth.models import User
6
9
  from django.db import models
7
10
  from django.utils import timezone
8
11
  from django_countries.fields import CountryField
9
- from wagtail.admin.edit_handlers import FieldPanel
10
- from wagtail.snippets.models import register_snippet
12
+
13
+ if t.TYPE_CHECKING:
14
+ from django.db.models import ManyToManyField
15
+ from game.models import Worksheet
16
+
17
+
18
+ class EncryptedCharField(models.CharField):
19
+ """
20
+ A custom CharField that encrypts data before saving and decrypts it when
21
+ retrieved.
22
+ """
23
+
24
+ _fernet = Fernet(settings.ENCRYPTION_KEY)
25
+ _prefix = "ENC:"
26
+
27
+ # pylint: disable-next=unused-argument
28
+ def from_db_value(self, value: t.Optional[str], expression, connection):
29
+ """
30
+ Converts a value as returned by the database to a Python object. It is
31
+ the reverse of get_prep_value().
32
+
33
+ https://docs.djangoproject.com/en/5.1/howto/custom-model-fields/#converting-values-to-python-objects
34
+ """
35
+ if isinstance(value, str):
36
+ return self.decrypt_value(value)
37
+ return value
38
+
39
+ def to_python(self, value: t.Optional[str]):
40
+ """
41
+ Converts the value into the correct Python object. It acts as the
42
+ reverse of value_to_string(), and is also called in clean().
43
+
44
+ https://docs.djangoproject.com/en/5.1/howto/custom-model-fields/#converting-values-to-python-objects
45
+ """
46
+ if isinstance(value, str):
47
+ return self.decrypt_value(value)
48
+ return value
49
+
50
+ def get_prep_value(self, value: t.Optional[str]):
51
+ """
52
+ 'value' is the current value of the model's attribute, and the method
53
+ should return data in a format that has been prepared for use as a
54
+ parameter in a query.
55
+
56
+ https://docs.djangoproject.com/en/5.1/howto/custom-model-fields/#converting-python-objects-to-query-values
57
+ """
58
+ if isinstance(value, str):
59
+ return self.encrypt_value(value)
60
+ return value
61
+
62
+ def encrypt_value(self, value: str):
63
+ """Encrypt the value if it's not encrypted."""
64
+ if not value.startswith(self._prefix):
65
+ return self._prefix + self._fernet.encrypt(value.encode()).decode()
66
+ return value
67
+
68
+ def decrypt_value(self, value: str):
69
+ """Decrpyt the value if it's encrypted.."""
70
+ if value.startswith(self._prefix):
71
+ value = value[len(self._prefix) :]
72
+ return self._fernet.decrypt(value).decode()
73
+ return value
11
74
 
12
75
 
13
76
  class UserProfile(models.Model):
14
77
  user = models.OneToOneField(User, on_delete=models.CASCADE)
15
- can_view_aggregated_data = models.BooleanField(default=False)
16
- developer = models.BooleanField(default=False)
17
78
 
18
- awaiting_email_verification = models.BooleanField(default=False)
79
+ otp_secret = models.CharField(max_length=40, null=True, blank=True)
80
+ last_otp_for_time = models.DateTimeField(null=True, blank=True)
81
+ developer = models.BooleanField(default=False)
82
+ is_verified = models.BooleanField(default=False)
83
+
84
+ # TODO: Make not nullable once data has been transferred
85
+ first_name = models.CharField(max_length=200, null=True, blank=True)
86
+ _first_name = models.BinaryField(null=True, blank=True)
87
+ last_name = models.CharField(max_length=200, null=True, blank=True)
88
+ _last_name = models.BinaryField(null=True, blank=True)
89
+ email = models.CharField(max_length=200, null=True, blank=True)
90
+ _email = models.BinaryField(null=True, blank=True)
91
+ # TODO: Make not nullable once data has been transferred
92
+ username = models.CharField(max_length=200, null=True, blank=True)
93
+ _username = models.BinaryField(null=True, blank=True)
94
+
95
+ # Google.
96
+ google_refresh_token = EncryptedCharField(
97
+ max_length=1000 + len(EncryptedCharField._prefix), null=True, blank=True
98
+ )
99
+ google_sub = models.CharField(max_length=255, null=True, blank=True)
19
100
 
20
101
  def __str__(self):
21
102
  return f"{self.user.first_name} {self.user.last_name}"
@@ -25,36 +106,26 @@ class UserProfile(models.Model):
25
106
  return now - timedelta(days=7) <= self.user.date_joined
26
107
 
27
108
 
28
- class EmailVerification(models.Model):
29
- user = models.ForeignKey(
30
- User,
31
- related_name="email_verifications",
32
- null=True,
33
- blank=True,
34
- on_delete=models.CASCADE,
35
- )
36
- token = models.CharField(max_length=30)
37
- email = models.CharField(max_length=200, null=True, default=None, blank=True)
38
- expiry = models.DateTimeField()
39
- verified = models.BooleanField(default=False)
109
+ class SchoolModelManager(models.Manager):
110
+ def get_original_queryset(self):
111
+ return super().get_queryset()
40
112
 
41
- def __str__(self):
42
- return f"Email verification for {self.user.username}, ({self.email})"
113
+ # Filter out inactive schools by default
114
+ def get_queryset(self):
115
+ return super().get_queryset().filter(is_active=True)
43
116
 
44
117
 
45
118
  class School(models.Model):
46
- name = models.CharField(max_length=200)
47
- postcode = models.CharField(max_length=10)
48
- town = models.CharField(max_length=200)
49
- latitude = models.CharField(max_length=20)
50
- longitude = models.CharField(max_length=20)
51
- country = CountryField(blank_label="(select country)")
119
+ name = models.CharField(max_length=200, unique=True)
120
+ country = CountryField(
121
+ blank_label="(select country)", null=True, blank=True
122
+ )
123
+ # TODO: Create an Address model to house address details
124
+ county = models.CharField(max_length=50, blank=True, null=True)
125
+ creation_time = models.DateTimeField(default=timezone.now, null=True)
126
+ is_active = models.BooleanField(default=True)
52
127
 
53
- class Meta(object):
54
- permissions = (
55
- ("view_aggregated_data", "Can see available aggregated data"),
56
- ("view_map_data", "Can see schools' location displayed on map"),
57
- )
128
+ objects = SchoolModelManager()
58
129
 
59
130
  def __str__(self):
60
131
  return self.name
@@ -69,6 +140,19 @@ class School(models.Model):
69
140
  return classes
70
141
  return None
71
142
 
143
+ def admins(self):
144
+ teachers = self.teacher_school.all()
145
+ return (
146
+ [teacher for teacher in teachers if teacher.is_admin]
147
+ if teachers
148
+ else None
149
+ )
150
+
151
+ def anonymise(self):
152
+ self.name = uuid4().hex
153
+ self.is_active = False
154
+ self.save()
155
+
72
156
 
73
157
  class TeacherModelManager(models.Manager):
74
158
  def factory(self, first_name, last_name, email, password):
@@ -84,6 +168,13 @@ class TeacherModelManager(models.Manager):
84
168
 
85
169
  return Teacher.objects.create(user=user_profile, new_user=user)
86
170
 
171
+ def get_original_queryset(self):
172
+ return super().get_queryset()
173
+
174
+ # Filter out non active teachers by default
175
+ def get_queryset(self):
176
+ return super().get_queryset().filter(new_user__is_active=True)
177
+
87
178
 
88
179
  class Teacher(models.Model):
89
180
  user = models.OneToOneField(UserProfile, on_delete=models.CASCADE)
@@ -95,32 +186,112 @@ class Teacher(models.Model):
95
186
  on_delete=models.CASCADE,
96
187
  )
97
188
  school = models.ForeignKey(
98
- School, related_name="teacher_school", null=True, on_delete=models.SET_NULL
189
+ School,
190
+ related_name="teacher_school",
191
+ null=True,
192
+ blank=True,
193
+ on_delete=models.SET_NULL,
99
194
  )
100
195
  is_admin = models.BooleanField(default=False)
101
- pending_join_request = models.ForeignKey(
102
- School,
103
- related_name="join_request",
196
+ blocked_time = models.DateTimeField(null=True, blank=True)
197
+ invited_by = models.ForeignKey(
198
+ "self",
199
+ related_name="invited_teachers",
104
200
  null=True,
105
201
  blank=True,
106
202
  on_delete=models.SET_NULL,
107
203
  )
108
- blocked_time = models.DateTimeField(null=True)
109
204
 
110
205
  objects = TeacherModelManager()
111
206
 
207
+ class Meta:
208
+ constraints = [
209
+ models.CheckConstraint(
210
+ check=~models.Q(
211
+ school__isnull=True,
212
+ is_admin=True,
213
+ ),
214
+ name="teacher__is_admin",
215
+ )
216
+ ]
217
+
112
218
  def teaches(self, userprofile):
113
219
  if hasattr(userprofile, "student"):
114
220
  student = userprofile.student
115
- return not student.is_independent() and student.class_field.teacher == self
221
+ return (
222
+ not student.is_independent()
223
+ and student.class_field.teacher == self
224
+ )
116
225
 
117
226
  def has_school(self):
118
227
  return self.school is not (None or "")
119
228
 
229
+ def has_class(self):
230
+ return self.class_teacher.exists()
231
+
120
232
  def __str__(self):
121
233
  return f"{self.new_user.first_name} {self.new_user.last_name}"
122
234
 
123
235
 
236
+ class SchoolTeacherInvitationModelManager(models.Manager):
237
+ def get_original_queryset(self):
238
+ return super().get_queryset()
239
+
240
+ # Filter out inactive invitations by default
241
+ def get_queryset(self):
242
+ return super().get_queryset().filter(is_active=True)
243
+
244
+
245
+ class SchoolTeacherInvitation(models.Model):
246
+ token = models.CharField(max_length=88)
247
+ school = models.ForeignKey(
248
+ School,
249
+ related_name="teacher_invitations",
250
+ null=True,
251
+ on_delete=models.SET_NULL,
252
+ )
253
+ from_teacher = models.ForeignKey(
254
+ Teacher,
255
+ related_name="school_invitations",
256
+ null=True,
257
+ on_delete=models.SET_NULL,
258
+ )
259
+ invited_teacher_first_name = models.CharField(
260
+ max_length=150
261
+ ) # Same as User model
262
+ # TODO: Make not nullable once data has been transferred
263
+ _invited_teacher_first_name = models.BinaryField(null=True, blank=True)
264
+ invited_teacher_last_name = models.CharField(
265
+ max_length=150
266
+ ) # Same as User model
267
+ # TODO: Make not nullable once data has been transferred
268
+ _invited_teacher_last_name = models.BinaryField(null=True, blank=True)
269
+ # TODO: Switch to a CharField to be able to hold hashed value
270
+ invited_teacher_email = models.EmailField() # Same as User model
271
+ # TODO: Make not nullable once data has been transferred
272
+ _invited_teacher_email = models.BinaryField(null=True, blank=True)
273
+ invited_teacher_is_admin = models.BooleanField(default=False)
274
+ expiry = models.DateTimeField()
275
+ creation_time = models.DateTimeField(default=timezone.now, null=True)
276
+ is_active = models.BooleanField(default=True)
277
+
278
+ objects = SchoolTeacherInvitationModelManager()
279
+
280
+ @property
281
+ def is_expired(self):
282
+ return self.expiry < timezone.now()
283
+
284
+ def __str__(self):
285
+ return f"School teacher invitation for {self.invited_teacher_email} to {self.school.name}"
286
+
287
+ def anonymise(self):
288
+ self.invited_teacher_first_name = uuid4().hex
289
+ self.invited_teacher_last_name = uuid4().hex
290
+ self.invited_teacher_email = uuid4().hex
291
+ self.is_active = False
292
+ self.save()
293
+
294
+
124
295
  class ClassModelManager(models.Manager):
125
296
  def all_members(self, user):
126
297
  members = []
@@ -136,22 +307,50 @@ class ClassModelManager(models.Manager):
136
307
  members.extend(c.students.all())
137
308
  return members
138
309
 
310
+ def get_original_queryset(self):
311
+ return super().get_queryset()
312
+
313
+ # Filter out non active classes by default
314
+ def get_queryset(self):
315
+ return super().get_queryset().filter(is_active=True)
316
+
139
317
 
140
318
  class Class(models.Model):
319
+ locked_worksheets: "ManyToManyField[Worksheet]"
320
+
141
321
  name = models.CharField(max_length=200)
142
322
  teacher = models.ForeignKey(
143
323
  Teacher, related_name="class_teacher", on_delete=models.CASCADE
144
324
  )
145
- access_code = models.CharField(max_length=5)
325
+ access_code = models.CharField(max_length=5, null=True)
146
326
  classmates_data_viewable = models.BooleanField(default=False)
147
327
  always_accept_requests = models.BooleanField(default=False)
148
328
  accept_requests_until = models.DateTimeField(null=True)
329
+ creation_time = models.DateTimeField(default=timezone.now, null=True)
330
+ is_active = models.BooleanField(default=True)
331
+ created_by = models.ForeignKey(
332
+ Teacher,
333
+ null=True,
334
+ blank=True,
335
+ related_name="created_classes",
336
+ on_delete=models.SET_NULL,
337
+ )
149
338
 
150
339
  objects = ClassModelManager()
151
340
 
152
341
  def __str__(self):
153
342
  return self.name
154
343
 
344
+ @property
345
+ def active_game(self):
346
+ games = self.game_set.filter(game_class=self, is_archived=False)
347
+ if len(games) >= 1:
348
+ assert (
349
+ len(games) == 1
350
+ ) # there should NOT be more than one active game
351
+ return games[0]
352
+ return None
353
+
155
354
  def has_students(self):
156
355
  students = self.students.all()
157
356
  return students.count() != 0
@@ -178,10 +377,32 @@ class Class(models.Model):
178
377
 
179
378
  return external_requests_message
180
379
 
380
+ def anonymise(self):
381
+ self.name = uuid4().hex
382
+ self.access_code = ""
383
+ self.is_active = False
384
+ self.save()
385
+
386
+ # Remove independent students' requests to join this class
387
+ self.class_request.clear()
388
+
181
389
  class Meta(object):
182
390
  verbose_name_plural = "classes"
183
391
 
184
392
 
393
+ class UserSession(models.Model):
394
+ user = models.ForeignKey(User, on_delete=models.CASCADE)
395
+ login_time = models.DateTimeField(default=timezone.now)
396
+ school = models.ForeignKey(School, null=True, on_delete=models.SET_NULL)
397
+ class_field = models.ForeignKey(Class, null=True, on_delete=models.SET_NULL)
398
+ login_type = models.CharField(
399
+ max_length=100, null=True
400
+ ) # for student login
401
+
402
+ def __str__(self):
403
+ return f"{self.user} login: {self.login_time} type: {self.login_type}"
404
+
405
+
185
406
  class StudentModelManager(models.Manager):
186
407
  def get_random_username(self):
187
408
  while True:
@@ -191,7 +412,9 @@ class StudentModelManager(models.Manager):
191
412
 
192
413
  def schoolFactory(self, klass, name, password, login_id=None):
193
414
  user = User.objects.create_user(
194
- username=self.get_random_username(), password=password, first_name=name
415
+ username=self.get_random_username(),
416
+ password=password,
417
+ first_name=name,
195
418
  )
196
419
  user_profile = UserProfile.objects.create(user=user)
197
420
 
@@ -202,28 +425,23 @@ class StudentModelManager(models.Manager):
202
425
  login_id=login_id,
203
426
  )
204
427
 
205
- def independentStudentFactory(self, username, name, email, password):
428
+ def independentStudentFactory(self, name, email, password):
206
429
  user = User.objects.create_user(
207
- username=username, email=email, password=password, first_name=name
430
+ username=email, email=email, password=password, first_name=name
208
431
  )
209
432
 
210
433
  user_profile = UserProfile.objects.create(user=user)
211
434
 
212
435
  return Student.objects.create(user=user_profile, new_user=user)
213
436
 
214
- def independent_students(self):
215
- """
216
- Returns all independent students in the database.
217
- :return: A list of all independent students.
218
- """
219
- return [
220
- student for student in Student.objects.all() if student.is_independent()
221
- ]
222
-
223
437
 
224
438
  class Student(models.Model):
225
439
  class_field = models.ForeignKey(
226
- Class, related_name="students", null=True, on_delete=models.CASCADE
440
+ Class,
441
+ related_name="students",
442
+ null=True,
443
+ blank=True,
444
+ on_delete=models.CASCADE,
227
445
  )
228
446
  # hashed uuid used for the unique direct login url
229
447
  login_id = models.CharField(max_length=64, null=True)
@@ -236,9 +454,13 @@ class Student(models.Model):
236
454
  on_delete=models.CASCADE,
237
455
  )
238
456
  pending_class_request = models.ForeignKey(
239
- Class, related_name="class_request", null=True, on_delete=models.SET_NULL
457
+ Class,
458
+ related_name="class_request",
459
+ null=True,
460
+ blank=True,
461
+ on_delete=models.SET_NULL,
240
462
  )
241
- blocked_time = models.DateTimeField(null=True)
463
+ blocked_time = models.DateTimeField(null=True, blank=True)
242
464
 
243
465
  objects = StudentModelManager()
244
466
 
@@ -253,21 +475,83 @@ def stripStudentName(name):
253
475
  return re.sub("[ \t]+", " ", name.strip())
254
476
 
255
477
 
256
- class AimmoCharacterManager(models.Manager):
257
- def sorted(self):
258
- return self.get_queryset().order_by("sort_order")
478
+ # -----------------------------------------------------------------------
479
+ # Below are models used for data tracking and maintenance
480
+ # -----------------------------------------------------------------------
481
+ class JoinReleaseStudent(models.Model):
482
+ """
483
+ To keep track when a student is released to be independent student or
484
+ joins a class to be a school student.
485
+ """
486
+
487
+ JOIN = "join"
488
+ RELEASE = "release"
489
+
490
+ student = models.ForeignKey(
491
+ Student, related_name="student", on_delete=models.CASCADE
492
+ )
493
+ # either "release" or "join"
494
+ action_type = models.CharField(max_length=64)
495
+ action_time = models.DateTimeField(default=timezone.now)
496
+
497
+
498
+ class DailyActivity(models.Model):
499
+ """
500
+ A model to record sets of daily activity. Currently used to record the
501
+ amount of student details download clicks, through the CSV and login
502
+ cards methods, per day.
503
+ """
504
+
505
+ date = models.DateField(default=timezone.now)
506
+ csv_click_count = models.PositiveIntegerField(default=0)
507
+ login_cards_click_count = models.PositiveIntegerField(default=0)
508
+ primary_coding_club_downloads = models.PositiveIntegerField(default=0)
509
+ python_coding_club_downloads = models.PositiveIntegerField(default=0)
510
+ level_control_submits = models.PositiveBigIntegerField(default=0)
511
+ teacher_lockout_resets = models.PositiveIntegerField(default=0)
512
+ indy_lockout_resets = models.PositiveIntegerField(default=0)
513
+ school_student_lockout_resets = models.PositiveIntegerField(default=0)
514
+ anonymised_unverified_teachers = models.PositiveIntegerField(default=0)
515
+ anonymised_unverified_independents = models.PositiveIntegerField(default=0)
516
+
517
+ class Meta:
518
+ verbose_name_plural = "Daily activities"
519
+
520
+ def __str__(self):
521
+ return f"Activity on {self.date}: CSV clicks: {self.csv_click_count}, login cards clicks: {self.login_cards_click_count}, primary pack downloads: {self.primary_coding_club_downloads}, python pack downloads: {self.python_coding_club_downloads}, level control submits: {self.level_control_submits}, teacher lockout resets: {self.teacher_lockout_resets}, indy lockout resets: {self.indy_lockout_resets}, school student lockout resets: {self.school_student_lockout_resets}, unverified teachers anonymised: {self.anonymised_unverified_teachers}, unverified independents anonymised: {self.anonymised_unverified_independents}"
522
+
523
+
524
+ class TotalActivity(models.Model):
525
+ """
526
+ A model to record total activity. Meant to only have one entry which
527
+ records all total activity. An example of this is total ever registrations.
528
+ """
259
529
 
530
+ teacher_registrations = models.PositiveIntegerField(default=0)
531
+ student_registrations = models.PositiveIntegerField(default=0)
532
+ independent_registrations = models.PositiveIntegerField(default=0)
533
+ anonymised_unverified_teachers = models.PositiveIntegerField(default=0)
534
+ anonymised_unverified_independents = models.PositiveIntegerField(default=0)
535
+
536
+ class Meta:
537
+ verbose_name_plural = "Total activity"
538
+
539
+ def __str__(self):
540
+ return "Total activity"
260
541
 
261
- @register_snippet
262
- class AimmoCharacter(models.Model):
263
- name = models.CharField(max_length=255)
264
- description = models.TextField()
265
- image_path = models.CharField(max_length=255)
266
- sort_order = models.IntegerField()
267
542
 
268
- objects = AimmoCharacterManager()
543
+ class DynamicElement(models.Model):
544
+ """
545
+ This model is meant to allow us to quickly update some elements
546
+ dynamically on the website without having to redeploy everytime. For
547
+ example, if a maintenance banner needs to be added, we check the box in
548
+ the Django admin panel, edit the text and it'll show immediately on the
549
+ website.
550
+ """
269
551
 
270
- panels = [FieldPanel("name"), FieldPanel("description"), FieldPanel("image_path")]
552
+ name = models.CharField(max_length=64, unique=True, editable=False)
553
+ active = models.BooleanField(default=False)
554
+ text = models.TextField(null=True, blank=True)
271
555
 
272
556
  def __str__(self) -> str:
273
557
  return self.name
common/permissions.py CHANGED
@@ -1,14 +1,14 @@
1
1
  from functools import wraps
2
2
 
3
- from django.urls import reverse_lazy
3
+ from common.utils import using_two_factor
4
+ from django.http import Http404
4
5
  from django.http import HttpResponseRedirect
6
+ from django.urls import reverse_lazy
5
7
  from rest_framework import permissions
6
8
 
7
- from common.utils import using_two_factor
8
-
9
9
 
10
- def has_completed_auth_setup(u):
11
- return (not using_two_factor(u)) or (u.is_verified() and using_two_factor(u))
10
+ def has_completed_auth_setup(user):
11
+ return (not using_two_factor(user)) or (user.userprofile.is_verified and using_two_factor(user))
12
12
 
13
13
 
14
14
  class LoggedInAsTeacher(permissions.BasePermission):
@@ -35,6 +35,20 @@ def logged_in_as_independent_student(u):
35
35
  return logged_in_as_student(u) and u.userprofile.student.is_independent()
36
36
 
37
37
 
38
+ def logged_in_as_school_student(u):
39
+ return logged_in_as_student(u) and not u.userprofile.student.is_independent()
40
+
41
+
42
+ def check_teacher_authorised(request, class_teacher):
43
+ current_teacher_owns_the_class = class_teacher == request.user.new_teacher
44
+ is_current_teacher_school_admin = (
45
+ class_teacher.school == request.user.new_teacher.school and request.user.new_teacher.is_admin
46
+ )
47
+
48
+ if not (current_teacher_owns_the_class or is_current_teacher_school_admin):
49
+ raise Http404
50
+
51
+
38
52
  def not_logged_in(u):
39
53
  try:
40
54
  if u.userprofile:
@@ -44,9 +58,7 @@ def not_logged_in(u):
44
58
 
45
59
 
46
60
  def not_fully_logged_in(u):
47
- return not_logged_in(u) or (
48
- not logged_in_as_student(u) and not logged_in_as_teacher(u)
49
- )
61
+ return not_logged_in(u) or (not logged_in_as_student(u) and not logged_in_as_teacher(u))
50
62
 
51
63
 
52
64
  def teacher_verified(view_func):