Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: move verification emails to Dotdigital #2277

Merged
merged 47 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from 43 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
6226ef9
fix: move verification emails to Dotdigital
evemartin Mar 28, 2024
d3b94b6
Merge branch 'master' into move-verification-emails-to-dotdigital
evemartin Mar 28, 2024
48cb482
fix: fix dotdigital email tests
evemartin Apr 2, 2024
dc6b581
fix: fix parent email test
evemartin Apr 2, 2024
84a5001
fix: add github secret binding
evemartin Apr 2, 2024
3eaaecb
fix: add chrome to dev container
evemartin Apr 2, 2024
55d6029
fix: attempt to fix under 13 test
evemartin Apr 2, 2024
33d57f4
fix: attempt to fix patching
evemartin Apr 2, 2024
f81868a
fix: continue work on updating tests
evemartin Apr 4, 2024
261cf33
fix: continue to update tests
evemartin Apr 4, 2024
0aa6d29
fix: fix package names
evemartin Apr 4, 2024
d4e95fe
fix: fix test assert
evemartin Apr 4, 2024
3ba97c2
fix: fix assert
evemartin Apr 4, 2024
9651a2a
fix: update tests
evemartin Apr 4, 2024
fcdf86d
fix: rearrange decorators
evemartin Apr 4, 2024
537f333
fix: continue work on tests
evemartin Apr 4, 2024
8add1ec
fix: fix typo
evemartin Apr 4, 2024
ea6239a
fix: testing something out
evemartin Apr 4, 2024
688013b
fix: testing something out
evemartin Apr 4, 2024
3ef54f4
fix: undo chrome changes
evemartin Apr 4, 2024
6dbe672
fix: adding additional mocks
evemartin Apr 4, 2024
f3a1cae
fix: debug tests
evemartin Apr 4, 2024
9ee8e3e
fix: debug tests
evemartin Apr 4, 2024
3305851
fix: debug tests
evemartin Apr 4, 2024
6502383
fix: debug tests
evemartin Apr 4, 2024
bd4942c
fix: debug tests
evemartin Apr 4, 2024
296d1cf
fix: debug tests
evemartin Apr 4, 2024
4327fee
fix: debug tests
evemartin Apr 4, 2024
dd8b086
fix: debug tests
evemartin Apr 4, 2024
a1f0674
fix: debug tests
evemartin Apr 4, 2024
1dc615a
fix: add forgotten import
evemartin Apr 4, 2024
643dfd2
fix: update dotdigital auth
evemartin Apr 9, 2024
5ab9fe3
fix: fix environmental variable
evemartin Apr 9, 2024
1425477
fix: address PR comments
evemartin Apr 9, 2024
566f528
fix: address PR comments
evemartin Apr 9, 2024
6abe884
fix: debug tests
evemartin Apr 9, 2024
e829610
fix: debug tests
evemartin Apr 9, 2024
1bc639f
fix: tidy code, replace verification reminder emails
evemartin Apr 9, 2024
4d3ac93
fix: fix verification reminder email tests
evemartin Apr 9, 2024
cc1e7c1
fix: debug tests
evemartin Apr 9, 2024
4e9cc43
fix: debug tests
evemartin Apr 9, 2024
dac0b40
fix: fix patch mock
evemartin Apr 10, 2024
b845d05
fix: tidy code
evemartin Apr 10, 2024
1ef51af
fix: add id to footer element
evemartin Apr 10, 2024
0834094
fix: remove id
evemartin Apr 10, 2024
90c49ae
fix: dummy commit
evemartin Apr 10, 2024
b8d9d24
fix: undo dummy commit
evemartin Apr 10, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"ghcr.io/devcontainers/features/python:1": {
"installTools": false,
"version": "3.8"
}
},
"ghcr.io/kreemer/features/chrometesting:1": {}
},
"name": "portal",
"postCreateCommand": "pipenv install --dev",
Expand Down
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,6 @@
"."
],
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false
"python.testing.unittestEnabled": false,
"python.analysis.extraPaths": ["./cfl_common"]
}
1 change: 1 addition & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pyvirtualdisplay = "*"
pytest-mock = "*"
PyPDF2 = "==2.10.6"
black = "*"
isort = "*"

[requires]
python_version = "3.8"
396 changes: 203 additions & 193 deletions Pipfile.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions cfl_common/common/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
# Email address to source notifications from
EMAIL_ADDRESS = getattr(settings, "EMAIL_ADDRESS", "[email protected]")

# Dotdigital authorization details
DOTDIGITAL_AUTH = getattr(settings, "DOTDIGITAL_AUTH", "")

# Dotmailer URLs for adding users to the newsletter address book
DOTMAILER_CREATE_CONTACT_URL = getattr(settings, "DOTMAILER_CREATE_CONTACT_URL", "")
DOTMAILER_MAIN_ADDRESS_BOOK_URL = getattr(settings, "DOTMAILER_MAIN_ADDRESS_BOOK_URL", "")
Expand Down
58 changes: 0 additions & 58 deletions cfl_common/common/email_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,64 +14,6 @@ def resetEmailPasswordMessage(request, domain, uid, token, protocol):
}


def emailVerificationNeededEmail(request, token):
url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': token}))}"
privacy_notice_url = f"{request.build_absolute_uri(reverse('privacy_notice'))}"
terms_url = f"{request.build_absolute_uri(reverse('terms'))}"
return {
"subject": f"Email verification ",
"message": (
f"Please go to {url} to verify your email address.\n\nBy activating the account you confirm that you have "
f"read and agreed to our terms ({terms_url}) and our privacy notice ({privacy_notice_url})."
),
"url": {"verify_url": url},
}


def parentsEmailVerificationNeededEmail(request, user, token):
url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': token}))}"
privacy_notice_url = f"{request.build_absolute_uri(reverse('privacy_notice'))}"
terms_url = f"{request.build_absolute_uri(reverse('terms'))}"
return {
"subject": f"Code for Life account request",
"message": (
f"{user.first_name} has requested to create a Code for Life account so that they can learn how to code for "
f"FREE! 🎉\n\n"
f"{user.first_name} provided your email address as a guardian that is able to read the privacy notice "
f"documents and agree to the terms and conditions related to our website on their behalf.\n\n"
f"If you also wish to receive communication from us, you can sign up for newsletters on our website here. 📧\n\n"
f"Please activate the account for {user.first_name} by following this link: {url}.\n\nBy activating the "
f"account you confirm that you have read and agreed to our terms ({terms_url}) and our privacy notice "
f"({privacy_notice_url})."
),
"url": {"verify_url": url},
}


def emailChangeVerificationEmail(request, token):
url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': token}))}"
return {
"subject": f"Email verification needed",
"message": (
f"You are changing your email, please go to "
f"{url} "
f"to verify your new email address. If you are not part of Code for Life "
f"then please ignore this email."
),
"url": {"verify_url": url},
}


def emailChangeNotificationEmail(request, new_email_address):
return {
"subject": f"Email address update",
"message": (
f"There is a request to change the email address of your account to "
f"{new_email_address}. If this was not you, please get in contact with us."
),
}


def userAlreadyRegisteredEmail(request, email, is_independent_student=False):
if is_independent_student:
login_url = reverse("independent_student_login")
Expand Down
7 changes: 3 additions & 4 deletions cfl_common/common/helpers/data_migration_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ def load_data_from_file(file_name) -> Callable:
For use with migrations.RunPython

Args:
file_name (str): The name of the file containing the data you want to load. Include `.json` at the end. The file must be in the fixtures directory.
file_name (str): The name of the file containing the data you want to load. Include `.json` at the end.
The file must be in the fixtures directory.
"""
absolute_file_path = Path(__file__).resolve().parent.parent / "fixtures" / file_name

Expand All @@ -26,9 +27,7 @@ def _get_model(model_identifier):
try:
return apps.get_model(model_identifier)
except (LookupError, TypeError):
raise base.DeserializationError(
"Invalid model identifier: '%s'" % model_identifier
)
raise base.DeserializationError("Invalid model identifier: '%s'" % model_identifier)

# Replace the _get_model() function on the module, so loaddata can utilize it.
python._get_model = _get_model
Expand Down
72 changes: 27 additions & 45 deletions cfl_common/common/helpers/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,16 @@
import jwt
from common import app_settings
from common.app_settings import domain
from common.email_messages import (
emailChangeNotificationEmail,
emailChangeVerificationEmail,
emailVerificationNeededEmail,
parentsEmailVerificationNeededEmail,
)
from common.models import Teacher, Student
from common.mail import campaign_ids, send_dotdigital_email
from common.models import Student, Teacher
from django.conf import settings
from django.contrib.auth.models import User
from django.core.mail import EmailMultiAlternatives
from django.http import HttpResponse
from django.template import loader
from django.urls import reverse
from django.utils import timezone
from requests import post, get, put, delete
from requests import delete, get, post, put
from requests.exceptions import RequestException

NOTIFICATION_EMAIL = "Code For Life Notification <" + app_settings.EMAIL_ADDRESS + ">"
Expand Down Expand Up @@ -123,55 +119,37 @@ def send_verification_email(request, user, data, new_email=None, age=None):

# if the user is a teacher
if age is None:
message = emailVerificationNeededEmail(request, verification)
send_email(
VERIFICATION_EMAIL,
[user.email],
message["subject"],
message["message"],
message["subject"],
replace_url=message["url"],
url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': verification}))}"

send_dotdigital_email(
campaign_ids["verify_new_user"], [user.email], personalization_values={"VERIFICATION_LINK": url}
)

if _newsletter_ticked(data):
add_to_dotmailer(user.first_name, user.last_name, user.email, DotmailerUserType.TEACHER)
# if the user is an independent student
else:
if age < 13:
message = parentsEmailVerificationNeededEmail(request, user, verification)
send_email(
VERIFICATION_EMAIL,
url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': verification}))}"
send_dotdigital_email(
campaign_ids["verify_new_user_via_parent"],
[user.email],
message["subject"],
message["message"],
message["subject"],
replace_url=message["url"],
personalization_values={"FIRST_NAME": user.first_name, "ACTIVATION_LINK": url},
)
else:
message = emailVerificationNeededEmail(request, verification)
send_email(
VERIFICATION_EMAIL,
[user.email],
message["subject"],
message["message"],
message["subject"],
replace_url=message["url"],
url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': verification}))}"
send_dotdigital_email(
campaign_ids["verify_new_user"], [user.email], personalization_values={"VERIFICATION_LINK": url}
)

if _newsletter_ticked(data):
add_to_dotmailer(user.first_name, user.last_name, user.email, DotmailerUserType.STUDENT)
# verifying change of email address.
else:
verification = generate_token(user, new_email)

message = emailChangeVerificationEmail(request, verification)
send_email(
VERIFICATION_EMAIL,
[new_email],
message["subject"],
message["message"],
message["subject"],
replace_url=message["url"],
url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': verification}))}"
send_dotdigital_email(
campaign_ids["email_change_verification"], [new_email], personalization_values={"VERIFICATION_LINK": url}
)


Expand Down Expand Up @@ -281,8 +259,11 @@ def update_indy_email(user, request, data):
changing_email = True
users_with_email = User.objects.filter(email=new_email)

message = emailChangeNotificationEmail(request, new_email)
send_email(VERIFICATION_EMAIL, [user.email], message["subject"], message["message"], message["subject"])
send_dotdigital_email(
campaign_ids["email_change_notification"],
[user.email],
personalization_values={"NEW_EMAIL_ADDRESS": new_email},
)

# email is available
if not users_with_email.exists():
Expand All @@ -299,9 +280,10 @@ def update_email(user: Teacher or Student, request, data):
changing_email = True
users_with_email = User.objects.filter(email=new_email)

message = emailChangeNotificationEmail(request, new_email)
send_email(
VERIFICATION_EMAIL, [user.new_user.email], message["subject"], message["message"], message["subject"]
send_dotdigital_email(
campaign_ids["email_change_notification"],
[user.new_user.email],
personalization_values={"NEW_EMAIL_ADDRESS": new_email},
)

# email is available
Expand Down
2 changes: 1 addition & 1 deletion cfl_common/common/helpers/generators.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import hashlib
import random
import string
import hashlib
from builtins import range, str
from uuid import uuid4

Expand Down
116 changes: 116 additions & 0 deletions cfl_common/common/mail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import typing as t
from dataclasses import dataclass

import requests
from common import app_settings

campaign_ids = {
"email_change_notification": 1551600,
"email_change_verification": 1551594,
"verify_new_user": 1551577,
"verify_new_user_first_reminder": 1557170,
"verify_new_user_second_reminder": 1557173,
"verify_new_user_via_parent": 1551587,
}


def add_contact(email: str):
"""Add a new contact to Dotdigital."""
# TODO: implement


def remove_contact(email: str):
"""Remove an existing contact from Dotdigital."""
# TODO: implement


@dataclass
class EmailAttachment:
"""An email attachment for a Dotdigital triggered campaign."""

file_name: str
mime_type: str
content: str


# pylint: disable-next=too-many-arguments
def send_dotdigital_email(
campaign_id: int,
to_addresses: t.List[str],
cc_addresses: t.Optional[t.List[str]] = None,
bcc_addresses: t.Optional[t.List[str]] = None,
from_address: t.Optional[str] = None,
personalization_values: t.Optional[t.Dict[str, str]] = None,
metadata: t.Optional[str] = None,
attachments: t.Optional[t.List[EmailAttachment]] = None,
region: str = "r1",
auth: t.Optional[str] = None,
timeout: int = 30,
):
# pylint: disable=line-too-long
"""Send a triggered email campaign using DotDigital's API.

https://developer.dotdigital.com/reference/send-transactional-email-using-a-triggered-campaign

Args:
campaign_id: The ID of the triggered campaign, which needs to be included within the request body.
to_addresses: The email address(es) to send to.
cc_addresses: The CC email address or address to to send to. separate email addresses with a comma. Maximum: 100.
bcc_addresses: The BCC email address or address to to send to. separate email addresses with a comma. Maximum: 100.
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.
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.
metadata: The metadata for your email. It can be either a single value or a series of values in a JSON object.
attachments: A Base64 encoded string. All attachment types are supported. Maximum file size: 15 MB.
region: The Dotdigital region id your account belongs to e.g. r1, r2 or r3.
auth: The authorization header used to enable API access. If None, the value will be retrieved from the DOTDIGITAL_AUTH environment variable.
timeout: Send timeout to avoid hanging.

Raises:
AssertionError: If failed to send email.
"""
# pylint: enable=line-too-long

if auth is None:
auth = app_settings.DOTDIGITAL_AUTH

body = {
"campaignId": campaign_id,
"toAddresses": to_addresses,
}
if cc_addresses is not None:
body["ccAddresses"] = cc_addresses
if bcc_addresses is not None:
body["bccAddresses"] = bcc_addresses
if from_address is not None:
body["fromAddress"] = from_address
if personalization_values is not None:
body["personalizationValues"] = [
{
"name": key,
"value": value,
}
for key, value in personalization_values.items()
]
if metadata is not None:
body["metadata"] = metadata
if attachments is not None:
body["attachments"] = [
{
"fileName": attachment.file_name,
"mimeType": attachment.mime_type,
"content": attachment.content,
}
for attachment in attachments
]

response = requests.post(
url=f"https://{region}-api.dotdigital.com/v2/email/triggered-campaign",
json=body,
headers={
"accept": "text/plain",
"authorization": auth,
},
timeout=timeout,
)

assert response.ok, "Failed to send email." f" Reason: {response.reason}." f" Text: {response.text}."
4 changes: 1 addition & 3 deletions cfl_common/common/migrations/0002_emailverification.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ class Migration(migrations.Migration):
("token", models.CharField(max_length=30)),
(
"email",
models.CharField(
blank=True, default=None, max_length=200, null=True
),
models.CharField(blank=True, default=None, max_length=200, null=True),
),
("expiry", models.DateTimeField()),
("verified", models.BooleanField(default=False)),
Expand Down
6 changes: 1 addition & 5 deletions cfl_common/common/migrations/0005_add_worksheets.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,4 @@ class Migration(migrations.Migration):
("aimmo", "0020_add_info_to_worksheet"),
]

operations = [
migrations.RunPython(
migrations.RunPython.noop, reverse_code=migrations.RunPython.noop
)
]
operations = [migrations.RunPython(migrations.RunPython.noop, reverse_code=migrations.RunPython.noop)]
Loading
Loading