From e46b47992d58939a0d8c33debee5ecc52681e7dd Mon Sep 17 00:00:00 2001
From: Alex Toff
Date: Mon, 27 Sep 2021 22:08:48 +0100
Subject: [PATCH] feat: Better Emails (#269)
* load local .env
* Implement nicer emails
* Update common.py
* plaintext style changes
* Add tests for composer
* fix namespace
* email testing and inline css for outlook support
* track static files
* add panel component, and put receipt info into booking email
* add test for payment info in booking
* 100coverage
* Give default created target user names, and make anonymous booking emails more tailored
* fix test
---
.gitignore | 3 -
.pre-commit-config.yaml | 73 ++++--
README.md | 2 +
config/settings/common.py | 13 +-
config/settings/local.py | 7 +-
config/settings/production.py | 1 -
conftest.py | 1 +
local.yml | 3 +-
requirements/base.txt | 3 +-
static/css/emails/base.css | 229 ++++++++++++++++++
static/css/emails/button.css | 47 ++++
static/css/emails/panel.css | 25 ++
uobtheatre/bookings/models.py | 68 ++++--
uobtheatre/bookings/schema.py | 6 +-
.../emails/booking_confirmation_email.html | 6 -
.../emails/booking_confirmation_email.txt | 6 -
uobtheatre/bookings/test/test_models.py | 86 ++++++-
uobtheatre/bookings/test/test_mutations.py | 3 +-
uobtheatre/mail/__init__.py | 0
uobtheatre/mail/composer.py | 186 ++++++++++++++
uobtheatre/mail/templates/base_email.html | 72 ++++++
.../mail/templates/components/button.html | 23 ++
.../mail/templates/components/image.html | 17 ++
.../mail/templates/components/panel.html | 16 ++
uobtheatre/mail/test/fixtures/__init__.py | 0
uobtheatre/mail/test/fixtures/plain_text.txt | 13 +
uobtheatre/mail/test/test_composer.py | 120 +++++++++
uobtheatre/productions/test/test_schema.py | 1 -
.../templates/emails/activation_email.html | 18 +-
.../emails/password_reset_email.html | 19 ++
30 files changed, 983 insertions(+), 84 deletions(-)
create mode 100644 static/css/emails/base.css
create mode 100644 static/css/emails/button.css
create mode 100644 static/css/emails/panel.css
delete mode 100644 uobtheatre/bookings/templates/emails/booking_confirmation_email.html
delete mode 100644 uobtheatre/bookings/templates/emails/booking_confirmation_email.txt
create mode 100644 uobtheatre/mail/__init__.py
create mode 100644 uobtheatre/mail/composer.py
create mode 100644 uobtheatre/mail/templates/base_email.html
create mode 100644 uobtheatre/mail/templates/components/button.html
create mode 100644 uobtheatre/mail/templates/components/image.html
create mode 100644 uobtheatre/mail/templates/components/panel.html
create mode 100644 uobtheatre/mail/test/fixtures/__init__.py
create mode 100644 uobtheatre/mail/test/fixtures/plain_text.txt
create mode 100644 uobtheatre/mail/test/test_composer.py
create mode 100644 uobtheatre/users/templates/emails/password_reset_email.html
diff --git a/.gitignore b/.gitignore
index a827651e5..a57acdced 100644
--- a/.gitignore
+++ b/.gitignore
@@ -103,9 +103,6 @@ venv.bak/
# mkdocs documentation
/site
-# django staticfiles
-/static
-
# mypy
.mypy_cache/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c7ddb34f1..aec9162df 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,41 +2,41 @@ repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
hooks:
- - id: trailing-whitespace
- - id: end-of-file-fixer
- - id: check-yaml
- - id: check-json
- exclude: ^.devcontainer/
- - id: fix-encoding-pragma
- args: ['--remove']
- - id: forbid-new-submodules
- - id: mixed-line-ending
- args: ['--fix=lf']
- description: Forces to replace line ending by the UNIX 'lf' character.
- # - id: pretty-format-json
- # args: ['--no-sort-keys']
- - id: check-added-large-files
- args: ['--maxkb=500']
- - id: no-commit-to-branch
- args: [--branch, main]
+ - id: trailing-whitespace
+ - id: end-of-file-fixer
+ exclude: .txt$
+ - id: check-yaml
+ - id: check-json
+ exclude: ^.devcontainer/
+ - id: fix-encoding-pragma
+ args: ["--remove"]
+ - id: forbid-new-submodules
+ - id: mixed-line-ending
+ args: ["--fix=lf"]
+ description: Forces to replace line ending by the UNIX 'lf' character.
+ # - id: pretty-format-json
+ # args: ['--no-sort-keys']
+ - id: check-added-large-files
+ args: ["--maxkb=500"]
+ - id: no-commit-to-branch
+ args: [--branch, main]
- repo: https://github.com/asottile/seed-isort-config
rev: v2.2.0
hooks:
- - id: seed-isort-config
+ - id: seed-isort-config
- repo: https://github.com/PyCQA/isort
rev: 5.8.0
hooks:
- - id: isort
+ - id: isort
exclude: ^t2\.py$
-
- repo: https://github.com/psf/black
rev: 21.5b1
hooks:
- - id: black
- language_version: python3.9
+ - id: black
+ language_version: python3.9
# Flake8 checks what errors there are and gives code
# - repo: https://gitlab.com/pycqa/flake8
@@ -49,10 +49,15 @@ repos:
rev: v1.1
hooks:
- id: autoflake
- args: ['--in-place', '--remove-all-unused-imports', '--remove-unused-variable']
+ args:
+ [
+ "--in-place",
+ "--remove-all-unused-imports",
+ "--remove-unused-variable",
+ ]
- repo: https://github.com/pre-commit/mirrors-pylint
- rev: 'v3.0.0a3' # Use the sha / tag you want to point at
+ rev: "v3.0.0a3" # Use the sha / tag you want to point at
hooks:
- id: pylint
exclude: migrations/
@@ -61,4 +66,22 @@ repos:
rev: v0.812
hooks:
- id: mypy
- additional_dependencies: [django-stubs,dj-database-url,django-filter,django-cors-headers,django-autoslug,graphene-django,psycopg2-binary,django-graphql-auth,gunicorn,shortuuid,squareup,django-environ,django-guardian,djangorestframework,django_tiptap]
+ additional_dependencies:
+ [
+ django-stubs,
+ dj-database-url,
+ django-filter,
+ django-cors-headers,
+ django-autoslug,
+ graphene-django,
+ psycopg2-binary,
+ django-graphql-auth,
+ gunicorn,
+ shortuuid,
+ squareup,
+ django-environ,
+ django-guardian,
+ djangorestframework,
+ django_tiptap,
+ django_inlinecss,
+ ]
diff --git a/README.md b/README.md
index 500b8ecd1..4ff918476 100644
--- a/README.md
+++ b/README.md
@@ -125,3 +125,5 @@ The API image will then need rebuilding to add this dependency. Run:
```
make build
```
+
+If mypy gives an error about not being able to find the new package, add it to the list of "additional_dependencies" in `.pre-commit-config.yaml`.
diff --git a/config/settings/common.py b/config/settings/common.py
index b871918ca..8ecd84cdf 100755
--- a/config/settings/common.py
+++ b/config/settings/common.py
@@ -34,6 +34,7 @@
"guardian",
"django_tiptap",
"rest_framework",
+ "django_inlinecss",
# Your apps
"uobtheatre.users",
"uobtheatre.productions",
@@ -45,6 +46,7 @@
"uobtheatre.addresses",
"uobtheatre.payments",
"uobtheatre.images",
+ "uobtheatre.mail",
"uobtheatre",
)
@@ -70,6 +72,11 @@
# Email
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
+EMAIL_HOST = os.getenv("EMAIL_HOST", "localhost")
+EMAIL_PORT = os.getenv("EMAIL_PORT") or 1025
+EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER")
+EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD")
+DEFAULT_FROM_EMAIL = "UOB Theatre "
ADMINS = (("Author", "webmaster@bristolsta.com"),)
@@ -253,8 +260,12 @@
"REGISTER_MUTATION_FIELDS": ["email", "first_name", "last_name"],
"REGISTER_MUTATION_FIELDS_OPTIONAL": [],
"ALLOW_LOGIN_NOT_VERIFIED": False,
- "ACTIVATION_PATH_ON_EMAIL": "user/email-verify",
+ "ACTIVATION_PATH_ON_EMAIL": "login/activate",
+ "ACTIVATION_SECONDARY_EMAIL_PATH_ON_EMAIL": "user/email-verify",
+ "PASSWORD_RESET_PATH_ON_EMAIL": "login/forgot",
"EMAIL_TEMPLATE_ACTIVATION": "emails/activation_email.html",
+ "EMAIL_TEMPLATE_SECONDARY_EMAIL_ACTIVATION": "emails/activation_email.html",
+ "EMAIL_TEMPLATE_PASSWORD_RESET": "emails/password_reset_email.html",
}
diff --git a/config/settings/local.py b/config/settings/local.py
index 8b31bdf3a..a54e63990 100755
--- a/config/settings/local.py
+++ b/config/settings/local.py
@@ -9,10 +9,9 @@
DEBUG = True
-# Mail
-EMAIL_HOST = "localhost"
-EMAIL_PORT = 1025
-EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
+EMAIL_BACKEND = os.getenv(
+ "EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend"
+)
# CORS
CORS_ORIGIN_ALLOW_ALL = True
diff --git a/config/settings/production.py b/config/settings/production.py
index 409f1c88d..b1b60606c 100755
--- a/config/settings/production.py
+++ b/config/settings/production.py
@@ -52,4 +52,3 @@
}
EMAIL_BACKEND = "anymail.backends.amazon_ses.EmailBackend"
-DEFAULT_FROM_EMAIL = "UOB Theatre "
diff --git a/conftest.py b/conftest.py
index 63ee3ead4..048473161 100644
--- a/conftest.py
+++ b/conftest.py
@@ -46,6 +46,7 @@ def user(self, new_user):
def login(self, user=None):
self.user = user if user else UserFactory()
+ self.user.status.verified = True
return self.user
def logout(self):
diff --git a/local.yml b/local.yml
index 5268089eb..218c880da 100644
--- a/local.yml
+++ b/local.yml
@@ -1,4 +1,4 @@
-version: '3'
+version: "3"
volumes:
uobtheatre_local_postgres_data: {}
@@ -17,6 +17,7 @@ services:
- .:/app/uobtheatre-api:z
env_file:
- ./.envs/.local/.django
+ - .env
- ./.envs/.local/.postgres
ports:
- "8000:8000"
diff --git a/requirements/base.txt b/requirements/base.txt
index 8309c4969..69967b5ae 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -59,5 +59,6 @@ squareup>=9.0.0.20210226
sentry-sdk==1.4.2
whitenoise==5.3.0 # https://github.com/evansd/whitenoise
-
+#=== Email ===#
django-anymail[amazon_ses]==8.4 # https://github.com/anymail/django-anymail
+django-inlinecss==0.3.0
\ No newline at end of file
diff --git a/static/css/emails/base.css b/static/css/emails/base.css
new file mode 100644
index 000000000..f2a71c36a
--- /dev/null
+++ b/static/css/emails/base.css
@@ -0,0 +1,229 @@
+
+@media only screen and (max-width: 600px) {
+ .inner-body {
+ width: 100% !important;
+ }
+
+ .footer {
+ width: 100% !important;
+ }
+}
+
+@media only screen and (max-width: 500px) {
+ .button {
+ width: 100% !important;
+ }
+}
+
+/* Base */
+
+body {
+ box-sizing: border-box;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
+ 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+ position: relative;
+}
+
+body {
+ -webkit-text-size-adjust: none;
+ background-color: #ffffff;
+ color: #718096;
+ height: 100%;
+ line-height: 1.4;
+ margin: 0;
+ padding: 0;
+ width: 100% !important;
+}
+
+p,
+ul,
+ol,
+blockquote {
+ line-height: 1.4;
+ text-align: left;
+}
+
+a {
+ color: #3869d4;
+}
+
+a img {
+ border: none;
+}
+
+/* Typography */
+
+h1 {
+ color: #3d4852;
+ font-size: 18px;
+ font-weight: bold;
+ margin-top: 0;
+ text-align: left;
+}
+
+h2 {
+ font-size: 16px;
+ font-weight: bold;
+ margin-top: 0;
+ text-align: left;
+}
+
+h3 {
+ font-size: 14px;
+ font-weight: bold;
+ margin-top: 0;
+ text-align: left;
+}
+
+p {
+ font-size: 16px;
+ line-height: 1.5em;
+ margin-top: 0;
+ text-align: left;
+}
+
+p.sub {
+ font-size: 12px;
+}
+
+/* Layout */
+
+.wrapper {
+ -premailer-cellpadding: 0;
+ -premailer-cellspacing: 0;
+ -premailer-width: 100%;
+ background-color: #2B303A;
+ margin: 0;
+ padding: 0;
+ width: 100%;
+}
+
+.content {
+ -premailer-cellpadding: 0;
+ -premailer-cellspacing: 0;
+ -premailer-width: 100%;
+ margin: 0;
+ padding: 0;
+ width: 100%;
+}
+
+/* Header */
+
+.header {
+ padding: 25px 0;
+ text-align: center;
+}
+
+.header a {
+ color: #dadada;
+ font-size: 19px;
+ font-weight: bold;
+ text-decoration: none;
+}
+
+/* Logo */
+
+.logo {
+ height: 75px;
+ max-height: 75px;
+ width: 75px;
+}
+
+/* Body */
+
+.body {
+ -premailer-cellpadding: 0;
+ -premailer-cellspacing: 0;
+ -premailer-width: 100%;
+ background-color: #2B303A;
+ border-bottom: 1px solid #2B303A;
+ border-top: 1px solid #2B303A;
+ margin: 0;
+ padding: 0;
+ width: 100%;
+}
+
+.inner-body {
+ -premailer-cellpadding: 0;
+ -premailer-cellspacing: 0;
+ -premailer-width: 570px;
+ background-color: #ffffff;
+ border-color: #e8e5ef;
+ border-radius: 2px;
+ border-width: 1px;
+ box-shadow: 0 2px 0 rgba(0, 0, 150, 0.025), 2px 4px 0 rgba(0, 0, 150, 0.015);
+ margin: 0 auto;
+ padding: 0;
+ width: 570px;
+}
+
+/* Subcopy */
+
+.subcopy {
+ border-top: 1px solid #e8e5ef;
+ margin-top: 25px;
+ padding-top: 25px;
+}
+
+.subcopy p {
+ font-size: 14px;
+}
+
+/* Footer */
+
+.footer {
+ -premailer-cellpadding: 0;
+ -premailer-cellspacing: 0;
+ -premailer-width: 570px;
+ margin: 0 auto;
+ padding: 0;
+ text-align: center;
+ width: 570px;
+}
+
+.footer p {
+ color: #b0adc5;
+ font-size: 12px;
+ text-align: center;
+}
+
+.footer a {
+ color: #b0adc5;
+ text-decoration: underline;
+}
+
+/* Tables */
+
+.table table {
+ -premailer-cellpadding: 0;
+ -premailer-cellspacing: 0;
+ -premailer-width: 100%;
+ margin: 30px auto;
+ width: 100%;
+}
+
+.table th {
+ border-bottom: 1px solid #edeff2;
+ margin: 0;
+ padding-bottom: 8px;
+}
+
+.table td {
+ color: #74787e;
+ font-size: 15px;
+ line-height: 18px;
+ margin: 0;
+ padding: 10px 0;
+}
+
+.content-cell {
+ max-width: 100vw;
+ padding: 32px;
+}
+
+
+/* Utilities */
+
+.break-all {
+ word-break: break-all;
+}
diff --git a/static/css/emails/button.css b/static/css/emails/button.css
new file mode 100644
index 000000000..70321bed5
--- /dev/null
+++ b/static/css/emails/button.css
@@ -0,0 +1,47 @@
+/* Buttons */
+
+.action {
+ -premailer-cellpadding: 0;
+ -premailer-cellspacing: 0;
+ -premailer-width: 100%;
+ margin: 30px auto;
+ padding: 0;
+ text-align: center;
+ width: 100%;
+}
+
+.button {
+ -webkit-text-size-adjust: none;
+ border-radius: 4px;
+ color: #fff;
+ display: inline-block;
+ overflow: hidden;
+ text-decoration: none;
+}
+
+.button-blue,
+.button-primary {
+ background-color: #2d3748;
+ border-bottom: 8px solid #2d3748;
+ border-left: 18px solid #2d3748;
+ border-right: 18px solid #2d3748;
+ border-top: 8px solid #2d3748;
+}
+
+.button-green,
+.button-success {
+ background-color: #48bb78;
+ border-bottom: 8px solid #48bb78;
+ border-left: 18px solid #48bb78;
+ border-right: 18px solid #48bb78;
+ border-top: 8px solid #48bb78;
+}
+
+.button-red,
+.button-error {
+ background-color: #e53e3e;
+ border-bottom: 8px solid #e53e3e;
+ border-left: 18px solid #e53e3e;
+ border-right: 18px solid #e53e3e;
+ border-top: 8px solid #e53e3e;
+}
diff --git a/static/css/emails/panel.css b/static/css/emails/panel.css
new file mode 100644
index 000000000..6e431c98d
--- /dev/null
+++ b/static/css/emails/panel.css
@@ -0,0 +1,25 @@
+/* Panels */
+
+.panel {
+ border-left: #2d3748 solid 4px;
+ margin: 21px 0;
+}
+
+.panel-content {
+ background-color: #2B303A;
+ color: #718096;
+ padding: 16px;
+}
+
+.panel-content p {
+ color: #718096;
+}
+
+.panel-item {
+ padding: 0;
+}
+
+.panel-item p:last-of-type {
+ margin-bottom: 0;
+ padding-bottom: 0;
+}
diff --git a/uobtheatre/bookings/models.py b/uobtheatre/bookings/models.py
index e00c68e77..af11a31d2 100644
--- a/uobtheatre/bookings/models.py
+++ b/uobtheatre/bookings/models.py
@@ -3,17 +3,15 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.aggregates import BoolAnd
-from django.contrib.sites.models import Site
-from django.core.mail import EmailMultiAlternatives
from django.db import models
from django.db.models import Case, F, FloatField, Q, Value, When
from django.db.models.functions import Cast
from django.db.models.query import QuerySet
-from django.template.loader import get_template
from django.utils import timezone
from django.utils.functional import cached_property
from uobtheatre.discounts.models import ConcessionType, DiscountCombination
+from uobtheatre.mail.composer import MailComposer
from uobtheatre.payments.models import Payment
from uobtheatre.payments.payables import Payable
from uobtheatre.productions.models import Performance
@@ -526,30 +524,50 @@ def send_confirmation_email(self):
"""
Send email confirmation which includes a link to the booking.
"""
- plaintext_template = get_template("emails/booking_confirmation_email.txt")
- html_template = get_template("emails/booking_confirmation_email.html")
- site = Site.objects.get_current()
-
- context = {
- "booking": self,
- "user_name": self.user.first_name.capitalize(),
- "production_name": self.performance.production.name,
- "start": self.performance.start,
- "protocol": "https",
- "domain": site.domain,
- }
-
- subject, from_email, to_email = (
- "Your booking is confirmed!",
- '"UOB Theatre" ',
- self.user.email,
+ composer = MailComposer()
+
+ # Add greating
+ composer.heading(
+ "Hi %s" % self.user.first_name.capitalize()
+ if self.user.status.verified
+ else "Hello"
+ )
+
+ composer.line(
+ "Your booking to %s has been confirmed!" % self.performance.production.name
+ ).image(self.performance.production.featured_image.file.url)
+
+ composer.line(
+ (
+ "This event opens at %s for a %s start. Please bring your tickets (printed or on your phone) or your booking reference (%s)."
+ if self.user.status.verified
+ else "This event opens at %s for a %s start. Please bring your booking reference (%s)."
+ )
+ % (
+ self.performance.doors_open.strftime("%d %B %Y %H:%M %Z"),
+ self.performance.start.strftime("%H:%M %Z"),
+ self.reference,
+ )
+ )
+
+ if self.user.status.verified:
+ composer.action(
+ "/user/booking/%s" % self.reference, "View Tickets & Booking"
+ )
+
+ payment = self.payments.first()
+ # If this booking includes a payment, we will include details of this payment as a reciept
+ if payment:
+ composer.heading("Payment Information").line(
+ "{:.2f}".format(payment.value / 100)
+ + f" {payment.currency} paid ({payment.provider}{' - ID' + payment.provider_payment_id if payment.provider_payment_id else '' })"
+ )
+
+ composer.line(
+ "If you have any accessability concerns, or otherwise need help, please contact support@uobtheatre.com."
)
- text_content = plaintext_template.render(context)
- html_content = html_template.render(context)
- msg = EmailMultiAlternatives(subject, text_content, from_email, [to_email])
- msg.attach_alternative(html_content, "text/html")
- msg.send()
+ composer.send("Your booking is confirmed!", self.user.email)
class Ticket(models.Model):
diff --git a/uobtheatre/bookings/schema.py b/uobtheatre/bookings/schema.py
index c8f796693..a6072a788 100644
--- a/uobtheatre/bookings/schema.py
+++ b/uobtheatre/bookings/schema.py
@@ -379,7 +379,10 @@ def parse_target_user_email(
# If a target user is provided get that user, if not then this booking is intended for the user that is logged in
if target_user_email:
- target_user, _ = User.objects.get_or_create(email=target_user_email)
+ target_user, _ = User.objects.get_or_create(
+ email=target_user_email,
+ defaults={"first_name": "Anonymous", "last_name": "User"},
+ )
return target_user
return creator_user
@@ -665,7 +668,6 @@ def resolve_mutation( # pylint: disable=too-many-arguments
code="missing_required",
)
payment_method = SquareOnline(nonce, idempotency_key)
-
elif payment_provider == SquarePOS.name:
if not device_id:
raise GQLException(
diff --git a/uobtheatre/bookings/templates/emails/booking_confirmation_email.html b/uobtheatre/bookings/templates/emails/booking_confirmation_email.html
deleted file mode 100644
index 05380e559..000000000
--- a/uobtheatre/bookings/templates/emails/booking_confirmation_email.html
+++ /dev/null
@@ -1,6 +0,0 @@
-Hello {{ user_name }}!
-
- Your booking for {{ production_name }} on {{ start|date:"l, d F Y" }} at {{ start|time:"H:i" }} is confirmed!
-
-Check your booking here:
-{{ protocol }}://{{ domain }}/user/booking/{{ booking.reference }}
diff --git a/uobtheatre/bookings/templates/emails/booking_confirmation_email.txt b/uobtheatre/bookings/templates/emails/booking_confirmation_email.txt
deleted file mode 100644
index 0098c1e6c..000000000
--- a/uobtheatre/bookings/templates/emails/booking_confirmation_email.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-Hello {{ user_name }}!
-
-Your booking for {{ production_name }} on {{ start|date:"l, d F Y" }} at {{ start|time:"H:i" }} is confirmed!
-
-Check your booking here:
-{{ protocol }}://{{ domain }}/user/booking/{{ booking.reference }}
diff --git a/uobtheatre/bookings/test/test_models.py b/uobtheatre/bookings/test/test_models.py
index 17a778ac0..80b00f1fa 100644
--- a/uobtheatre/bookings/test/test_models.py
+++ b/uobtheatre/bookings/test/test_models.py
@@ -22,7 +22,9 @@
DiscountFactory,
DiscountRequirementFactory,
)
+from uobtheatre.payments import payment_methods
from uobtheatre.payments.models import Payment
+from uobtheatre.payments.test.factories import PaymentFactory
from uobtheatre.productions.test.factories import PerformanceFactory, ProductionFactory
from uobtheatre.users.test.factories import UserFactory
from uobtheatre.utils.test_utils import ticket_dict_list_dict_gen, ticket_list_dict_gen
@@ -982,10 +984,29 @@ def test_complete():
@pytest.mark.django_db
-def test_send_confirmation_email(mailoutbox):
+@pytest.mark.parametrize(
+ "with_payment, provider_payment_id",
+ [(True, "SQUARE_PAYMENT_ID"), (True, None), (False, None)],
+)
+def test_send_confirmation_email(mailoutbox, with_payment, provider_payment_id):
production = ProductionFactory(name="Legally Ginger")
performance = PerformanceFactory(
- start=datetime.datetime(day=20, month=10, year=2021, hour=19, minute=15),
+ doors_open=datetime.datetime(
+ day=20,
+ month=10,
+ year=2021,
+ hour=18,
+ minute=15,
+ tzinfo=timezone.get_current_timezone(),
+ ),
+ start=datetime.datetime(
+ day=20,
+ month=10,
+ year=2021,
+ hour=19,
+ minute=15,
+ tzinfo=timezone.get_current_timezone(),
+ ),
production=production,
)
booking = BookingFactory(
@@ -993,6 +1014,16 @@ def test_send_confirmation_email(mailoutbox):
reference="abc",
performance=performance,
)
+ booking.user.status.verified = True
+
+ if with_payment:
+ PaymentFactory(
+ pay_object=booking,
+ value=1000,
+ provider=payment_methods.SquareOnline.__name__,
+ provider_payment_id=provider_payment_id,
+ )
+
booking.send_confirmation_email()
assert len(mailoutbox) == 1
@@ -1000,4 +1031,53 @@ def test_send_confirmation_email(mailoutbox):
assert email.subject == "Your booking is confirmed!"
assert "https://example.com/user/booking/abc" in email.body
assert "Legally Ginger" in email.body
- assert "on Wednesday, 20 October 2021" in email.body
+ assert "opens at 20 October 2021 18:15 UTC for a 19:15 UTC start" in email.body
+ if with_payment:
+ assert "Payment Information" in email.body
+ assert "10.00 GBP" in email.body
+ assert (
+ "(SquareOnline - ID SQUARE_PAYMENT_ID)"
+ if provider_payment_id
+ else "(SquareOnline)" in email.body
+ )
+ else:
+ assert "Payment Information" not in email.body
+
+
+@pytest.mark.django_db
+def test_send_confirmation_email_for_anonymous(mailoutbox):
+ production = ProductionFactory(name="Legally Ginger")
+ performance = PerformanceFactory(
+ doors_open=datetime.datetime(
+ day=20,
+ month=10,
+ year=2021,
+ hour=18,
+ minute=15,
+ tzinfo=timezone.get_current_timezone(),
+ ),
+ start=datetime.datetime(
+ day=20,
+ month=10,
+ year=2021,
+ hour=19,
+ minute=15,
+ tzinfo=timezone.get_current_timezone(),
+ ),
+ production=production,
+ )
+ booking = BookingFactory(
+ status=Booking.BookingStatus.IN_PROGRESS,
+ reference="abc",
+ performance=performance,
+ )
+
+ booking.send_confirmation_email()
+
+ assert len(mailoutbox) == 1
+ email = mailoutbox[0]
+ assert email.subject == "Your booking is confirmed!"
+ assert "https://example.com/user/booking/abc" not in email.body
+ assert "Legally Ginger" in email.body
+ assert "opens at 20 October 2021 18:15 UTC for a 19:15 UTC start" in email.body
+ assert "reference (abc)" in email.body
diff --git a/uobtheatre/bookings/test/test_mutations.py b/uobtheatre/bookings/test/test_mutations.py
index ef21aa479..abc6cafae 100644
--- a/uobtheatre/bookings/test/test_mutations.py
+++ b/uobtheatre/bookings/test/test_mutations.py
@@ -1237,13 +1237,14 @@ def test_pay_booking_mutation_unauthorized_provider(gql_client):
@pytest.mark.django_db
def test_pay_booking_mutation_online_without_idempotency_key(gql_client):
booking = BookingFactory(status=Booking.BookingStatus.IN_PROGRESS)
+ add_ticket_to_booking(booking)
gql_client.login()
request_query = """
mutation {
payBooking(
bookingId: "%s"
- price: 0
+ price: 100
nonce: "cnon:card-nonce-ok"
) {
success
diff --git a/uobtheatre/mail/__init__.py b/uobtheatre/mail/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/uobtheatre/mail/composer.py b/uobtheatre/mail/composer.py
new file mode 100644
index 000000000..05cc45269
--- /dev/null
+++ b/uobtheatre/mail/composer.py
@@ -0,0 +1,186 @@
+import abc
+from datetime import datetime
+from typing import List, Union
+
+from django.conf import settings
+from django.contrib.sites.models import Site
+from django.core.mail import EmailMultiAlternatives
+from django.template.loader import get_template
+from django.utils.html import strip_tags
+
+
+def get_site_base():
+ return "https://%s" % Site.objects.get_current().domain
+
+
+class ComposerItemInterface(abc.ABC):
+ """Abstract interface for a mail composer item"""
+
+ def to_text(self) -> Union[str, None]:
+ """Generate the plain text version of this item"""
+ raise NotImplementedError()
+
+ def to_html(self) -> str:
+ """Generate the HTML version of this item"""
+ raise NotImplementedError()
+
+
+class ComposerItemsContainer(ComposerItemInterface, abc.ABC):
+ """Abstract container of composer items"""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.items: List[ComposerItemInterface] = []
+ self.subcopy: List[Line] = []
+
+ def line(self, text: str):
+ """A line (paragraph) of text. May contain simple HTML, which will be stripped for plain text version"""
+ self.items.append(Line(text))
+ return self
+
+ def action(self, url, text):
+ """Create an action button"""
+ action = Action(url, text)
+ self.items.append(action)
+ self.subcopy.append(
+ Line(
+ "Can't click the '%s' button? Copy the following into your browser: '%s'"
+ % (text, action.url, action.url)
+ )
+ )
+ return self
+
+ def heading(self, text):
+ """Create a heading (title)"""
+ self.items.append(Heading(text))
+ return self
+
+ def image(self, url):
+ """Create a full-width image"""
+ self.items.append(Image(url))
+ return self
+
+ def append(self, item):
+ self.items.append(item)
+ return self
+
+ def to_text(self) -> Union[str, None]:
+ """Generate the plain text version of this item"""
+ return """{}""".format(
+ "\n\n".join(
+ [item.to_text() for item in self.items if item.to_text()] # type: ignore
+ )
+ )
+
+ def to_html(self) -> str:
+ """Generate the HTML version of this item"""
+ return """{}""".format("\n".join([item.to_html() or "" for item in self.items]))
+
+
+class Heading(ComposerItemInterface):
+ """A heading (i.e. ) composer item"""
+
+ def __init__(self, text, size=1) -> None:
+ super().__init__()
+ self.text = text
+ self.size = size
+
+ def to_text(self):
+ return self.text
+
+ def to_html(self):
+ return "%s" % (self.size, self.text, self.size)
+
+
+class Line(ComposerItemInterface):
+ """A line/paragraph composer item"""
+
+ def __init__(self, text) -> None:
+ super().__init__()
+ self.text = text
+
+ def to_text(self):
+ return strip_tags(self.text)
+
+ def to_html(self):
+ return "%s
" % self.text
+
+
+class Image(ComposerItemInterface):
+ """A full-width image composer item"""
+
+ def __init__(self, url) -> None:
+ super().__init__()
+ self.url = url
+
+ def to_text(self):
+ return None
+
+ def to_html(self):
+ template = get_template("components/image.html")
+ return template.render({"url": self.url})
+
+
+class Action(ComposerItemInterface):
+ """An action button composer item"""
+
+ def __init__(self, url, text) -> None:
+ super().__init__()
+ self.url = url if not url[0] == "/" else get_site_base() + url
+ self.text = text
+
+ def to_text(self):
+ return "%s (%s)" % (self.text, self.url)
+
+ def to_html(self):
+ template = get_template("components/button.html")
+
+ return template.render({"url": self.url, "text": self.text})
+
+
+class Panel(ComposerItemsContainer):
+ def to_html(self) -> str:
+ content = super().to_html()
+
+ return get_template("components/panel.html").render({"content": content})
+
+
+class MailComposer(ComposerItemsContainer):
+ """Compose a mail notificaiton"""
+
+ def get_complete_items(self):
+ """Get the email body items (including any signature/signoff)"""
+ return self.items + [Line("Thanks,"), Line("The UOBTheatre Team")]
+
+ def to_plain_text(self):
+ """Generate the plain text version of the email"""
+ return """{}""".format(
+ "\n\n".join(
+ [item.to_text() for item in self.get_complete_items() if item.to_text()]
+ )
+ )
+
+ def to_html(self):
+ """Generate the HTML version of the email"""
+ content = """{}""".format(
+ "\n".join([item.to_html() or "" for item in self.get_complete_items()])
+ )
+ subcopy = "\n".join((item.to_html() for item in self.subcopy))
+
+ email = get_template("base_email.html").render(
+ {
+ "site_url": get_site_base(),
+ "content": content,
+ "subcopy": subcopy,
+ "footer": "© UOB Theatre %s" % datetime.now().year,
+ }
+ )
+ return email
+
+ def send(self, subject, to_email):
+ """Send the email to the given email with the given subject"""
+ msg = EmailMultiAlternatives(
+ subject, self.to_plain_text(), settings.DEFAULT_FROM_EMAIL, [to_email]
+ )
+ msg.attach_alternative(self.to_html(), "text/html")
+ msg.send()
diff --git a/uobtheatre/mail/templates/base_email.html b/uobtheatre/mail/templates/base_email.html
new file mode 100644
index 000000000..2dc11a965
--- /dev/null
+++ b/uobtheatre/mail/templates/base_email.html
@@ -0,0 +1,72 @@
+{% load inlinecss %}
+{% inlinecss "css/emails/base.css" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{content|safe}}
+ {% block content %}
+ {% endblock %}
+ {% if subcopy %}
+
+
+
+ {{subcopy|safe}}
+ |
+
+
+ {% endif %}
+ |
+
+
+ |
+
+
+
+
+ |
+
+
+ |
+
+
+
+
+
+{% endinlinecss %}
diff --git a/uobtheatre/mail/templates/components/button.html b/uobtheatre/mail/templates/components/button.html
new file mode 100644
index 000000000..46a8c46d4
--- /dev/null
+++ b/uobtheatre/mail/templates/components/button.html
@@ -0,0 +1,23 @@
+{% load inlinecss %}
+{% inlinecss "css/emails/button.css" %}
+
+{% endinlinecss %}
diff --git a/uobtheatre/mail/templates/components/image.html b/uobtheatre/mail/templates/components/image.html
new file mode 100644
index 000000000..5608be86d
--- /dev/null
+++ b/uobtheatre/mail/templates/components/image.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ |
+
+
+
diff --git a/uobtheatre/mail/templates/components/panel.html b/uobtheatre/mail/templates/components/panel.html
new file mode 100644
index 000000000..7a3afdbc5
--- /dev/null
+++ b/uobtheatre/mail/templates/components/panel.html
@@ -0,0 +1,16 @@
+{% load inlinecss %}
+{% inlinecss "css/emails/panel.css" %}
+
+
+
+
+
+
+ {{ content|safe }}
+ |
+
+
+ |
+
+
+{% endinlinecss %}
diff --git a/uobtheatre/mail/test/fixtures/__init__.py b/uobtheatre/mail/test/fixtures/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/uobtheatre/mail/test/fixtures/plain_text.txt b/uobtheatre/mail/test/fixtures/plain_text.txt
new file mode 100644
index 000000000..add1a8e8d
--- /dev/null
+++ b/uobtheatre/mail/test/fixtures/plain_text.txt
@@ -0,0 +1,13 @@
+This is a heading
+
+This is a paragraph
+
+This is another paragraph
+
+Call to Action (https://example.org/call/to/action)
+
+This is the final paragraph
+
+Thanks,
+
+The UOBTheatre Team
\ No newline at end of file
diff --git a/uobtheatre/mail/test/test_composer.py b/uobtheatre/mail/test/test_composer.py
new file mode 100644
index 000000000..449e5899c
--- /dev/null
+++ b/uobtheatre/mail/test/test_composer.py
@@ -0,0 +1,120 @@
+import importlib.resources as pkg_resources
+
+import pytest
+from django.template.loader import get_template
+
+import uobtheatre.mail.test.fixtures as fixtures
+from uobtheatre.mail.composer import Action, Heading, Image, Line, MailComposer, Panel
+
+
+def test_it_generates_correct_plain_text():
+ composer = (
+ MailComposer()
+ .heading("This is a heading")
+ .line("This is a paragraph")
+ .image("http://example.org/my/image")
+ .line("This is another paragraph")
+ .action("https://example.org/call/to/action", "Call to Action")
+ .line("This is the final paragraph")
+ )
+
+ assert composer.to_plain_text() == pkg_resources.read_text(
+ fixtures, "plain_text.txt"
+ )
+
+
+@pytest.mark.django_db
+def test_it_generates_correct_html():
+ composer = (
+ MailComposer()
+ .heading("This is a heading")
+ .line("This is a paragraph")
+ .image("http://example.org/my/image")
+ .line("This is another paragraph")
+ .action("https://example.org/call/to/action", "Call to Action")
+ .line("This is the final paragraph")
+ )
+
+ assert ">This is a heading" in composer.to_html()
+ assert ">This is a paragraph
" in composer.to_html()
+ assert Image("http://example.org/my/image").to_html() in composer.to_html()
+ assert 'href="https://example.org/call/to/action"' in composer.to_html()
+ assert ">Call to Action" in composer.to_html()
+
+
+@pytest.mark.django_db
+def test_it_can_send(mailoutbox):
+ composer = (
+ MailComposer()
+ .heading("This is a heading")
+ .line("This is a paragraph")
+ .image("http://example.org/my/image")
+ .line("This is another paragraph")
+ .action("https://example.org/call/to/action", "Call to Action")
+ .line("This is the final paragraph")
+ )
+
+ composer.send("Email Subject", "joe@example.org")
+
+ assert len(mailoutbox) == 1
+ email = mailoutbox[0]
+ assert email.subject == "Email Subject"
+ assert email.to == ["joe@example.org"]
+ assert email.body == composer.to_plain_text()
+ assert email.alternatives[0][0] == composer.to_html()
+ assert email.alternatives[0][1] == "text/html"
+
+
+def test_line_item():
+ line = Line("My paragraph text")
+ assert line.to_html() == "My paragraph text
"
+ assert line.to_text() == "My paragraph text"
+
+
+def test_line_item_with_html():
+ line = Line("My strong paragraph text")
+ assert line.to_html() == "My strong paragraph text
"
+ assert line.to_text() == "My strong paragraph text"
+
+
+def test_image_item():
+ image = Image("http://example.org/my/image")
+
+ assert image.to_html() == get_template("components/image.html").render(
+ {"url": "http://example.org/my/image"}
+ )
+ assert image.to_text() is None
+
+
+def test_action_item():
+ action = Action("http://example.org/call/to/action", "Call to Action")
+
+ assert action.to_html() == get_template("components/button.html").render(
+ {"url": "http://example.org/call/to/action", "text": "Call to Action"}
+ )
+ assert action.to_text() == "Call to Action (http://example.org/call/to/action)"
+
+
+def test_panel_item():
+ panel = Panel()
+ panel.line("Test text")
+
+ assert panel.to_text() == "Test text"
+ assert panel.to_html() == get_template("components/panel.html").render(
+ {"content": Line("Test text").to_html()}
+ )
+
+
+def test_append():
+ line = Line("Test")
+ composer = MailComposer()
+ assert composer.items == []
+ composer.append(line)
+ assert composer.items == [line]
+
+
+@pytest.mark.parametrize("size", [1, 2, 3])
+def test_heading_item(size):
+ heading = Heading("My Heading %s" % size, size)
+ assert heading.to_text() == "My Heading %s" % size
+ assert heading.to_html() == "My Heading %s" % (size, size, size)
diff --git a/uobtheatre/productions/test/test_schema.py b/uobtheatre/productions/test/test_schema.py
index 98027cb4d..885253581 100644
--- a/uobtheatre/productions/test/test_schema.py
+++ b/uobtheatre/productions/test/test_schema.py
@@ -121,7 +121,6 @@ def test_productions_schema(gql_client):
}
"""
)
- print(f"{response=}")
assert response == {
"data": {
diff --git a/uobtheatre/users/templates/emails/activation_email.html b/uobtheatre/users/templates/emails/activation_email.html
index 74c3579cf..2efb24079 100644
--- a/uobtheatre/users/templates/emails/activation_email.html
+++ b/uobtheatre/users/templates/emails/activation_email.html
@@ -1,7 +1,17 @@
-{{ site_name }}
-
-Hello {{ user.first_name }}!
+{% extends "base_email.html" %}
+{% block content %}
+{% with protocol|add:"://"|add:domain|add:"/"|add:path|add:"/"|add:token as url %}
+Hello {{ user.first_name }}!
Please activate your account with the following link:
+{% with text="Activate Account" %}
+{% include "components/button.html" %}
+{% endwith %}
+
+or goto
+{{url}}
+in your browser.
-{{ protocol }}://{{ domain }}/{{ path }}/{{ token }}
+Thanks,
+The UOBTheatre Team
{% endwith %}
+{% endblock %}
diff --git a/uobtheatre/users/templates/emails/password_reset_email.html b/uobtheatre/users/templates/emails/password_reset_email.html
new file mode 100644
index 000000000..5a71a5933
--- /dev/null
+++ b/uobtheatre/users/templates/emails/password_reset_email.html
@@ -0,0 +1,19 @@
+{% extends "base_email.html" %}
+{% block content %}
+{% with protocol|add:"://"|add:domain|add:"/"|add:path|add:"/"|add:token as url %}
+Hello {{ user.first_name }}!
+
+You recently requested a password reset.
+To reset your password, please click below:
+{% with text="Reset Password" %}
+{% include "components/button.html" %}
+{% endwith %}
+
+or goto
+{{url}}
+in your browser.
+
+Thanks,
+The UOBTheatre Team
+{% endwith %}
+{% endblock %}