cfl-common 7.4.3__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.
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: cfl-common
3
+ Version: 8.9.15
4
+ Classifier: Programming Language :: Python :: 3
5
+ Classifier: Operating System :: OS Independent
6
+ Requires-Dist: asgiref==3.11.0; python_version >= "3.9"
7
+ Requires-Dist: certifi==2026.1.4; python_version >= "3.7"
8
+ Requires-Dist: cffi==2.0.0; platform_python_implementation != "PyPy"
9
+ Requires-Dist: charset-normalizer==3.4.4; python_version >= "3.7"
10
+ Requires-Dist: cryptography==44.0.1; python_version >= "3.7" and python_full_version not in "3.9.0, 3.9.1"
11
+ Requires-Dist: diff-match-patch==20241021; python_version >= "3.7"
12
+ Requires-Dist: django==5.1.15; python_version >= "3.10"
13
+ Requires-Dist: django-countries==7.6.1
14
+ Requires-Dist: django-csp==3.8
15
+ Requires-Dist: django-formtools==2.5.1; python_version >= "3.8"
16
+ Requires-Dist: django-import-export==4.2.0; python_version >= "3.9"
17
+ Requires-Dist: django-otp==1.7.0; python_version >= "3.8"
18
+ Requires-Dist: django-phonenumber-field==8.4.0; python_version >= "3.10"
19
+ Requires-Dist: django-pipeline==4.0.0; python_version >= "3.9"
20
+ Requires-Dist: django-two-factor-auth==1.17.0; python_version >= "3.8"
21
+ Requires-Dist: djangorestframework==3.16.0; python_version >= "3.9"
22
+ Requires-Dist: idna==3.11; python_version >= "3.8"
23
+ Requires-Dist: libsass==0.23.0; python_version >= "3.8"
24
+ Requires-Dist: more-itertools==8.7.0; python_version >= "3.5"
25
+ Requires-Dist: numpy==2.4.1; python_version >= "3.11"
26
+ Requires-Dist: pandas==2.3.3; python_version >= "3.9"
27
+ Requires-Dist: pgeocode==0.4.0; python_version >= "3.8"
28
+ Requires-Dist: pycparser==2.23; implementation_name != "PyPy"
29
+ Requires-Dist: pyjwt==2.6.0; python_version >= "3.7"
30
+ Requires-Dist: pypng==0.20220715.0
31
+ Requires-Dist: python-dateutil==2.9.0.post0; python_version >= "2.7" and python_version not in "3.0, 3.1, 3.2"
32
+ Requires-Dist: pytz==2025.2
33
+ Requires-Dist: qrcode==7.4.2; python_version >= "3.7"
34
+ Requires-Dist: requests==2.32.5; python_version >= "3.9"
35
+ Requires-Dist: setuptools==80.9.0; python_version >= "3.9"
36
+ Requires-Dist: six==1.17.0; python_version >= "2.7" and python_version not in "3.0, 3.1, 3.2"
37
+ Requires-Dist: sqlparse==0.5.5; python_version >= "3.8"
38
+ Requires-Dist: tablib==3.7.0; python_version >= "3.9"
39
+ Requires-Dist: typing-extensions==4.15.0; python_version >= "3.9"
40
+ Requires-Dist: tzdata==2025.3; python_version >= "2"
41
+ Requires-Dist: urllib3==2.6.3; python_version >= "3.9"
42
+ Requires-Dist: wheel==0.45.1; python_version >= "3.8"
43
+ Dynamic: classifier
44
+ Dynamic: description
45
+ Dynamic: requires-dist
46
+
47
+ Common package for Code for Life
@@ -1,10 +1,10 @@
1
1
  common/__init__.py,sha256=XlncBOpKp_gekbKH7Y_i6yu1qy5tJc3Y8sn8cDy-Vgk,48
2
- common/app_settings.py,sha256=Bw1DXkZpNIdwUJ-cIOjZnngH5_NbMXC0koW7NgQ0pKY,2495
2
+ common/app_settings.py,sha256=br31aXMTs48oyxkvGmCSoGb94Ir8imONuD2uNO3k7-k,2763
3
3
  common/apps.py,sha256=49UXZ3bSkFKvIEOL4zM7y1sAhccQJyRtsoOg5XVd_8Y,129
4
4
  common/context_processors.py,sha256=X0iuX5qu9kMWa7q8osE9CJ2LgM7pPOYQFGdjm8X3rk0,236
5
- common/csp_config.py,sha256=9ECOLnp60ENRFAYEEIoYOMhqQzLgfKA-wkWxeUBwDrQ,2824
5
+ common/csp_config.py,sha256=saeg9LbRr5xw7NDJPlt6fqi8Zz0vI8Rpc4VCS6oJNe8,2976
6
6
  common/mail.py,sha256=pIRfUMVoJWxdv74UqToj_0_pTVTC51z6QlFVLI3QBOw,6874
7
- common/models.py,sha256=yAULJtzuF4loEt6mPrgZjwHGiQH6Hqm82etE7Sofvys,15989
7
+ common/models.py,sha256=Qm-UHUR4Qbjn407HbA-YPFHQQk0qOZAsylukptnBXq0,19149
8
8
  common/permissions.py,sha256=gC6RQGZI2QDBbglx-xr_V4Hl2C2nf1V2_uPmEuoEcJo,2416
9
9
  common/utils.py,sha256=Nn2Npao9Uqad5Js_IdHwF-ow6wrPNpBLW4AO1LxoEBc,1727
10
10
  common/fixtures/aimmo_characters.json,sha256=LqjwI05-H4heSBxl6_hS-nb3gMN_4SNVlDnDRT6qNZ8,1234
@@ -12,7 +12,7 @@ common/fixtures/aimmo_characters2.json,sha256=R-23mbjLrvpH4G9khXKcu7PTDK86xf9eHf
12
12
  common/fixtures/aimmo_characters3.json,sha256=7Pv_6DFastPzmM86sPx6D60Y8Biq84GLL5pWz5NGSUA,1392
13
13
  common/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  common/helpers/data_migration_loader.py,sha256=_BhS5lPmhcuVUbryBmJytlWdHyT02KYyxPkHar32mOE,1748
15
- common/helpers/emails.py,sha256=KyXIudPRgetggOnlNUohArF7cPBuazQI2V6dtMfKnrM,12368
15
+ common/helpers/emails.py,sha256=wBgwTsOQSDIEFGZ3SS4N5XZO9nlwuCVzsjvgH4NNUkc,12060
16
16
  common/helpers/generators.py,sha256=kTL5e91I8wgmjJ-mu4jr9vIacjccUZ5pZSAz5cUNhdM,1505
17
17
  common/helpers/organisation.py,sha256=e-JKumKoXrkMTzZPv0H4ViWL8vtCt7oXJjn_zZ1ec00,427
18
18
  common/migrations/0001_initial.py,sha256=Y2kt2xmdCbrmDXCgqmhXeacicNg26Zj7L7SANSsgAAI,9664
@@ -70,6 +70,9 @@ common/migrations/0052_add_cse_fields.py,sha256=NhUkkcu2EBzJFhewCTccQ63AoANkGq1C
70
70
  common/migrations/0053_clean_class_data.py,sha256=lBKlDa49YwT660o-Ot6IYe4I1KfervTn4uAijda0nyw,584
71
71
  common/migrations/0054_delete_aimmo_models.py,sha256=fmn4mDdlHr5DhPbIl_ygvGwWVFD8TZHAgQ6VUQQgCx8,415
72
72
  common/migrations/0055_alter_schoolteacherinvitation_token.py,sha256=lzPAMaI3zU_q25RuIos_LV97NXOa3MIn_eWaEL71Y4I,403
73
+ common/migrations/0056_set_non_school_teachers_as_non_admins.py,sha256=_BrC9yfSYpP-c7EGFZmz8a4Kx_SxnRhpLMxJ70RVFOY,603
74
+ common/migrations/0057_teacher_teacher__is_admin.py,sha256=4pinm9T3JF3PRNLnbXI6mdFiNyxg1MnRs8lMKC4S5Bc,511
75
+ common/migrations/0058_userprofile_google_refresh_token_and_more.py,sha256=xHBv29aWKGkISxhQd_ao5RcZPg5UkirsVf9sqajkk_8,656
73
76
  common/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
74
77
  common/static/common/img/RR_logo.svg,sha256=DjbNHUHWrYkyuefTsg3uSHFI9-kVHBndpaeznfPPcTw,557149
75
78
  common/static/common/img/brain.svg,sha256=689wY5b7E40oKlGumLoHBRpV5z6zCcCp0sGrxjXToXU,16332
@@ -87,10 +90,10 @@ common/tests/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuF
87
90
  common/tests/utils/classes.py,sha256=ZA2pp9Pyx3rwi0VFwtuUA2Pys9xQJ-L_zE0u2tpwEH4,1094
88
91
  common/tests/utils/email.py,sha256=RljsVjIob4Uqi3O5YhP2ifqfc4cMcdP4Gv0EaL-sHXo,1780
89
92
  common/tests/utils/organisation.py,sha256=vNgKFtU3VPcWRnZfh82yCS90PLAK1XTYJNIxGwfgUI4,966
90
- common/tests/utils/student.py,sha256=PLd980iSlxmMoB8J3C2pVjNC5xHdVxfAkJXzhv_dRhg,3814
91
- common/tests/utils/teacher.py,sha256=bJAYLzZdksCIHNxUJJYGZNLHj-axYro4pd3_sXvXbno,2625
93
+ common/tests/utils/student.py,sha256=GYOyd2VH6QXjjLzjCh4hpT86U5si2URlJJWZwlCVLrU,3846
94
+ common/tests/utils/teacher.py,sha256=KQ_NAl4yQqiX_zwcULQjkovc29JPhnkLR5Nk3Ljzbpg,2661
92
95
  common/tests/utils/user.py,sha256=NvLzZLVP4jy5Hn1iztOYF_BTQ9WsbSmuWMEzGzhAsRU,919
93
- cfl_common-7.4.3.dist-info/METADATA,sha256=N06HG7Ihe70MKhpKcv7F0VgbVV8HnQghxV7EjYNMzBo,400
94
- cfl_common-7.4.3.dist-info/WHEEL,sha256=UvcQYKBHoFqaQd6LKyqHw9fxEolWLQnlzP0h_LgJAfI,91
95
- cfl_common-7.4.3.dist-info/top_level.txt,sha256=LOtYx8KZTmnxM_zLK4rwrcI3PRc40Ihwp5rgaQ-ceaI,7
96
- cfl_common-7.4.3.dist-info/RECORD,,
96
+ cfl_common-8.9.15.dist-info/METADATA,sha256=T3AlBNWQDjp7QKxJHCviAKmbqjc5uclhdg0b9XF32Wk,2486
97
+ cfl_common-8.9.15.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
98
+ cfl_common-8.9.15.dist-info/top_level.txt,sha256=LOtYx8KZTmnxM_zLK4rwrcI3PRc40Ihwp5rgaQ-ceaI,7
99
+ cfl_common-8.9.15.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (74.0.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
common/app_settings.py CHANGED
@@ -44,6 +44,9 @@ DOTMAILER_SEND_CAMPAIGN_URL = getattr(settings, "DOTMAILER_SEND_CAMPAIGN_URL", "
44
44
  # ID of the "Thanks for staying!" campaign in Dotmailer
45
45
  DOTMAILER_THANKS_FOR_STAYING_CAMPAIGN_ID = getattr(settings, "DOTMAILER_THANKS_FOR_STAYING_CAMPAIGN_ID", "")
46
46
 
47
+ # Fernet encryption for OAuth2 sign in
48
+ ENCRYPTION_KEY = getattr(settings, "ENCRYPTION_KEY", "")
49
+
47
50
  # The name of the google app engine service the application is running on, local otherwise
48
51
  MODULE_NAME = getattr(settings, "MODULE_NAME", "local")
49
52
 
@@ -51,12 +54,15 @@ MODULE_NAME = getattr(settings, "MODULE_NAME", "local")
51
54
  COOKIE_MANAGEMENT_ENABLED = getattr(settings, "COOKIE_MANAGEMENT_ENABLED", True)
52
55
 
53
56
 
54
- def domain():
57
+ def domain(request=None):
55
58
  """Returns the full domain depending on whether it's local, dev, staging or prod."""
59
+ if hasattr(settings, "SERVICE_BASE_URL"):
60
+ return getattr(settings, "SERVICE_BASE_URL")
61
+
56
62
  domain = "https://www.codeforlife.education"
57
63
 
58
64
  if MODULE_NAME == "local":
59
- domain = "localhost:8000"
65
+ domain = f"http://{request.get_host()}" if request is not None else "localhost:8000"
60
66
  elif MODULE_NAME == "staging" or MODULE_NAME == "dev":
61
67
  domain = f"https://{MODULE_NAME}-dot-decent-digit-629.appspot.com"
62
68
 
common/csp_config.py CHANGED
@@ -29,7 +29,6 @@ CSP_SCRIPT_SRC = (
29
29
  "https://cdn-ukwest.onetrust.com/",
30
30
  "https://code.iconify.design/2/2.0.3/iconify.min.js",
31
31
  "https://www.googletagmanager.com/",
32
- "https://cdn.mouseflow.com/",
33
32
  "https://www.recaptcha.net/",
34
33
  "https://www.google.com/recaptcha/",
35
34
  "https://www.gstatic.com/recaptcha/",
@@ -50,7 +49,8 @@ CSP_STYLE_SRC = (
50
49
  )
51
50
  CSP_FRAME_SRC = (
52
51
  "https://storage.googleapis.com/",
53
- "https://www.youtube-nocookie.com/",
52
+ "https://2662351606-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/",
53
+ "https://files.gitbook.com/v0/b/gitbook-x-prod.appspot.com/",
54
54
  "https://www.recaptcha.net/",
55
55
  "https://www.google.com/recaptcha/",
56
56
  "https://crowdin.com/",
@@ -78,4 +78,8 @@ CSP_IMG_SRC = (
78
78
  f"{domain()}/static/icons/",
79
79
  )
80
80
  CSP_OBJECT_SRC = (f"{domain()}/static/common/img/", f"{domain()}/static/game/image/")
81
- CSP_MEDIA_SRC = (f"{domain()}/static/game/sound/", f"{domain()}/static/game/js/blockly/media/")
81
+ CSP_MEDIA_SRC = (
82
+ "https://files.gitbook.com/v0/b/gitbook-x-prod.appspot.com/",
83
+ f"{domain()}/static/game/sound/",
84
+ f"{domain()}/static/game/js/blockly/media/",
85
+ )
common/helpers/emails.py CHANGED
@@ -20,15 +20,9 @@ from django.utils import timezone
20
20
  from requests import delete, get, post, put
21
21
  from requests.exceptions import RequestException
22
22
 
23
- NOTIFICATION_EMAIL = (
24
- "Code For Life Notification <" + app_settings.EMAIL_ADDRESS + ">"
25
- )
26
- VERIFICATION_EMAIL = (
27
- "Code For Life Verification <" + app_settings.EMAIL_ADDRESS + ">"
28
- )
29
- PASSWORD_RESET_EMAIL = (
30
- "Code For Life Password Reset <" + app_settings.EMAIL_ADDRESS + ">"
31
- )
23
+ NOTIFICATION_EMAIL = "Code For Life Notification <" + app_settings.EMAIL_ADDRESS + ">"
24
+ VERIFICATION_EMAIL = "Code For Life Verification <" + app_settings.EMAIL_ADDRESS + ">"
25
+ PASSWORD_RESET_EMAIL = "Code For Life Password Reset <" + app_settings.EMAIL_ADDRESS + ">"
32
26
  INVITE_FROM = "Code For Life Invitation <" + app_settings.EMAIL_ADDRESS + ">"
33
27
 
34
28
 
@@ -52,9 +46,7 @@ def generate_token_for_email(email: str, new_email: str = ""):
52
46
  "email": email,
53
47
  "new_email": new_email,
54
48
  "email_verification_token": uuid4().hex[:30],
55
- "expires": (
56
- timezone.now() + datetime.timedelta(hours=1)
57
- ).timestamp(),
49
+ "expires": (timezone.now() + datetime.timedelta(hours=1)).timestamp(),
58
50
  },
59
51
  settings.SECRET_KEY,
60
52
  algorithm="HS256",
@@ -87,9 +79,7 @@ def send_email(
87
79
  )
88
80
 
89
81
 
90
- def send_verification_email(
91
- request, user, data, new_email=None, age=None, school=None
92
- ):
82
+ def send_verification_email(request, user, data, new_email=None, age=None, school=None):
93
83
  """
94
84
  Sends emails relating to email address verification.
95
85
 
@@ -119,7 +109,7 @@ def send_verification_email(
119
109
  if age is None:
120
110
  # if the user is a released student
121
111
  if hasattr(user, "new_student") and school is not None:
122
- url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': verification}))}"
112
+ url = f"{app_settings.domain(request)}{reverse('verify_email', kwargs={'token': verification})}"
123
113
 
124
114
  send_dotdigital_email(
125
115
  campaign_ids["verify_released_student"],
@@ -130,7 +120,7 @@ def send_verification_email(
130
120
  },
131
121
  )
132
122
  else:
133
- url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': verification}))}"
123
+ url = f"{app_settings.domain(request)}{reverse('verify_email', kwargs={'token': verification})}"
134
124
 
135
125
  send_dotdigital_email(
136
126
  campaign_ids["verify_new_user"],
@@ -149,7 +139,7 @@ def send_verification_email(
149
139
  # if the user is an independent student
150
140
  else:
151
141
  if age < 13:
152
- url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': verification}))}"
142
+ url = f"{app_settings.domain(request)}{reverse('verify_email', kwargs={'token': verification})}"
153
143
  send_dotdigital_email(
154
144
  campaign_ids["verify_new_user_via_parent"],
155
145
  [user.email],
@@ -159,7 +149,7 @@ def send_verification_email(
159
149
  },
160
150
  )
161
151
  else:
162
- url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': verification}))}"
152
+ url = f"{app_settings.domain(request)}{reverse('verify_email', kwargs={'token': verification})}"
163
153
  send_dotdigital_email(
164
154
  campaign_ids["verify_new_user"],
165
155
  [user.email],
@@ -177,7 +167,7 @@ def send_verification_email(
177
167
  # verifying change of email address.
178
168
  else:
179
169
  verification = generate_token(user, new_email)
180
- url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': verification}))}"
170
+ url = f"{app_settings.domain(request)}{reverse('verify_email', kwargs={'token': verification})}"
181
171
  send_dotdigital_email(
182
172
  campaign_ids["email_change_verification"],
183
173
  [new_email],
@@ -194,9 +184,7 @@ def add_to_dotmailer(
194
184
  ):
195
185
  try:
196
186
  create_contact(first_name, last_name, email)
197
- add_contact_to_address_book(
198
- first_name, last_name, email, address_book_id, user_type
199
- )
187
+ add_contact_to_address_book(first_name, last_name, email, address_book_id, user_type)
200
188
  except RequestException:
201
189
  return HttpResponse(status=404)
202
190
 
@@ -261,18 +249,12 @@ def add_contact_to_address_book(
261
249
  )
262
250
 
263
251
  if user_type is not None:
264
- specific_address_book_url = (
265
- app_settings.DOTMAILER_NO_ACCOUNT_ADDRESS_BOOK_URL
266
- )
252
+ specific_address_book_url = app_settings.DOTMAILER_NO_ACCOUNT_ADDRESS_BOOK_URL
267
253
 
268
254
  if user_type == DotmailerUserType.TEACHER:
269
- specific_address_book_url = (
270
- app_settings.DOTMAILER_TEACHER_ADDRESS_BOOK_URL
271
- )
255
+ specific_address_book_url = app_settings.DOTMAILER_TEACHER_ADDRESS_BOOK_URL
272
256
  elif user_type == DotmailerUserType.STUDENT:
273
- specific_address_book_url = (
274
- app_settings.DOTMAILER_STUDENT_ADDRESS_BOOK_URL
275
- )
257
+ specific_address_book_url = app_settings.DOTMAILER_STUDENT_ADDRESS_BOOK_URL
276
258
 
277
259
  post(
278
260
  specific_address_book_url,
@@ -286,9 +268,7 @@ def delete_contact(email: str):
286
268
  user = get_dotmailer_user_by_email(email)
287
269
  user_id = user.get("id")
288
270
  if user_id:
289
- url = app_settings.DOTMAILER_DELETE_USER_BY_ID_URL.replace(
290
- "ID", str(user_id)
291
- )
271
+ url = app_settings.DOTMAILER_DELETE_USER_BY_ID_URL.replace("ID", str(user_id))
292
272
  delete(
293
273
  url,
294
274
  auth=(
@@ -303,9 +283,7 @@ def delete_contact(email: str):
303
283
  def get_dotmailer_user_by_email(email):
304
284
  url = app_settings.DOTMAILER_GET_USER_BY_EMAIL_URL.replace("EMAIL", email)
305
285
 
306
- response = get(
307
- url, auth=(app_settings.DOTMAILER_USER, app_settings.DOTMAILER_PASSWORD)
308
- )
286
+ response = get(url, auth=(app_settings.DOTMAILER_USER, app_settings.DOTMAILER_PASSWORD))
309
287
 
310
288
  return json.loads(response.content)
311
289
 
@@ -313,9 +291,7 @@ def get_dotmailer_user_by_email(email):
313
291
  def add_consent_record_to_dotmailer_user(user):
314
292
  consent_date_time = datetime.datetime.now().__str__()
315
293
 
316
- url = app_settings.DOTMAILER_PUT_CONSENT_DATA_URL.replace(
317
- "USER_ID", str(user["id"])
318
- )
294
+ url = app_settings.DOTMAILER_PUT_CONSENT_DATA_URL.replace("USER_ID", str(user["id"]))
319
295
  body = {
320
296
  "contact": {
321
297
  "email": user["email"],
@@ -323,13 +299,7 @@ def add_consent_record_to_dotmailer_user(user):
323
299
  "emailType": user["emailType"],
324
300
  "dataFields": user["dataFields"],
325
301
  },
326
- "consentFields": [
327
- {
328
- "fields": [
329
- {"key": "DATETIMECONSENTED", "value": consent_date_time}
330
- ]
331
- }
332
- ],
302
+ "consentFields": [{"fields": [{"key": "DATETIMECONSENTED", "value": consent_date_time}]}],
333
303
  }
334
304
 
335
305
  put(
@@ -0,0 +1,25 @@
1
+ from django.apps.registry import Apps
2
+ from django.db import migrations
3
+
4
+
5
+ def set_non_school_teachers_as_non_admins(apps: Apps, *args):
6
+ Teacher = apps.get_model("common", "Teacher")
7
+
8
+ Teacher.objects.filter(
9
+ is_admin=True,
10
+ school__isnull=True,
11
+ ).update(is_admin=False)
12
+
13
+
14
+ class Migration(migrations.Migration):
15
+
16
+ dependencies = [
17
+ ("common", "0055_alter_schoolteacherinvitation_token"),
18
+ ]
19
+
20
+ operations = [
21
+ migrations.RunPython(
22
+ code=set_non_school_teachers_as_non_admins,
23
+ reverse_code=migrations.RunPython.noop,
24
+ ),
25
+ ]
@@ -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,12 +1,77 @@
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
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
74
+
10
75
 
11
76
  class UserProfile(models.Model):
12
77
  user = models.OneToOneField(User, on_delete=models.CASCADE)
@@ -27,6 +92,12 @@ class UserProfile(models.Model):
27
92
  username = models.CharField(max_length=200, null=True, blank=True)
28
93
  _username = models.BinaryField(null=True, blank=True)
29
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)
100
+
30
101
  def __str__(self):
31
102
  return f"{self.user.first_name} {self.user.last_name}"
32
103
 
@@ -36,6 +107,9 @@ class UserProfile(models.Model):
36
107
 
37
108
 
38
109
  class SchoolModelManager(models.Manager):
110
+ def get_original_queryset(self):
111
+ return super().get_queryset()
112
+
39
113
  # Filter out inactive schools by default
40
114
  def get_queryset(self):
41
115
  return super().get_queryset().filter(is_active=True)
@@ -94,6 +168,9 @@ class TeacherModelManager(models.Manager):
94
168
 
95
169
  return Teacher.objects.create(user=user_profile, new_user=user)
96
170
 
171
+ def get_original_queryset(self):
172
+ return super().get_queryset()
173
+
97
174
  # Filter out non active teachers by default
98
175
  def get_queryset(self):
99
176
  return super().get_queryset().filter(new_user__is_active=True)
@@ -127,6 +204,17 @@ class Teacher(models.Model):
127
204
 
128
205
  objects = TeacherModelManager()
129
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
+
130
218
  def teaches(self, userprofile):
131
219
  if hasattr(userprofile, "student"):
132
220
  student = userprofile.student
@@ -146,6 +234,9 @@ class Teacher(models.Model):
146
234
 
147
235
 
148
236
  class SchoolTeacherInvitationModelManager(models.Manager):
237
+ def get_original_queryset(self):
238
+ return super().get_queryset()
239
+
149
240
  # Filter out inactive invitations by default
150
241
  def get_queryset(self):
151
242
  return super().get_queryset().filter(is_active=True)
@@ -216,12 +307,17 @@ class ClassModelManager(models.Manager):
216
307
  members.extend(c.students.all())
217
308
  return members
218
309
 
310
+ def get_original_queryset(self):
311
+ return super().get_queryset()
312
+
219
313
  # Filter out non active classes by default
220
314
  def get_queryset(self):
221
315
  return super().get_queryset().filter(is_active=True)
222
316
 
223
317
 
224
318
  class Class(models.Model):
319
+ locked_worksheets: "ManyToManyField[Worksheet]"
320
+
225
321
  name = models.CharField(max_length=200)
226
322
  teacher = models.ForeignKey(
227
323
  Teacher, related_name="class_teacher", on_delete=models.CASCADE
@@ -91,7 +91,7 @@ def generate_independent_student_details():
91
91
  name = "Independent Student %d" % generate_independent_student_details.next_id
92
92
  email_address = "student%d@codeforlife.com" % generate_independent_student_details.next_id
93
93
  username = email_address
94
- password = "$RFVBGT%^YHNmju7"
94
+ password = "$RFVBGT%^YHNmju7$RFVBGT%^YHNmju7$RFVBGT%^YHNmju7"
95
95
 
96
96
  generate_independent_student_details.next_id += 1
97
97
 
@@ -13,7 +13,7 @@ def generate_details(**kwargs):
13
13
  first_name = kwargs.get("first_name", "Test")
14
14
  last_name = kwargs.get("last_name", f"Teacher {random_int}")
15
15
  email_address = kwargs.get("email_address", f"testteacher{random_int}@codeforlife.com")
16
- password = kwargs.get("password", "$RFVBGT%6yhn")
16
+ password = kwargs.get("password", "$RFVBGT%6yhn$RFVBGT%6yhn$RFVBGT%6yhn$RFVBGT%6yhn")
17
17
 
18
18
  return first_name, last_name, email_address, password
19
19
 
@@ -1,13 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: cfl-common
3
- Version: 7.4.3
4
- Classifier: Programming Language :: Python :: 3
5
- Classifier: Operating System :: OS Independent
6
- Requires-Dist: django==3.2.25
7
- Requires-Dist: djangorestframework==3.13.1
8
- Requires-Dist: django-two-factor-auth==1.13.2
9
- Requires-Dist: django-countries==7.3.1
10
- Requires-Dist: pyjwt==2.6.0
11
- Requires-Dist: pgeocode==0.4.0
12
-
13
- Common package for Code for Life