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" %} + + + + + + + + + + + + + + + + + + + + + +{% 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" %} + + + + + +{% 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 %}