diff --git a/.env.example b/.env.example index 41d0de0a8..b7d7cb00a 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,6 @@ HAWK_AUTH_KEY="LITE_API_HAWK_KEY" SEND_LICENCE_TO_CHIEF=False SET_INACTIVE_APP_TYPES_ACTIVE=False + +# To send emails set GOV_NOTIFY_API_KEY to the development API key in the dev vault +GOV_NOTIFY_API_KEY= \ No newline at end of file diff --git a/config/settings/base.py b/config/settings/base.py index f67078928..f910758c5 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -129,8 +129,9 @@ ] # Email -# EMAIL_BACKEND = "django_ses.SESBackend" TODO ICMLST-1994 +GOV_NOTIFY_API_KEY = env.str("GOV_NOTIFY_API_KEY", default="") EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + AWS_SES_ACCESS_KEY_ID = env.str("AWS_SES_ACCESS_KEY_ID", default="") AWS_SES_SECRET_ACCESS_KEY = env.str("AWS_SES_SECRET_ACCESS_KEY", default="") AWS_SES_REGION_NAME = "eu-west-1" diff --git a/config/settings/test.py b/config/settings/test.py index 81b18e65b..f12b86cf1 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -55,3 +55,5 @@ ICMS_V1_REPLICA_USER = "" ICMS_V1_REPLICA_PASSWORD = "" ICMS_V1_REPLICA_DSN = "" + +GOV_NOTIFY_API_KEY = "fakekey-11111111-1111-1111-1111-111111111111-22222222-2222-2222-222222222222" diff --git a/docker-compose.yml b/docker-compose.yml index eca85d1bc..c7cb74f4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,7 @@ services: - HAWK_AUTH_KEY - SEND_LICENCE_TO_CHIEF - SET_INACTIVE_APP_TYPES_ACTIVE + - GOV_NOTIFY_API_KEY # stdin_open: true # tty: true ports: @@ -102,6 +103,7 @@ services: - HAWK_AUTH_ID - HAWK_AUTH_KEY - SEND_LICENCE_TO_CHIEF + - GOV_NOTIFY_API_KEY depends_on: - redis volumes: diff --git a/pii-ner-exclude.txt b/pii-ner-exclude.txt index 04b3e3291..9e10eafb9 100644 --- a/pii-ner-exclude.txt +++ b/pii-ner-exclude.txt @@ -3164,3 +3164,5 @@ Access Request Approval Response An Access Request Approval request\nis Access Request Approval +GOV_NOTIFY_API_KEY +GOV Notify diff --git a/requirements-base.txt b/requirements-base.txt index cab112b65..699385ac8 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -22,6 +22,7 @@ humanize==3.1.0 Jinja2==3.0.3 lxml==4.9.1 mohawk==1.1.0 +notifications-python-client==8.0.1 openpyxl==3.0.7 oracledb==1.2.0 phonenumbers==8.12.12 diff --git a/web/admin.py b/web/admin.py index 3311d1fb8..3b1d4bd11 100644 --- a/web/admin.py +++ b/web/admin.py @@ -3,9 +3,11 @@ from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db.models import QuerySet from guardian.admin import GuardedModelAdmin +from web.mail.api import is_valid_template_id from web.models import ( Commodity, CommodityGroup, @@ -14,6 +16,7 @@ CountryGroup, DerogationsApplication, Email, + EmailTemplate, ExportApplication, ExportApplicationType, Exporter, @@ -50,6 +53,27 @@ class ExporterAdmin(GuardedModelAdmin): ... +class EmailTemplateForm(forms.ModelForm): + class Meta: + model = EmailTemplate + fields = ["name", "gov_notify_template_id"] + + def clean_gov_notify_template_id(self) -> str: + template_id = self.cleaned_data["gov_notify_template_id"] + if not is_valid_template_id(template_id): + raise ValidationError("GOV Notify template not found") + return template_id + + +class EmailTemplateAdmin(admin.ModelAdmin): + form = EmailTemplateForm + fields = ("name", "gov_notify_template_id") + readonly_fields = ("name",) + + def has_delete_permission(self, request, obj=None) -> bool: + return False + + admin.site.register(User, UserAdmin) admin.site.register(CommodityType) admin.site.register(Commodity) @@ -71,6 +95,7 @@ class ExporterAdmin(GuardedModelAdmin): admin.site.register(SanctionsAndAdhocApplication) admin.site.register(SanctionsAndAdhocApplicationGoods) admin.site.register(DerogationsApplication) +admin.site.register(EmailTemplate, EmailTemplateAdmin) @admin.register(Permission) diff --git a/web/mail/__init__.py b/web/mail/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/web/mail/api.py b/web/mail/api.py new file mode 100644 index 000000000..a4669ee51 --- /dev/null +++ b/web/mail/api.py @@ -0,0 +1,27 @@ +from uuid import UUID + +from django.conf import settings +from notifications_python_client import NotificationsAPIClient +from notifications_python_client.errors import HTTPError + + +def get_gov_notify_client() -> NotificationsAPIClient: + return NotificationsAPIClient(settings.GOV_NOTIFY_API_KEY) + + +def get_template_by_id(template_id: UUID) -> dict: + client = get_gov_notify_client() + try: + return client.get_template(template_id) + except HTTPError as e: + return e.response.json() + + +def is_valid_template_id(template_id: UUID) -> bool: + response = get_template_by_id(template_id) + gov_notify_template_id = response.get("id", "") + try: + gov_notify_template_id = UUID(gov_notify_template_id) + except ValueError: + return False + return gov_notify_template_id == template_id diff --git a/web/mail/constants.py b/web/mail/constants.py new file mode 100644 index 000000000..dfc16895c --- /dev/null +++ b/web/mail/constants.py @@ -0,0 +1,6 @@ +from web.types import TypedTextChoices + + +class EmailTypes(TypedTextChoices): + ACCESS_REQUEST = ("ACCESS_REQUEST", "Access Request") + CASE_COMPLETE = ("CASE_COMPLETE", "Case Complete") diff --git a/web/mail/models.py b/web/mail/models.py new file mode 100644 index 000000000..aef562ffd --- /dev/null +++ b/web/mail/models.py @@ -0,0 +1,11 @@ +from django.db import models + +from .constants import EmailTypes + + +class EmailTemplate(models.Model): + name = models.CharField(max_length=255, unique=True, choices=EmailTypes.choices) + gov_notify_template_id = models.UUIDField() + + def __str__(self) -> str: + return self.get_name_display() diff --git a/web/management/commands/utils/add_email_template_data.py b/web/management/commands/utils/add_email_template_data.py new file mode 100644 index 000000000..e831d80d3 --- /dev/null +++ b/web/management/commands/utils/add_email_template_data.py @@ -0,0 +1,16 @@ +from web.mail.constants import EmailTypes +from web.models import EmailTemplate + +templates = [ + (EmailTypes.ACCESS_REQUEST, "d8905fee-1f7d-48dc-bc11-aee71c130b3e"), + (EmailTypes.CASE_COMPLETE, "2e03bc8e-1d57-404d-ba53-0fbf00316a4d"), # /PS-IGNORE +] + + +def add_email_gov_notify_templates(): + EmailTemplate.objects.bulk_create( + [ + EmailTemplate(name=name, gov_notify_template_id=gov_notify_template_id) + for name, gov_notify_template_id in templates + ] + ) diff --git a/web/management/commands/utils/load_data.py b/web/management/commands/utils/load_data.py index 8b94d1972..e4eb20f72 100644 --- a/web/management/commands/utils/load_data.py +++ b/web/management/commands/utils/load_data.py @@ -18,6 +18,7 @@ load_country_data, load_country_group_data, ) +from .add_email_template_data import add_email_gov_notify_templates from .add_product_legislation_data import add_product_legislation_data from .add_sanction_data import add_sanction_data from .add_template_data import ( @@ -57,3 +58,4 @@ def load_app_test_data(): add_constabulary_data() add_product_legislation_data() add_sanction_data() + add_email_gov_notify_templates() diff --git a/web/migrations/0001_initial.py b/web/migrations/0001_initial.py index 3be92af27..e8349c1d4 100644 --- a/web/migrations/0001_initial.py +++ b/web/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.10 on 2023-07-25 15:10 +# Generated by Django 4.1.10 on 2023-07-31 08:52 import uuid @@ -22,8 +22,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("contenttypes", "0002_remove_content_type_name"), ("auth", "0012_alter_user_first_name_max_length"), + ("contenttypes", "0002_remove_content_type_name"), ] operations = [ @@ -1099,6 +1099,29 @@ class Migration(migrations.Migration): "abstract": False, }, ), + migrations.CreateModel( + name="EmailTemplate", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "name", + models.CharField( + choices=[ + ("ACCESS_REQUEST", "Access Request"), + ("CASE_COMPLETE", "Case Complete"), + ], + max_length=255, + unique=True, + ), + ), + ("gov_notify_template_id", models.UUIDField()), + ], + ), migrations.CreateModel( name="EndorsementImportApplication", fields=[ diff --git a/web/models/__init__.py b/web/models/__init__.py index 369da1479..1bb0f2d6e 100644 --- a/web/models/__init__.py +++ b/web/models/__init__.py @@ -152,6 +152,7 @@ from web.domains.template.models import CFSScheduleParagraph, Template from web.domains.user.models import Email, PhoneNumber, User from web.flow.models import Process, Task +from web.mail.models import EmailTemplate from web.models.models import CaseReference, GlobalPermission __all__ = [ @@ -267,13 +268,12 @@ "SIGLTransmission", "CFSScheduleParagraph", "Template", - "AlternativeEmail", "Email", - "PersonalEmail", "PhoneNumber", "User", "Process", "Task", "CaseReference", "GlobalPermission", + "EmailTemplate", ] diff --git a/web/tests/mail/test_admin_forms.py b/web/tests/mail/test_admin_forms.py new file mode 100644 index 000000000..28f247d83 --- /dev/null +++ b/web/tests/mail/test_admin_forms.py @@ -0,0 +1,57 @@ +from unittest import mock +from uuid import UUID + +import pytest + +from web.admin import EmailTemplateForm +from web.mail.constants import EmailTypes +from web.models import EmailTemplate + +FAKE_TEMPLATE_UUID = UUID("646bea34-20ef-437c-b001-ea557f3ba1e6") + + +@pytest.mark.django_db +def test_email_template_form_uuid_error(): + case_complete = EmailTemplate.objects.get(name=EmailTypes.CASE_COMPLETE) + form = EmailTemplateForm( + instance=case_complete, + data={"gov_notify_template_id": "hello", "name": EmailTypes.CASE_COMPLETE}, + ) + assert form.is_valid() is False + assert form.errors == {"gov_notify_template_id": ["Enter a valid UUID."]} + + +@pytest.mark.django_db +@mock.patch("web.admin.is_valid_template_id") +def test_email_template_id_is_invalid(mock_is_valid_template_id): + mock_is_valid_template_id.return_value = False + case_complete = EmailTemplate.objects.get(name=EmailTypes.CASE_COMPLETE) + form = EmailTemplateForm( + instance=case_complete, + data={ + "gov_notify_template_id": FAKE_TEMPLATE_UUID, + "name": EmailTypes.CASE_COMPLETE, + }, + ) + assert form.is_valid() is False + assert form.errors == {"gov_notify_template_id": ["GOV Notify template not found"]} + mock_is_valid_template_id.assert_called_once_with(FAKE_TEMPLATE_UUID) + + +@pytest.mark.django_db +@mock.patch("web.admin.is_valid_template_id") +def test_email_template_id_is_valid(mock_is_valid_template_id): + mock_is_valid_template_id.return_value = True + case_complete = EmailTemplate.objects.get(name=EmailTypes.CASE_COMPLETE) + form = EmailTemplateForm( + instance=case_complete, + data={ + "gov_notify_template_id": FAKE_TEMPLATE_UUID, + "name": EmailTypes.CASE_COMPLETE, + }, + ) + assert form.is_valid() is True, form.errors + instance = form.save() + mock_is_valid_template_id.assert_called_once_with(FAKE_TEMPLATE_UUID) + assert str(instance) == EmailTypes.CASE_COMPLETE.label + assert instance.gov_notify_template_id == FAKE_TEMPLATE_UUID diff --git a/web/tests/mail/test_api.py b/web/tests/mail/test_api.py new file mode 100644 index 000000000..c214d8a64 --- /dev/null +++ b/web/tests/mail/test_api.py @@ -0,0 +1,53 @@ +from unittest import mock +from uuid import UUID + +import pytest +from notifications_python_client.errors import HTTPError + +from web.mail import api + +HTTP_404_NOT_FOUND_ERROR = { + "errors": [{"error": "NoResultFound", "message": "No result found"}], + "status_code": 404, +} + + +@pytest.mark.parametrize( + "template_id,expected_result,mock_get_template_id_response", + [ + (UUID("4adda435-af30-4f24-98a4-1f07e222369e"), False, HTTP_404_NOT_FOUND_ERROR), + ( + UUID("646bea34-20ef-437c-b001-ea557f3ba1e6"), + True, + {"id": "646bea34-20ef-437c-b001-ea557f3ba1e6"}, + ), + (UUID("646bea34-20ef-437c-b001-ea557f3ba1e6"), False, {}), + ( + UUID("646bea34-20ef-437c-b001-ea557f3ba1e6"), + False, + {"id": "fb9a1023-3901-44e8-a7d3-a0e309e93951"}, + ), + ], +) +def test_is_valid_template_by_id(template_id, expected_result, mock_get_template_id_response): + with mock.patch("web.mail.api.get_template_by_id") as mock_get_template_id: + mock_get_template_id.return_value = mock_get_template_id_response + assert api.is_valid_template_id(template_id) == expected_result + + +@mock.patch("notifications_python_client.NotificationsAPIClient.get_template") +def test_get_template_by_id(mock_gov_notify_get_template): + fake_response = {"id": "fb9a1023-3901-44e8-a7d3-a0e309e93951"} + mock_gov_notify_get_template.return_value = fake_response + assert api.get_template_by_id(UUID("fb9a1023-3901-44e8-a7d3-a0e309e93951")) == fake_response + + +@mock.patch("notifications_python_client.NotificationsAPIClient.get_template") +def test_get_template_by_id_error(mock_gov_notify_get_template): + fake_response = mock.Mock(status_code=404, json=lambda: HTTP_404_NOT_FOUND_ERROR) + fake_error = mock.Mock(response=fake_response) + mock_gov_notify_get_template.side_effect = HTTPError.create(fake_error) + assert ( + api.get_template_by_id(UUID("fb9a1023-3901-44e8-a7d3-a0e309e93951")) + == HTTP_404_NOT_FOUND_ERROR + )