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.
- cfl_common-8.9.15.dist-info/METADATA +47 -0
- cfl_common-8.9.15.dist-info/RECORD +99 -0
- {cfl_common-5.3.0.dist-info → cfl_common-8.9.15.dist-info}/WHEEL +1 -1
- common/app_settings.py +35 -5
- common/csp_config.py +85 -0
- common/fixtures/aimmo_characters.json +30 -30
- common/fixtures/aimmo_characters2.json +1 -1
- common/fixtures/aimmo_characters3.json +35 -0
- common/helpers/data_migration_loader.py +3 -4
- common/helpers/emails.py +228 -108
- common/helpers/generators.py +1 -1
- common/helpers/organisation.py +10 -0
- common/mail.py +201 -0
- common/migrations/0002_emailverification.py +1 -3
- common/migrations/0005_add_worksheets.py +2 -13
- common/migrations/0007_add_pdf_names_to_first_two_worksheets.py +2 -14
- common/migrations/0008_unlock_worksheet_3.py +1 -6
- common/migrations/0011_student_login_id.py +3 -3
- common/migrations/0012_usersession.py +39 -0
- common/migrations/0013_class_school.py +42 -0
- common/migrations/0014_login_type.py +29 -0
- common/migrations/0015_dailyactivity.py +31 -0
- common/migrations/0016_joinreleasestudent.py +42 -0
- common/migrations/0017_copy_email_to_username.py +18 -0
- common/migrations/0018_update_aimmo_character_image_path.py +15 -0
- common/migrations/0019_aimmocharacter_alt.py +16 -0
- common/migrations/0020_class_is_active_and_null_access_code.py +23 -0
- common/migrations/0021_school_is_active.py +28 -0
- common/migrations/0022_school_cleanup.py +29 -0
- common/migrations/0023_userprofile_aimmo_badges.py +22 -0
- common/migrations/0024_teacher_invited_by.py +25 -0
- common/migrations/0025_schoolteacherinvitation.py +47 -0
- common/migrations/0026_teacher_remove_join_request.py +22 -0
- common/migrations/0027_class_created_by.py +25 -0
- common/migrations/0028_coding_club_downloads.py +23 -0
- common/migrations/0029_dynamicelement.py +22 -0
- common/migrations/0030_add_maintenance_banner.py +25 -0
- common/migrations/0031_improve_admin_panel.py +56 -0
- common/migrations/0032_dailyactivity_level_control_submits.py +18 -0
- common/migrations/0033_password_reset_tracking_fields.py +23 -0
- common/migrations/0034_dailyactivity_daily_school_student_lockout_reset.py +18 -0
- common/migrations/0035_rename_lockout_fields.py +27 -0
- common/migrations/0036_rename_awaiting_email_verification_userprofile_is_verified.py +17 -0
- common/migrations/0037_migrate_email_verification.py +21 -0
- common/migrations/0038_delete_emailverification.py +16 -0
- common/migrations/0039_copy_email_to_username.py +18 -0
- common/migrations/0040_school_county.py +18 -0
- common/migrations/0041_populate_gb_counties.py +27 -0
- common/migrations/0042_totalactivity.py +25 -0
- common/migrations/0043_add_total_activity.py +30 -0
- common/migrations/0044_update_activity_models.py +33 -0
- common/migrations/0045_otp.py +23 -0
- common/migrations/0046_alter_school_country.py +19 -0
- common/migrations/0047_delete_school_postcode.py +16 -0
- common/migrations/0048_unique_school_names.py +42 -0
- common/migrations/0049_anonymise_orphan_users.py +29 -0
- common/migrations/0050_anonymise_orphan_schools.py +30 -0
- common/migrations/0051_verify_returning_users.py +26 -0
- common/migrations/0052_add_cse_fields.py +68 -0
- common/migrations/0053_clean_class_data.py +24 -0
- common/migrations/0054_delete_aimmo_models.py +20 -0
- common/migrations/0055_alter_schoolteacherinvitation_token.py +18 -0
- common/migrations/0056_set_non_school_teachers_as_non_admins.py +25 -0
- common/migrations/0057_teacher_teacher__is_admin.py +19 -0
- common/migrations/0058_userprofile_google_refresh_token_and_more.py +24 -0
- common/models.py +347 -63
- common/permissions.py +20 -8
- common/static/common/img/RR_logo.svg +336 -0
- common/static/common/img/brain.svg +1 -0
- common/templates/common/onetrust_cookies_consent_notice.html +6 -6
- common/tests/test_migration_anonymise_orphan_schools.py +30 -0
- common/tests/test_migration_anonymise_orphan_users.py +30 -0
- common/tests/test_migration_blocked_time.py +3 -11
- common/tests/test_migration_remove_teacher_title.py +1 -3
- common/tests/test_migration_unique_school_names.py +33 -0
- common/tests/test_migration_verify_returning_users.py +59 -0
- common/tests/test_models.py +49 -43
- common/tests/utils/classes.py +1 -3
- common/tests/utils/email.py +11 -49
- common/tests/utils/organisation.py +10 -14
- common/tests/utils/student.py +14 -67
- common/tests/utils/teacher.py +16 -38
- common/tests/utils/user.py +1 -3
- cfl_common-5.3.0.dist-info/METADATA +0 -20
- cfl_common-5.3.0.dist-info/RECORD +0 -48
- common/email_messages.py +0 -218
- common/fixtures/unlock_worksheet3.json +0 -20
- common/fixtures/worksheets.json +0 -98
- common/fixtures/worksheets2.json +0 -110
- common/tests/test_migration_aimmo_characters.py +0 -31
- common/tests/test_migration_worksheets.py +0 -49
- {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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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(),
|
|
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,
|
|
428
|
+
def independentStudentFactory(self, name, email, password):
|
|
206
429
|
user = User.objects.create_user(
|
|
207
|
-
username=
|
|
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,
|
|
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,
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
11
|
-
return (not using_two_factor(
|
|
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):
|