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
common/helpers/emails.py CHANGED
@@ -1,107 +1,190 @@
1
1
  import datetime
2
2
  import json
3
+ from enum import Enum, auto
3
4
  from uuid import uuid4
4
5
 
6
+ import jwt
5
7
  from common import app_settings
6
- from common.email_messages import (
7
- emailChangeNotificationEmail,
8
- emailChangeVerificationEmail,
9
- emailChangeDuplicateNotificationEmail,
10
- emailVerificationNeededEmail,
8
+ from common.mail import (
9
+ address_book_ids,
10
+ campaign_ids,
11
+ django_send_email,
12
+ send_dotdigital_email,
11
13
  )
12
- from common.models import EmailVerification, Teacher, Student
13
- from django.core.mail import EmailMultiAlternatives
14
+ from common.models import Student, Teacher
15
+ from django.conf import settings
16
+ from django.contrib.auth.models import User
14
17
  from django.http import HttpResponse
15
- from django.template import loader
18
+ from django.urls import reverse
16
19
  from django.utils import timezone
17
- from requests import post, get, put
20
+ from requests import delete, get, post, put
18
21
  from requests.exceptions import RequestException
19
22
 
20
23
  NOTIFICATION_EMAIL = "Code For Life Notification <" + app_settings.EMAIL_ADDRESS + ">"
21
24
  VERIFICATION_EMAIL = "Code For Life Verification <" + app_settings.EMAIL_ADDRESS + ">"
22
- PASSWORD_RESET_EMAIL = (
23
- "Code For Life Password Reset <" + app_settings.EMAIL_ADDRESS + ">"
24
- )
25
+ PASSWORD_RESET_EMAIL = "Code For Life Password Reset <" + app_settings.EMAIL_ADDRESS + ">"
25
26
  INVITE_FROM = "Code For Life Invitation <" + app_settings.EMAIL_ADDRESS + ">"
26
27
 
27
28
 
29
+ class DotmailerUserType(Enum):
30
+ TEACHER = auto()
31
+ STUDENT = auto()
32
+ NO_ACCOUNT = auto()
33
+
34
+
35
+ def generate_token(user, new_email="", preverified=False):
36
+ if preverified:
37
+ user.userprofile.is_verified = preverified
38
+ user.userprofile.save()
39
+
40
+ return generate_token_for_email(user.email, new_email)
41
+
42
+
43
+ def generate_token_for_email(email: str, new_email: str = ""):
44
+ return jwt.encode(
45
+ {
46
+ "email": email,
47
+ "new_email": new_email,
48
+ "email_verification_token": uuid4().hex[:30],
49
+ "expires": (timezone.now() + datetime.timedelta(hours=1)).timestamp(),
50
+ },
51
+ settings.SECRET_KEY,
52
+ algorithm="HS256",
53
+ )
54
+
55
+
56
+ def _newsletter_ticked(data):
57
+ return "newsletter_ticked" in data and data["newsletter_ticked"]
58
+
59
+
28
60
  def send_email(
29
61
  sender,
30
62
  recipients,
31
63
  subject,
32
64
  text_content,
33
- html_content=None,
65
+ title,
66
+ replace_url=None,
34
67
  plaintext_template="email.txt",
35
68
  html_template="email.html",
36
69
  ):
37
- # add in template for templates to message
38
-
39
- # setup templates
40
- plaintext = loader.get_template(plaintext_template)
41
- html = loader.get_template(html_template)
42
- plaintext_email_context = {"content": text_content}
43
- html_email_context = {"content": text_content}
44
- if html_content:
45
- html_email_context = {"content": html_content}
46
-
47
- # render templates
48
- plaintext_body = plaintext.render(plaintext_email_context)
49
- html_body = html.render(html_email_context)
50
-
51
- # make message using templates
52
- message = EmailMultiAlternatives(subject, plaintext_body, sender, recipients)
53
- message.attach_alternative(html_body, "text/html")
54
-
55
- message.send()
56
-
57
-
58
- def generate_token(user, email="", preverified=False):
59
- return EmailVerification.objects.create(
60
- user=user,
61
- email=email,
62
- token=uuid4().hex[:30],
63
- expiry=timezone.now() + datetime.timedelta(hours=1),
64
- verified=preverified,
70
+ django_send_email(
71
+ sender,
72
+ recipients,
73
+ subject,
74
+ text_content,
75
+ title,
76
+ replace_url,
77
+ plaintext_template,
78
+ html_template,
65
79
  )
66
80
 
67
81
 
68
- def send_verification_email(request, user, new_email=None):
69
- """Send an email prompting the user to verify their email address."""
82
+ def send_verification_email(request, user, data, new_email=None, age=None, school=None):
83
+ """
84
+ Sends emails relating to email address verification.
70
85
 
71
- if not new_email: # verifying first email address
72
- user.email_verifications.all().delete()
86
+ On registration:
87
+ - if the user is under 13, send a verification email addressed to the parent / guardian
88
+ - if the user is over 13, send a regular verification email
89
+ - if the user is a student who just got released, send a verification email explaining the situation
90
+ - if the user is a student who has requested to sign up to the newsletter, handle their Dotmailer subscription
73
91
 
74
- verification = generate_token(user)
92
+ On email address update:
93
+ - sends an email to the old address alerting the user that an email change request has occurred
94
+ - sends an email to the new address requesting email address verification
75
95
 
76
- message = emailVerificationNeededEmail(request, verification.token)
77
- send_email(
78
- VERIFICATION_EMAIL, [user.email], message["subject"], message["message"]
79
- )
80
-
81
- else: # verifying change of email address.
82
- verification = generate_token(user, new_email)
96
+ :param request: The Django form request
97
+ :param user: The user object concerned with the email verification
98
+ :param data: The data inputted by the user in the Django form.
99
+ :param new_email: New email the user wants to associate to their account - if not provided, it means the user is
100
+ registering a new account
101
+ :param age: The user's age (collected only for the purposes of this function and if the user is an independent
102
+ student)
103
+ """
83
104
 
84
- message = emailChangeVerificationEmail(request, verification.token)
85
- send_email(
86
- VERIFICATION_EMAIL, [new_email], message["subject"], message["message"]
87
- )
105
+ # verifying first email address (registration or unverified login attempt)
106
+ if not new_email:
107
+ verification = generate_token(user)
88
108
 
89
- message = emailChangeNotificationEmail(request)
90
- send_email(
91
- VERIFICATION_EMAIL, [user.email], message["subject"], message["message"]
109
+ if age is None:
110
+ # if the user is a released student
111
+ if hasattr(user, "new_student") and school is not None:
112
+ url = f"{app_settings.domain(request)}{reverse('verify_email', kwargs={'token': verification})}"
113
+
114
+ send_dotdigital_email(
115
+ campaign_ids["verify_released_student"],
116
+ [user.email],
117
+ personalization_values={
118
+ "VERIFICATION_LINK": url,
119
+ "SCHOOL_NAME": school.name,
120
+ },
121
+ )
122
+ else:
123
+ url = f"{app_settings.domain(request)}{reverse('verify_email', kwargs={'token': verification})}"
124
+
125
+ send_dotdigital_email(
126
+ campaign_ids["verify_new_user"],
127
+ [user.email],
128
+ personalization_values={"VERIFICATION_LINK": url},
129
+ )
130
+
131
+ if _newsletter_ticked(data):
132
+ add_to_dotmailer(
133
+ user.first_name,
134
+ user.last_name,
135
+ user.email,
136
+ address_book_ids["newsletter"],
137
+ DotmailerUserType.TEACHER,
138
+ )
139
+ # if the user is an independent student
140
+ else:
141
+ if age < 13:
142
+ url = f"{app_settings.domain(request)}{reverse('verify_email', kwargs={'token': verification})}"
143
+ send_dotdigital_email(
144
+ campaign_ids["verify_new_user_via_parent"],
145
+ [user.email],
146
+ personalization_values={
147
+ "FIRST_NAME": user.first_name,
148
+ "ACTIVATION_LINK": url,
149
+ },
150
+ )
151
+ else:
152
+ url = f"{app_settings.domain(request)}{reverse('verify_email', kwargs={'token': verification})}"
153
+ send_dotdigital_email(
154
+ campaign_ids["verify_new_user"],
155
+ [user.email],
156
+ personalization_values={"VERIFICATION_LINK": url},
157
+ )
158
+
159
+ if _newsletter_ticked(data):
160
+ add_to_dotmailer(
161
+ user.first_name,
162
+ user.last_name,
163
+ user.email,
164
+ address_book_ids["newsletter"],
165
+ DotmailerUserType.STUDENT,
166
+ )
167
+ # verifying change of email address.
168
+ else:
169
+ verification = generate_token(user, new_email)
170
+ url = f"{app_settings.domain(request)}{reverse('verify_email', kwargs={'token': verification})}"
171
+ send_dotdigital_email(
172
+ campaign_ids["email_change_verification"],
173
+ [new_email],
174
+ personalization_values={"VERIFICATION_LINK": url},
92
175
  )
93
176
 
94
177
 
95
- def is_verified(user):
96
- """Check that a user has verified their email address."""
97
- verifications = user.email_verifications.filter(verified=True)
98
- return len(verifications) != 0
99
-
100
-
101
- def add_to_dotmailer(first_name: str, last_name: str, email: str):
178
+ def add_to_dotmailer(
179
+ first_name: str,
180
+ last_name: str,
181
+ email: str,
182
+ address_book_id: int,
183
+ user_type: DotmailerUserType = None,
184
+ ):
102
185
  try:
103
186
  create_contact(first_name, last_name, email)
104
- add_contact_to_address_book(first_name, last_name, email)
187
+ add_contact_to_address_book(first_name, last_name, email, address_book_id, user_type)
105
188
  except RequestException:
106
189
  return HttpResponse(status=404)
107
190
 
@@ -125,7 +208,7 @@ def create_contact(first_name, last_name, email):
125
208
  {
126
209
  "key": "DATETIMECONSENTED",
127
210
  "value": datetime.datetime.now().__str__(),
128
- },
211
+ }
129
212
  ]
130
213
  }
131
214
  ],
@@ -139,8 +222,15 @@ def create_contact(first_name, last_name, email):
139
222
  )
140
223
 
141
224
 
142
- def add_contact_to_address_book(first_name, last_name, email):
143
- url = app_settings.DOTMAILER_ADDRESS_BOOK_URL
225
+ def add_contact_to_address_book(
226
+ first_name: str,
227
+ last_name: str,
228
+ email: str,
229
+ address_book_id: int,
230
+ user_type: DotmailerUserType = None,
231
+ ):
232
+ main_address_book_url = f"https://r1-api.dotmailer.com/v2/address-books/{address_book_id}/contacts"
233
+
144
234
  body = {
145
235
  "email": email,
146
236
  "optInType": "VerifiedDouble",
@@ -153,19 +243,47 @@ def add_contact_to_address_book(first_name, last_name, email):
153
243
  }
154
244
 
155
245
  post(
156
- url,
246
+ main_address_book_url,
157
247
  json=body,
158
248
  auth=(app_settings.DOTMAILER_USER, app_settings.DOTMAILER_PASSWORD),
159
249
  )
160
250
 
251
+ if user_type is not None:
252
+ specific_address_book_url = app_settings.DOTMAILER_NO_ACCOUNT_ADDRESS_BOOK_URL
253
+
254
+ if user_type == DotmailerUserType.TEACHER:
255
+ specific_address_book_url = app_settings.DOTMAILER_TEACHER_ADDRESS_BOOK_URL
256
+ elif user_type == DotmailerUserType.STUDENT:
257
+ specific_address_book_url = app_settings.DOTMAILER_STUDENT_ADDRESS_BOOK_URL
258
+
259
+ post(
260
+ specific_address_book_url,
261
+ json=body,
262
+ auth=(app_settings.DOTMAILER_USER, app_settings.DOTMAILER_PASSWORD),
263
+ )
264
+
265
+
266
+ def delete_contact(email: str):
267
+ try:
268
+ user = get_dotmailer_user_by_email(email)
269
+ user_id = user.get("id")
270
+ if user_id:
271
+ url = app_settings.DOTMAILER_DELETE_USER_BY_ID_URL.replace("ID", str(user_id))
272
+ delete(
273
+ url,
274
+ auth=(
275
+ app_settings.DOTMAILER_USER,
276
+ app_settings.DOTMAILER_PASSWORD,
277
+ ),
278
+ )
279
+ except RequestException:
280
+ return HttpResponse(status=404)
281
+
161
282
 
162
283
  def get_dotmailer_user_by_email(email):
163
284
  url = app_settings.DOTMAILER_GET_USER_BY_EMAIL_URL.replace("EMAIL", email)
164
285
 
165
- response = get(
166
- url,
167
- auth=(app_settings.DOTMAILER_USER, app_settings.DOTMAILER_PASSWORD),
168
- )
286
+ response = get(url, auth=(app_settings.DOTMAILER_USER, app_settings.DOTMAILER_PASSWORD))
169
287
 
170
288
  return json.loads(response.content)
171
289
 
@@ -173,9 +291,7 @@ def get_dotmailer_user_by_email(email):
173
291
  def add_consent_record_to_dotmailer_user(user):
174
292
  consent_date_time = datetime.datetime.now().__str__()
175
293
 
176
- url = app_settings.DOTMAILER_PUT_CONSENT_DATA_URL.replace(
177
- "USER_ID", str(user["id"])
178
- )
294
+ url = app_settings.DOTMAILER_PUT_CONSENT_DATA_URL.replace("USER_ID", str(user["id"]))
179
295
  body = {
180
296
  "contact": {
181
297
  "email": user["email"],
@@ -183,13 +299,7 @@ def add_consent_record_to_dotmailer_user(user):
183
299
  "emailType": user["emailType"],
184
300
  "dataFields": user["dataFields"],
185
301
  },
186
- "consentFields": [
187
- {
188
- "fields": [
189
- {"key": "DATETIMECONSENTED", "value": consent_date_time},
190
- ]
191
- }
192
- ],
302
+ "consentFields": [{"fields": [{"key": "DATETIMECONSENTED", "value": consent_date_time}]}],
193
303
  }
194
304
 
195
305
  put(
@@ -202,10 +312,7 @@ def add_consent_record_to_dotmailer_user(user):
202
312
  def send_dotmailer_consent_confirmation_email_to_user(user):
203
313
  url = app_settings.DOTMAILER_SEND_CAMPAIGN_URL
204
314
  campaign_id = app_settings.DOTMAILER_THANKS_FOR_STAYING_CAMPAIGN_ID
205
- body = {
206
- "campaignID": campaign_id,
207
- "contactIds": [str(user["id"])],
208
- }
315
+ body = {"campaignID": campaign_id, "contactIds": [str(user["id"])]}
209
316
 
210
317
  post(
211
318
  url,
@@ -214,30 +321,43 @@ def send_dotmailer_consent_confirmation_email_to_user(user):
214
321
  )
215
322
 
216
323
 
217
- def update_email(user: Teacher or Student, request, data):
324
+ def update_indy_email(user, request, data):
218
325
  changing_email = False
219
326
  new_email = data["email"]
220
327
 
221
- if new_email != "" and new_email != user.new_user.email:
328
+ if new_email != "" and new_email != user.email:
222
329
  changing_email = True
223
- if _is_email_already_taken(new_email, user):
224
- email_message = emailChangeDuplicateNotificationEmail(request, new_email)
225
- send_email(
226
- NOTIFICATION_EMAIL,
227
- [user.new_user.email],
228
- email_message["subject"],
229
- email_message["message"],
230
- )
231
- else:
330
+ users_with_email = User.objects.filter(email=new_email)
331
+
332
+ send_dotdigital_email(
333
+ campaign_ids["email_change_notification"],
334
+ [user.email],
335
+ personalization_values={"NEW_EMAIL_ADDRESS": new_email},
336
+ )
337
+
338
+ # email is available
339
+ if not users_with_email.exists():
232
340
  # new email to set and verify
233
- send_verification_email(request, user.new_user, new_email)
341
+ send_verification_email(request, user, data, new_email)
234
342
  return changing_email, new_email
235
343
 
236
344
 
237
- def _is_email_already_taken(new_email, user):
238
- teachers_with_email = Teacher.objects.filter(new_user__email=new_email)
239
- students_with_email = Student.objects.filter(new_user__email=new_email)
345
+ def update_email(user: Teacher or Student, request, data):
346
+ changing_email = False
347
+ new_email = data["email"]
240
348
 
241
- return (teachers_with_email.exists() and teachers_with_email[0] != user) or (
242
- students_with_email.exists() and students_with_email[0] != user
243
- )
349
+ if new_email != "" and new_email != user.new_user.email:
350
+ changing_email = True
351
+ users_with_email = User.objects.filter(email=new_email)
352
+
353
+ send_dotdigital_email(
354
+ campaign_ids["email_change_notification"],
355
+ [user.new_user.email],
356
+ personalization_values={"NEW_EMAIL_ADDRESS": new_email},
357
+ )
358
+
359
+ # email is available
360
+ if not users_with_email.exists():
361
+ # new email to set and verify
362
+ send_verification_email(request, user.new_user, data, new_email)
363
+ return changing_email, new_email
@@ -1,6 +1,6 @@
1
+ import hashlib
1
2
  import random
2
3
  import string
3
- import hashlib
4
4
  from builtins import range, str
5
5
  from uuid import uuid4
6
6
 
@@ -0,0 +1,10 @@
1
+ # TODO: Move to Address model once we create it
2
+ def sanitise_uk_postcode(postcode):
3
+ if len(postcode) >= 5: # Valid UK postcodes are at least 5 chars long
4
+ outcode = postcode[:-3] # UK incodes are always 3 characters
5
+
6
+ # Insert a space between outcode and incode if there isn't already one
7
+ if not outcode.endswith(" "):
8
+ postcode = postcode[:-3] + " " + postcode[-3:]
9
+
10
+ return postcode
common/mail.py ADDED
@@ -0,0 +1,201 @@
1
+ import typing as t
2
+ from dataclasses import dataclass
3
+
4
+ import requests
5
+ from common import app_settings
6
+ from common.app_settings import MODULE_NAME, domain
7
+ from django.core.mail import EmailMultiAlternatives
8
+ from django.template import loader
9
+
10
+ campaign_ids = {
11
+ "admin_given": 1569057,
12
+ "admin_revoked": 1569071,
13
+ "delete_account": 1567477,
14
+ "email_change_notification": 1551600,
15
+ "email_change_verification": 1551594,
16
+ "invite_teacher_with_account": 1569599,
17
+ "invite_teacher_without_account": 1569607,
18
+ "level_creation": 1570259,
19
+ "reset_password": 1557153,
20
+ "student_join_request_notification": 1569486,
21
+ "student_join_request_rejected": 1569470,
22
+ "student_join_request_sent": 1569477,
23
+ "teacher_released": 1569537,
24
+ "user_already_registered": 1569539,
25
+ "verify_new_user": 1551577,
26
+ "verify_new_user_first_reminder": 1557170,
27
+ "verify_new_user_second_reminder": 1557173,
28
+ "verify_new_user_via_parent": 1551587,
29
+ "verify_released_student": 1580574,
30
+ "inactive_users_on_website_first_reminder": 1604381,
31
+ "inactive_users_on_website_second_reminder": 1606208,
32
+ "inactive_users_on_website_final_reminder": 1606215,
33
+ }
34
+
35
+ address_book_ids = {
36
+ "newsletter": 9705772,
37
+ "donors": 37649245,
38
+ }
39
+
40
+
41
+ def add_contact(email: str):
42
+ """Add a new contact to Dotdigital."""
43
+ # TODO: implement
44
+
45
+
46
+ def remove_contact(email: str):
47
+ """Remove an existing contact from Dotdigital."""
48
+ # TODO: implement
49
+
50
+
51
+ @dataclass
52
+ class EmailAttachment:
53
+ """An email attachment for a Dotdigital triggered campaign."""
54
+
55
+ file_name: str
56
+ mime_type: str
57
+ content: str
58
+
59
+
60
+ def django_send_email(
61
+ sender,
62
+ recipients,
63
+ subject,
64
+ text_content,
65
+ title,
66
+ replace_url=None,
67
+ plaintext_template="email.txt",
68
+ html_template="email.html",
69
+ ):
70
+ # add in template for templates to message
71
+
72
+ # setup templates
73
+ plaintext = loader.get_template(plaintext_template)
74
+ html = loader.get_template(html_template)
75
+ plaintext_email_context = {"content": text_content}
76
+ html_email_context = {
77
+ "content": text_content,
78
+ "title": title,
79
+ "url_prefix": domain(),
80
+ }
81
+
82
+ # render templates
83
+ plaintext_body = plaintext.render(plaintext_email_context)
84
+ original_html_body = html.render(html_email_context)
85
+ html_body = original_html_body
86
+
87
+ if replace_url:
88
+ verify_url = replace_url["verify_url"]
89
+ verify_replace_url = re.sub(
90
+ f"(.*/verify_email/)(.*)", f"\\1", verify_url
91
+ )
92
+ html_body = re.sub(
93
+ f"({verify_url})(.*){verify_url}",
94
+ f"\\1\\2{verify_replace_url}",
95
+ original_html_body,
96
+ )
97
+
98
+ # make message using templates
99
+ message = EmailMultiAlternatives(
100
+ subject, plaintext_body, sender, recipients
101
+ )
102
+ message.attach_alternative(html_body, "text/html")
103
+
104
+ message.send()
105
+
106
+
107
+ # pylint: disable-next=too-many-arguments
108
+ def send_dotdigital_email(
109
+ campaign_id: int,
110
+ to_addresses: t.List[str],
111
+ cc_addresses: t.Optional[t.List[str]] = None,
112
+ bcc_addresses: t.Optional[t.List[str]] = None,
113
+ from_address: t.Optional[str] = None,
114
+ personalization_values: t.Optional[t.Dict[str, str]] = None,
115
+ metadata: t.Optional[str] = None,
116
+ attachments: t.Optional[t.List[EmailAttachment]] = None,
117
+ region: str = "r1",
118
+ auth: t.Optional[str] = None,
119
+ timeout: int = 30,
120
+ ):
121
+ # pylint: disable=line-too-long
122
+ """Send a triggered email campaign using DotDigital's API.
123
+
124
+ https://developer.dotdigital.com/reference/send-transactional-email-using-a-triggered-campaign
125
+
126
+ Args:
127
+ campaign_id: The ID of the triggered campaign, which needs to be included within the request body.
128
+ to_addresses: The email address(es) to send to.
129
+ cc_addresses: The CC email address or address to to send to. separate email addresses with a comma. Maximum: 100.
130
+ bcc_addresses: The BCC email address or address to to send to. separate email addresses with a comma. Maximum: 100.
131
+ from_address: The From address for your email. Note: The From address must already be added to your account. Otherwise, your account's default From address is used.
132
+ personalization_values: Each personalisation value is a key-value pair; the placeholder name of the personalization value needs to be included in the request body.
133
+ metadata: The metadata for your email. It can be either a single value or a series of values in a JSON object.
134
+ attachments: A Base64 encoded string. All attachment types are supported. Maximum file size: 15 MB.
135
+ region: The Dotdigital region id your account belongs to e.g. r1, r2 or r3.
136
+ auth: The authorization header used to enable API access. If None, the value will be retrieved from the DOTDIGITAL_AUTH environment variable.
137
+ timeout: Send timeout to avoid hanging.
138
+
139
+ Raises:
140
+ AssertionError: If failed to send email.
141
+ """
142
+ # pylint: enable=line-too-long
143
+
144
+ # Dotdigital emails don't work locally, so if testing emails locally use Django to send a dummy email instead
145
+ if MODULE_NAME == "local":
146
+ django_send_email(
147
+ from_address,
148
+ to_addresses,
149
+ "dummy_subject",
150
+ "dummy_text_content",
151
+ "dummy_title",
152
+ )
153
+ else:
154
+ if auth is None:
155
+ auth = app_settings.DOTDIGITAL_AUTH
156
+
157
+ body = {
158
+ "campaignId": campaign_id,
159
+ "toAddresses": to_addresses,
160
+ }
161
+ if cc_addresses is not None:
162
+ body["ccAddresses"] = cc_addresses
163
+ if bcc_addresses is not None:
164
+ body["bccAddresses"] = bcc_addresses
165
+ if from_address is not None:
166
+ body["fromAddress"] = from_address
167
+ if personalization_values is not None:
168
+ body["personalizationValues"] = [
169
+ {
170
+ "name": key,
171
+ "value": value,
172
+ }
173
+ for key, value in personalization_values.items()
174
+ ]
175
+ if metadata is not None:
176
+ body["metadata"] = metadata
177
+ if attachments is not None:
178
+ body["attachments"] = [
179
+ {
180
+ "fileName": attachment.file_name,
181
+ "mimeType": attachment.mime_type,
182
+ "content": attachment.content,
183
+ }
184
+ for attachment in attachments
185
+ ]
186
+
187
+ response = requests.post(
188
+ url=f"https://{region}-api.dotdigital.com/v2/email/triggered-campaign",
189
+ json=body,
190
+ headers={
191
+ "accept": "text/plain",
192
+ "authorization": auth,
193
+ },
194
+ timeout=timeout,
195
+ )
196
+
197
+ assert response.ok, (
198
+ "Failed to send email."
199
+ f" Reason: {response.reason}."
200
+ f" Text: {response.text}."
201
+ )
@@ -32,9 +32,7 @@ class Migration(migrations.Migration):
32
32
  ("token", models.CharField(max_length=30)),
33
33
  (
34
34
  "email",
35
- models.CharField(
36
- blank=True, default=None, max_length=200, null=True
37
- ),
35
+ models.CharField(blank=True, default=None, max_length=200, null=True),
38
36
  ),
39
37
  ("expiry", models.DateTimeField()),
40
38
  ("verified", models.BooleanField(default=False)),