Skip to content

Commit

Permalink
fix: move verification emails to Dotdigital (#2277)
Browse files Browse the repository at this point in the history
* fix: move verification emails to Dotdigital

* Merge branch 'master' into move-verification-emails-to-dotdigital

* fix: fix dotdigital email tests

* fix: fix parent email test

* fix: add github secret binding

* fix: add chrome to dev container

* fix: attempt to fix under 13 test

* fix: attempt to fix patching

* fix: continue work on updating tests

* fix: continue to update tests

* fix: fix package names

* fix: fix test assert

* fix: fix assert

* fix: update tests

* fix: rearrange decorators

* fix: continue work on tests

* fix: fix typo

* fix: testing something out

* fix: testing something out

* fix: undo chrome changes

* fix: adding additional mocks

* fix: debug tests

* fix: debug tests

* fix: debug tests

* fix: debug tests

* fix: debug tests

* fix: debug tests

* fix: debug tests

* fix: debug tests

* fix: debug tests

* fix: add forgotten import

* fix: update dotdigital auth

* fix: fix environmental variable

* fix: address PR comments

* fix: address PR comments

* fix: debug tests

* fix: debug tests

* fix: tidy code, replace verification reminder emails

* fix: fix verification reminder email tests

* fix: debug tests

* fix: debug tests

* fix: fix patch mock

* fix: tidy code

* fix: add id to footer element

* fix: remove id

* fix: dummy commit

* fix: undo dummy commit
  • Loading branch information
evemartin authored Apr 10, 2024
1 parent afdced8 commit 19b96d6
Show file tree
Hide file tree
Showing 48 changed files with 724 additions and 805 deletions.
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

0 comments on commit 19b96d6

Please sign in to comment.