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.
- cfl_common-8.9.15.dist-info/METADATA +47 -0
- {cfl_common-7.4.3.dist-info → cfl_common-8.9.15.dist-info}/RECORD +13 -10
- {cfl_common-7.4.3.dist-info → cfl_common-8.9.15.dist-info}/WHEEL +1 -1
- common/app_settings.py +8 -2
- common/csp_config.py +7 -3
- common/helpers/emails.py +18 -48
- 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 +96 -0
- common/tests/utils/student.py +1 -1
- common/tests/utils/teacher.py +1 -1
- cfl_common-7.4.3.dist-info/METADATA +0 -13
- {cfl_common-7.4.3.dist-info → cfl_common-8.9.15.dist-info}/top_level.txt +0 -0
|
@@ -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=
|
|
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=
|
|
5
|
+
common/csp_config.py,sha256=saeg9LbRr5xw7NDJPlt6fqi8Zz0vI8Rpc4VCS6oJNe8,2976
|
|
6
6
|
common/mail.py,sha256=pIRfUMVoJWxdv74UqToj_0_pTVTC51z6QlFVLI3QBOw,6874
|
|
7
|
-
common/models.py,sha256=
|
|
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=
|
|
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=
|
|
91
|
-
common/tests/utils/teacher.py,sha256=
|
|
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-
|
|
94
|
-
cfl_common-
|
|
95
|
-
cfl_common-
|
|
96
|
-
cfl_common-
|
|
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,,
|
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://
|
|
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 = (
|
|
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
|
-
|
|
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"{
|
|
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"{
|
|
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"{
|
|
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"{
|
|
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"{
|
|
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
|
common/tests/utils/student.py
CHANGED
|
@@ -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
|
|
common/tests/utils/teacher.py
CHANGED
|
@@ -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
|
|
File without changes
|