From bec000ac54d1278d9694d5778e3df44783a9fcf1 Mon Sep 17 00:00:00 2001
From: A Gleeson
Date: Wed, 20 Sep 2023 15:39:42 +0000
Subject: [PATCH] Notifications model and goods report notification (#1036)
* Introduce Notification base-class and specialising sub-classes.
* Change emphasis of sub-class implementations - get notify template ID and notified users.
* Associate Notifications with their source notified object.
* Use proxy model of inheritance in base classes.
* WIP mostly working fix notification choices
* address type error and serialisation issues
* remove comment add todo
* Refactor notification inits and fixed email list
* tests, mocks broke & goods report not working
* Working tests!
* removing unused fixtures
* test fixes and admin filter
* test fix
* Confirm should be true by default
* PR comments
---------
Co-authored-by: Paul Pepper
---
.gitignore | 2 +
common/tests/factories.py | 31 ++
conftest.py | 139 ++++++
.../jinja2/eu-importer/notify-success.jinja | 40 ++
.../send_goods_report_notification.py | 43 ++
.../test_send_goods_report_notification.py | 52 +++
importer/tests/test_views.py | 27 ++
importer/urls.py | 10 +
importer/views.py | 32 ++
notifications/admin.py | 54 +++
notifications/forms.py | 1 +
.../migrations/0003_auto_20230911_0924.py | 132 ++++++
notifications/models.py | 438 +++++++++++++++++-
notifications/notify.py | 86 ++++
notifications/tasks.py | 42 +-
notifications/tests/conftest.py | 56 +++
notifications/tests/test_models.py | 135 ++++++
notifications/tests/test_tasks.py | 158 ++++---
notifications/utils.py | 0
.../models/crown_dependencies_envelope.py | 46 +-
publishing/models/packaged_workbasket.py | 80 +---
publishing/tests/conftest.py | 126 -----
publishing/tests/test_envelope_queue_views.py | 4 +-
.../test_model_crown_dependencies_envelope.py | 31 +-
publishing/tests/test_models.py | 34 +-
sample.env | 1 +
settings/common.py | 1 +
.../jinja2/workbaskets/review-goods.jinja | 9 +
.../workbaskets/summary-workbasket.jinja | 15 +-
workbaskets/tests/test_views.py | 198 ++++++++
workbaskets/views/ui.py | 25 +
31 files changed, 1685 insertions(+), 363 deletions(-)
create mode 100644 importer/jinja2/eu-importer/notify-success.jinja
create mode 100644 importer/management/commands/send_goods_report_notification.py
create mode 100644 importer/tests/management/commands/test_send_goods_report_notification.py
create mode 100644 notifications/migrations/0003_auto_20230911_0924.py
create mode 100644 notifications/notify.py
create mode 100644 notifications/tests/conftest.py
create mode 100644 notifications/tests/test_models.py
create mode 100644 notifications/utils.py
diff --git a/.gitignore b/.gitignore
index 43109bc38..28d2a8cf7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -165,3 +165,5 @@ _dumped_cache.pkl
# Vim
.swp
+
+tamato_*.sql
diff --git a/common/tests/factories.py b/common/tests/factories.py
index b7d7c6950..da8f952cd 100644
--- a/common/tests/factories.py
+++ b/common/tests/factories.py
@@ -1426,3 +1426,34 @@ class Meta:
commodity = factory.SubFactory(GoodsNomenclatureFactory)
duty_sentence = factory.Faker("text", max_nb_chars=24)
valid_between = date_ranges("no_end")
+
+
+class SucceededImportBatchFactory(ImportBatchFactory):
+ status = ImportBatchStatus.SUCCEEDED
+ goods_import = True
+ taric_file = "goods.xml"
+
+
+class GoodsSuccessfulImportNotificationFactory(factory.django.DjangoModelFactory):
+ """This is a factory for a goods report notification, requires an import id
+ passed by notified_object_id."""
+
+ class Meta:
+ model = "notifications.GoodsSuccessfulImportNotification"
+
+
+class EnvelopeReadyForProcessingNotificationFactory(factory.django.DjangoModelFactory):
+ """This is a factory for an envelope ready for processing notificaiton."""
+
+ class Meta:
+ model = "notifications.EnvelopeReadyForProcessingNotification"
+
+
+class CrownDependenciesEnvelopeSuccessNotificationFactory(
+ factory.django.DjangoModelFactory,
+):
+ """This is a factory for a crown dependencies envelope success
+ notification."""
+
+ class Meta:
+ model = "notifications.CrownDependenciesEnvelopeSuccessNotification"
diff --git a/conftest.py b/conftest.py
index 354490825..170f98c0e 100644
--- a/conftest.py
+++ b/conftest.py
@@ -10,6 +10,7 @@
from typing import Sequence
from typing import Tuple
from typing import Type
+from unittest.mock import MagicMock
from unittest.mock import patch
import boto3
@@ -66,6 +67,7 @@
from measures.models import MeasurementUnitQualifier
from measures.models import MonetaryUnit
from measures.parsers import DutySentenceParser
+from publishing.models import PackagedWorkBasket
from workbaskets.models import WorkBasket
from workbaskets.models import get_partition_scheme
from workbaskets.validators import WorkflowStatus
@@ -1820,3 +1822,140 @@ def duty_sentence_x_2_data(request, get_component_data):
(expected, [get_component_data(*args) for args in component_data]),
)
return history
+
+
+@pytest.fixture()
+def mocked_send_emails_apply_async():
+ with patch(
+ "notifications.tasks.send_emails_task.apply_async",
+ return_value=MagicMock(id=factory.Faker("uuid4")),
+ ) as mocked_delay:
+ yield mocked_delay
+
+
+@pytest.fixture()
+def mocked_send_emails():
+ with patch(
+ "notifications.tasks.send_emails_task",
+ return_value=MagicMock(id=factory.Faker("uuid4")),
+ ) as mocked_delay:
+ yield mocked_delay
+
+
+@pytest.fixture(scope="function")
+def packaged_workbasket_factory(queued_workbasket_factory):
+ """
+ Factory fixture to create a packaged workbasket.
+
+ params:
+ workbasket defaults to queued_workbasket_factory() which creates a
+ Workbasket in the state QUEUED with an approved transaction and tracked models
+ """
+
+ def factory_method(workbasket=None, **kwargs):
+ if not workbasket:
+ workbasket = queued_workbasket_factory()
+ with patch(
+ "publishing.tasks.create_xml_envelope_file.apply_async",
+ return_value=MagicMock(id=factory.Faker("uuid4")),
+ ):
+ packaged_workbasket = factories.QueuedPackagedWorkBasketFactory(
+ workbasket=workbasket, **kwargs
+ )
+ return packaged_workbasket
+
+ return factory_method
+
+
+@pytest.fixture(scope="function")
+def published_envelope_factory(packaged_workbasket_factory, envelope_storage):
+ """
+ Factory fixture to create an envelope and update the packaged_workbasket
+ envelope field.
+
+ params:
+ packaged_workbasket defaults to packaged_workbasket_factory() which creates a
+ Packaged workbasket with a Workbasket in the state QUEUED
+ with an approved transaction and tracked models
+ """
+
+ def factory_method(packaged_workbasket=None, **kwargs):
+ if not packaged_workbasket:
+ packaged_workbasket = packaged_workbasket_factory()
+
+ with patch(
+ "publishing.storages.EnvelopeStorage.save",
+ wraps=MagicMock(side_effect=envelope_storage.save),
+ ) as mock_save:
+ envelope = factories.PublishedEnvelopeFactory(
+ packaged_work_basket=packaged_workbasket,
+ **kwargs,
+ )
+ mock_save.assert_called_once()
+
+ packaged_workbasket.envelope = envelope
+ packaged_workbasket.save()
+ return envelope
+
+ return factory_method
+
+
+@pytest.fixture(scope="function")
+def successful_envelope_factory(
+ published_envelope_factory,
+ mocked_send_emails_apply_async,
+):
+ """
+ Factory fixture to create a successfully processed envelope and update the
+ packaged_workbasket envelope field.
+
+ params:
+ packaged_workbasket defaults to packaged_workbasket_factory() which creates a
+ Packaged workbasket with a Workbasket in the state QUEUED
+ with an approved transaction and tracked models
+ """
+
+ def factory_method(**kwargs):
+ envelope = published_envelope_factory(**kwargs)
+
+ packaged_workbasket = PackagedWorkBasket.objects.get(
+ envelope=envelope,
+ )
+
+ packaged_workbasket.begin_processing()
+ assert packaged_workbasket.position == 0
+ assert (
+ packaged_workbasket.pk
+ == PackagedWorkBasket.objects.currently_processing().pk
+ )
+ factories.LoadingReportFactory.create(packaged_workbasket=packaged_workbasket)
+ packaged_workbasket.processing_succeeded()
+ packaged_workbasket.save()
+ assert packaged_workbasket.position == 0
+ return envelope
+
+ return factory_method
+
+
+@pytest.fixture(scope="function")
+def crown_dependencies_envelope_factory(successful_envelope_factory):
+ """
+ Factory fixture to create a crown dependencies envelope.
+
+ params:
+ packaged_workbasket defaults to packaged_workbasket_factory() which creates a
+ Packaged workbasket with a Workbasket in the state QUEUED
+ with an approved transaction and tracked models
+ """
+
+ def factory_method(**kwargs):
+ envelope = successful_envelope_factory(**kwargs)
+
+ packaged_workbasket = PackagedWorkBasket.objects.get(
+ envelope=envelope,
+ )
+ return factories.CrownDependenciesEnvelopeFactory(
+ packaged_work_basket=packaged_workbasket,
+ )
+
+ return factory_method
diff --git a/importer/jinja2/eu-importer/notify-success.jinja b/importer/jinja2/eu-importer/notify-success.jinja
new file mode 100644
index 000000000..17b495110
--- /dev/null
+++ b/importer/jinja2/eu-importer/notify-success.jinja
@@ -0,0 +1,40 @@
+{% extends "layouts/layout.jinja" %}
+{% from "components/breadcrumbs/macro.njk" import govukBreadcrumbs %}
+{% from "components/button/macro.njk" import govukButton %}
+{% from "components/panel/macro.njk" import govukPanel %}
+
+
+{% set page_title = "Notify Goods Report" %}
+
+
+{% block breadcrumb %}
+ {{ govukBreadcrumbs({
+ "items": [
+ {"text": "Home", "href": url("home")},
+ {"text": "Edit an existing workbasket", "href": url("workbaskets:workbasket-ui-list")},
+ {"text": "Workbasket " ~ request.session.workbasket.id ~ " - Review goods",
+ "href": url("workbaskets:workbasket-ui-review-goods")},
+ {"text": page_title}
+ ]
+ }) }}
+{% endblock %}
+
+
+{% block content %}
+
+
+ {{ govukPanel({
+ "titleText": "Notification email sent",
+ "text": "An email notification has been successfully sent to Channel Islands.",
+ "classes": "govuk-!-margin-bottom-7"
+ }) }}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/importer/management/commands/send_goods_report_notification.py b/importer/management/commands/send_goods_report_notification.py
new file mode 100644
index 000000000..8db3368eb
--- /dev/null
+++ b/importer/management/commands/send_goods_report_notification.py
@@ -0,0 +1,43 @@
+from django.core.management import BaseCommand
+from django.core.management.base import CommandError
+
+from importer.models import ImportBatch
+from notifications.models import GoodsSuccessfulImportNotification
+
+
+def send_notifcation(
+ import_id: int,
+):
+ try:
+ import_batch = ImportBatch.objects.get(
+ pk=import_id,
+ )
+ except ImportBatch.DoesNotExist:
+ raise CommandError(
+ f"No ImportBatch instance found with pk={import_id}",
+ )
+
+ notification = GoodsSuccessfulImportNotification(
+ notified_object_pk=import_batch.id,
+ )
+ notification.save()
+ notification.synchronous_send_emails()
+
+
+class Command(BaseCommand):
+ help = "Send a good report notifcation for a give Id"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--import-batch-id",
+ help=(
+ "The primary key ID of ImportBatch instance for which a report "
+ "should be generated."
+ ),
+ type=int,
+ )
+
+ def handle(self, *args, **options):
+ send_notifcation(
+ import_id=options["import_batch_id"],
+ )
diff --git a/importer/tests/management/commands/test_send_goods_report_notification.py b/importer/tests/management/commands/test_send_goods_report_notification.py
new file mode 100644
index 000000000..a64425197
--- /dev/null
+++ b/importer/tests/management/commands/test_send_goods_report_notification.py
@@ -0,0 +1,52 @@
+from unittest.mock import MagicMock
+from unittest.mock import patch
+
+import pytest
+from django.core.management import call_command
+from django.core.management.base import CommandError
+
+pytestmark = pytest.mark.django_db
+
+
+@pytest.mark.parametrize(
+ "args, exception_type, error_msg",
+ [
+ (
+ [""],
+ CommandError,
+ "Error: unrecognized arguments:",
+ ),
+ (
+ ["--import-batch-id", "1234"],
+ CommandError,
+ "No ImportBatch instance found with pk=1234",
+ ),
+ ],
+)
+def test_send_goods_report_notification_required_arguments(
+ args,
+ exception_type,
+ error_msg,
+):
+ """Test that `send_goods_report_notification` command raises errors when
+ invalid arguments are provided."""
+ with pytest.raises(exception_type, match=error_msg):
+ call_command("send_goods_report_notification", *args)
+
+
+def test_send_goods_report_notification(
+ completed_goods_import_batch,
+):
+ """Test that `send_goods_report_notification` command triggers an email
+ notification."""
+
+ with patch(
+ "notifications.models.send_emails_task",
+ return_value=MagicMock(),
+ ) as mocked_email_task:
+ call_command(
+ "send_goods_report_notification",
+ "--import-batch-id",
+ str(completed_goods_import_batch.id),
+ )
+ mocked_email_task.assert_called_once()
diff --git a/importer/tests/test_views.py b/importer/tests/test_views.py
index 31fdcd9ba..87bf0e0ba 100644
--- a/importer/tests/test_views.py
+++ b/importer/tests/test_views.py
@@ -1,4 +1,5 @@
from os import path
+from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
@@ -282,3 +283,29 @@ def test_import_list_filters_return_correct_imports(
assert len(page.find_all(class_="status-badge")) == 1
assert page.find(class_="status-badge", text=expected_status_text)
assert page.find("tbody").find("td", text=import_batch.name)
+
+
+def test_notify_channel_islands_redirects(
+ valid_user_client,
+ completed_goods_import_batch,
+):
+ """Tests that, when the notify button is clicked that it redirects to the
+ conformation page on successful notification trigger."""
+
+ with patch(
+ "notifications.models.send_emails_task",
+ return_value=MagicMock(),
+ ) as mocked_email_task:
+ response = valid_user_client.get(
+ reverse(
+ "goods-report-notify",
+ kwargs={"pk": completed_goods_import_batch.pk},
+ ),
+ )
+
+ mocked_email_task.assert_called_once()
+ assert response.status_code == 302
+ assert response.url == reverse(
+ "goods-report-notify-success",
+ kwargs={"pk": completed_goods_import_batch.pk},
+ )
diff --git a/importer/urls.py b/importer/urls.py
index cbeae244e..6497ffca3 100644
--- a/importer/urls.py
+++ b/importer/urls.py
@@ -41,6 +41,16 @@
views.DownloadGoodsReportView.as_view(),
name="goods-report-ui-download",
),
+ path(
+ "notify-goods-report//",
+ views.NotifyGoodsReportView.as_view(),
+ name="goods-report-notify",
+ ),
+ path(
+ "notify-goods-report-confirm//",
+ views.NotifyGoodsReportSuccessView.as_view(),
+ name="goods-report-notify-success",
+ ),
]
urlpatterns = general_importer_urlpatterns + commodity_importer_urlpatterns
diff --git a/importer/views.py b/importer/views.py
index e40e948db..78f8e9c9e 100644
--- a/importer/views.py
+++ b/importer/views.py
@@ -19,6 +19,7 @@
from importer.filters import TaricImportFilter
from importer.goods_report import GoodsReporter
from importer.models import ImportBatchStatus
+from notifications.models import GoodsSuccessfulImportNotification
from workbaskets.validators import WorkflowStatus
@@ -236,3 +237,34 @@ class DownloadGoodsReportView(
def get(self, request, *args, **kwargs) -> HttpResponse:
import_batch = self.get_object()
return self.download_response(import_batch)
+
+
+class NotifyGoodsReportView(
+ PermissionRequiredMixin,
+ DetailView,
+):
+ """View used to notify an import report of goods changes in Excel format."""
+
+ permission_required = "common.add_trackedmodel"
+ model = models.ImportBatch
+
+ def get(self, request, *args, **kwargs):
+ import_batch = self.get_object()
+
+ # create notification
+ notification = GoodsSuccessfulImportNotification(
+ notified_object_pk=import_batch.id,
+ )
+ notification.save()
+ notification.synchronous_send_emails()
+
+ return redirect(
+ reverse("goods-report-notify-success", kwargs={"pk": import_batch.id}),
+ )
+
+
+class NotifyGoodsReportSuccessView(DetailView):
+ """Goods Report notification success trigger view."""
+
+ template_name = "eu-importer/notify-success.jinja"
+ model = models.ImportBatch
diff --git a/notifications/admin.py b/notifications/admin.py
index bc6da4940..e7f4e8a42 100644
--- a/notifications/admin.py
+++ b/notifications/admin.py
@@ -1,6 +1,7 @@
from django.contrib import admin
from notifications.forms import NotifiedUserAdminForm
+from notifications.models import Notification
from notifications.models import NotificationLog
from notifications.models import NotifiedUser
@@ -13,12 +14,15 @@ class NotifiedUserAdmin(admin.ModelAdmin):
"email",
"enrol_packaging",
"enrol_api_publishing",
+ "enrol_goods_report",
)
actions = [
"set_enrol_packaging",
"unset_enrol_packaging",
"set_enrol_api_publishing",
"unset_enrol_api_publishing",
+ "set_enrol_goods_report",
+ "unset_enrol_goods_report",
]
def set_enrol_packaging(self, request, queryset):
@@ -33,6 +37,12 @@ def set_enrol_api_publishing(self, request, queryset):
def unset_enrol_api_publishing(self, request, queryset):
queryset.update(enrol_api_publishing=False)
+ def set_enrol_goods_report(self, request, queryset):
+ queryset.update(enrol_goods_report=True)
+
+ def unset_enrol_goods_report(self, request, queryset):
+ queryset.update(enrol_goods_report=False)
+
class NotificationLogAdmin(admin.ModelAdmin):
"""Class providing read-only list and detail views for notification logs."""
@@ -59,5 +69,49 @@ def changeform_view(self, request, object_id=None, form_url="", extra_context=No
)
+class NotificationAdmin(admin.ModelAdmin):
+ """Class providing read-only list and detail views for notification."""
+
+ ordering = ["pk"]
+ list_display = (
+ "pk",
+ "notification_type",
+ "notified_object_pk",
+ "display_users",
+ )
+
+ list_filter = ("notification_type",)
+
+ readonly_fields = []
+
+ def display_users(self, obj):
+ subclass_obj = obj.return_subclass_instance()
+ return ", ".join(
+ [user.email for user in subclass_obj.notified_users().all()],
+ )
+
+ display_users.short_description = "Recipients"
+
+ def get_readonly_fields(self, request, obj=None):
+ return list(self.readonly_fields) + [field.name for field in obj._meta.fields]
+
+ def has_add_permission(self, request):
+ return False
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+ def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
+ extra_context = extra_context or {}
+ extra_context["show_save_and_continue"] = False
+ extra_context["show_save"] = False
+ return super(NotificationAdmin, self).changeform_view(
+ request,
+ object_id,
+ extra_context=extra_context,
+ )
+
+
admin.site.register(NotifiedUser, NotifiedUserAdmin)
admin.site.register(NotificationLog, NotificationLogAdmin)
+admin.site.register(Notification, NotificationAdmin)
diff --git a/notifications/forms.py b/notifications/forms.py
index 6812a9dd1..164a048f7 100644
--- a/notifications/forms.py
+++ b/notifications/forms.py
@@ -10,4 +10,5 @@ class Meta:
"email",
"enrol_packaging",
"enrol_api_publishing",
+ "enrol_goods_report",
]
diff --git a/notifications/migrations/0003_auto_20230911_0924.py b/notifications/migrations/0003_auto_20230911_0924.py
new file mode 100644
index 000000000..523c7bbed
--- /dev/null
+++ b/notifications/migrations/0003_auto_20230911_0924.py
@@ -0,0 +1,132 @@
+# Generated by Django 3.2.20 on 2023-09-11 08:24
+
+import django.db.models.deletion
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("notifications", "0002_notifieduser_enrol_api_publishing"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Notification",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("notified_object_pk", models.IntegerField(default=None, null=True)),
+ (
+ "notification_type",
+ models.CharField(
+ choices=[
+ ("goods_report", "Goods Report"),
+ ("packaging_notify_ready", "Packaging Notify Ready"),
+ ("packaging_accepted", "Packaging Accepted"),
+ ("packaging_rejected", "Packaging Rejected"),
+ ("publishing_success", "Publishing Successful"),
+ ("publishing_failed", "Publishing Failed"),
+ ],
+ max_length=100,
+ ),
+ ),
+ ],
+ ),
+ migrations.RemoveField(
+ model_name="notificationlog",
+ name="template_id",
+ ),
+ migrations.AddField(
+ model_name="notificationlog",
+ name="response_ids",
+ field=models.TextField(default=None, null=True),
+ ),
+ migrations.AddField(
+ model_name="notificationlog",
+ name="success",
+ field=models.BooleanField(default=True),
+ ),
+ migrations.AddField(
+ model_name="notifieduser",
+ name="enrol_goods_report",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.CreateModel(
+ name="CrownDependenciesEnvelopeFailedNotification",
+ fields=[],
+ options={
+ "proxy": True,
+ "indexes": [],
+ "constraints": [],
+ },
+ bases=("notifications.notification",),
+ ),
+ migrations.CreateModel(
+ name="CrownDependenciesEnvelopeSuccessNotification",
+ fields=[],
+ options={
+ "proxy": True,
+ "indexes": [],
+ "constraints": [],
+ },
+ bases=("notifications.notification",),
+ ),
+ migrations.CreateModel(
+ name="EnvelopeAcceptedNotification",
+ fields=[],
+ options={
+ "proxy": True,
+ "indexes": [],
+ "constraints": [],
+ },
+ bases=("notifications.notification",),
+ ),
+ migrations.CreateModel(
+ name="EnvelopeReadyForProcessingNotification",
+ fields=[],
+ options={
+ "proxy": True,
+ "indexes": [],
+ "constraints": [],
+ },
+ bases=("notifications.notification",),
+ ),
+ migrations.CreateModel(
+ name="EnvelopeRejectedNotification",
+ fields=[],
+ options={
+ "proxy": True,
+ "indexes": [],
+ "constraints": [],
+ },
+ bases=("notifications.notification",),
+ ),
+ migrations.CreateModel(
+ name="GoodsSuccessfulImportNotification",
+ fields=[],
+ options={
+ "proxy": True,
+ "indexes": [],
+ "constraints": [],
+ },
+ bases=("notifications.notification",),
+ ),
+ migrations.AddField(
+ model_name="notificationlog",
+ name="notification",
+ field=models.ForeignKey(
+ default=None,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="notifications.notification",
+ ),
+ ),
+ ]
diff --git a/notifications/models.py b/notifications/models.py
index 502c1ee1e..c9faad050 100644
--- a/notifications/models.py
+++ b/notifications/models.py
@@ -1,6 +1,16 @@
+import logging
+
+from django.conf import settings
from django.db import models
+from django.db.models.query_utils import Q
+from django.urls import reverse
from common.models.mixins import TimestampedMixin
+from notifications.notify import prepare_link_to_file
+from notifications.notify import send_emails
+from notifications.tasks import send_emails_task
+
+logger = logging.getLogger(__name__)
class NotifiedUser(models.Model):
@@ -10,17 +20,439 @@ class NotifiedUser(models.Model):
email = models.EmailField()
enrol_packaging = models.BooleanField(default=True)
enrol_api_publishing = models.BooleanField(default=False)
+ enrol_goods_report = models.BooleanField(default=False)
def __str__(self):
- return self.email
+ return str(self.email)
class NotificationLog(TimestampedMixin):
"""
- A NotificationLog records which email template a group of users received.
+ A NotificationLog records which Notification a group of users received.
We create one each time a group of users receive an email.
"""
- template_id = models.CharField(max_length=100)
+ response_ids = models.TextField(
+ default=None,
+ null=True,
+ )
recipients = models.TextField()
+ """Comma separated email addresses, as a single string, of the recipients of
+ the notification."""
+ notification = models.ForeignKey(
+ "notifications.Notification",
+ default=None,
+ null=True,
+ on_delete=models.PROTECT,
+ )
+ success = models.BooleanField(default=True)
+
+
+class NotificationTypeChoices(models.TextChoices):
+ GOODS_REPORT = (
+ "goods_report",
+ "Goods Report",
+ )
+ PACKAGING_NOTIFY_READY = (
+ "packaging_notify_ready",
+ "Packaging Notify Ready",
+ )
+ PACKAGING_ACCEPTED = (
+ "packaging_accepted",
+ "Packaging Accepted",
+ )
+ PACKAGING_REJECTED = (
+ "packaging_rejected",
+ "Packaging Rejected",
+ )
+ PUBLISHING_SUCCESS = (
+ "publishing_success",
+ "Publishing Successful",
+ )
+ PUBLISHING_FAILED = (
+ "publishing_failed",
+ "Publishing Failed",
+ )
+
+
+class Notification(models.Model):
+ """
+ Base class to manage sending notifications.
+
+ Subclasses specialise this class's behaviour for specific categories of
+ notification.
+
+ Subclasses should specify the proxy model of inheritance:
+ https://docs.djangoproject.com/en/dev/topics/db/models/#proxy-models
+ """
+
+ notified_object_pk = models.IntegerField(
+ default=None,
+ null=True,
+ )
+ """The primary key of the object being notified on."""
+
+ notification_type = models.CharField(
+ max_length=100,
+ choices=NotificationTypeChoices.choices,
+ )
+
+ def return_subclass_instance(self) -> "Notification":
+ subclasses = {
+ NotificationTypeChoices.GOODS_REPORT: GoodsSuccessfulImportNotification,
+ NotificationTypeChoices.PACKAGING_ACCEPTED: EnvelopeAcceptedNotification,
+ NotificationTypeChoices.PACKAGING_NOTIFY_READY: EnvelopeReadyForProcessingNotification,
+ NotificationTypeChoices.PACKAGING_REJECTED: EnvelopeRejectedNotification,
+ NotificationTypeChoices.PUBLISHING_SUCCESS: CrownDependenciesEnvelopeSuccessNotification,
+ NotificationTypeChoices.PUBLISHING_FAILED: CrownDependenciesEnvelopeFailedNotification,
+ }
+ self.__class__ = subclasses[self.notification_type]
+ return self
+
+ def get_personalisation(self) -> dict:
+ """
+ Returns the personalisation of the notified object.
+
+ Implement in concrete subclasses.
+ """
+ raise NotImplementedError
+
+ def notify_template_id(self) -> str:
+ """
+ GOV.UK Notify template ID specific to each Notification sub-class.
+
+ Implement in concrete subclasses.
+ """
+ raise NotImplementedError
+
+ def notified_users(self) -> models.QuerySet[NotifiedUser]:
+ """
+ Returns the queryset of NotifiedUsers for a specific notifications.
+
+ Implement in concrete subclasses.
+ """
+ raise NotImplementedError
+
+ def notified_object(self) -> models.Model:
+ """
+ Returns the object instance that is being notified on.
+
+ Implement in concrete subclasses.
+ """
+ raise NotImplementedError
+
+ def synchronous_send_emails(self):
+ """Synchronously call to send a notification email."""
+ send_emails_task(self.pk)
+
+ def schedule_send_emails(self, countdown=1):
+ """Schedule a call to send a notification email, run as an asynchronous
+ background task."""
+ send_emails_task.apply_async(args=[self.pk], countdown=countdown)
+
+ def send_notification_emails(self):
+ """Send the notification emails to users via GOV.UK Notify."""
+
+ notified_users = self.notified_users()
+ if not notified_users:
+ logger.error(
+ f"No notified users for {self.__class__.__name__} "
+ f"with pk={self.pk}",
+ )
+ return
+
+ personalisation = self.get_personalisation()
+
+ result = send_emails(
+ self.notify_template_id(),
+ personalisation,
+ [user.email for user in notified_users],
+ )
+
+ NotificationLog.objects.create(
+ response_ids=result["response_ids"],
+ recipients=result["recipients"],
+ notification=self,
+ )
+
+ # if any emails failed create a log for unsuccessful emails
+ if result["failed_recipients"]:
+ NotificationLog.objects.create(
+ recipients=result["failed_recipients"],
+ notification=self,
+ success=False,
+ )
+
+
+class EnvelopeReadyForProcessingNotification(Notification):
+ """Manage sending notifications when envelopes are ready for processing by
+ HMRC."""
+
+ class Meta:
+ proxy = True
+
+ def __init__(self, notified_object_pk: int):
+ super(EnvelopeReadyForProcessingNotification, self).__init__(
+ notified_object_pk=notified_object_pk,
+ notification_type=NotificationTypeChoices.PACKAGING_NOTIFY_READY,
+ )
+
+ def get_personalisation(self) -> dict:
+ packaged_workbasket = self.notified_object()
+ eif = "Immediately"
+ if packaged_workbasket.eif:
+ eif = packaged_workbasket.eif.strftime("%d/%m/%Y")
+
+ personalisation = {
+ "envelope_id": packaged_workbasket.envelope.envelope_id,
+ "description": packaged_workbasket.description,
+ "download_url": (
+ settings.BASE_SERVICE_URL + reverse("publishing:envelope-queue-ui-list")
+ ),
+ "theme": packaged_workbasket.theme,
+ "eif": eif,
+ "embargo": packaged_workbasket.embargo
+ if packaged_workbasket.embargo
+ else "None",
+ "jira_url": packaged_workbasket.jira_url,
+ }
+ return personalisation
+
+ def notify_template_id(self) -> str:
+ return settings.READY_FOR_CDS_TEMPLATE_ID
+
+ def notified_users(self):
+ return NotifiedUser.objects.filter(
+ Q(enrol_packaging=True),
+ )
+
+ def notified_object(self) -> models.Model:
+ from publishing.models import PackagedWorkBasket
+
+ return (
+ PackagedWorkBasket.objects.get(pk=self.notified_object_pk)
+ if self.notified_object_pk
+ else None
+ )
+
+
+class EnvelopeAcceptedNotification(Notification):
+ """Manage sending notifications when envelopes have been accepted by
+ HMRC."""
+
+ class Meta:
+ proxy = True
+
+ def __init__(self, notified_object_pk: int):
+ super(EnvelopeAcceptedNotification, self).__init__(
+ notified_object_pk=notified_object_pk,
+ notification_type=NotificationTypeChoices.PACKAGING_ACCEPTED,
+ )
+
+ def get_personalisation(self) -> dict:
+ packaged_workbasket = self.notified_object()
+ loading_report_message = "Loading report: No loading report was provided."
+ loading_reports = packaged_workbasket.loadingreports.exclude(
+ file_name="",
+ ).values_list(
+ "file_name",
+ flat=True,
+ )
+ if loading_reports:
+ file_names = ", ".join(loading_reports)
+ loading_report_message = f"Loading report(s): {file_names}"
+
+ personalisation = {
+ "envelope_id": packaged_workbasket.envelope.envelope_id,
+ "transaction_count": packaged_workbasket.workbasket.transactions.count(),
+ "loading_report_message": loading_report_message,
+ "comments": packaged_workbasket.loadingreports.first().comments,
+ }
+ return personalisation
+
+ def notify_template_id(self) -> str:
+ return settings.CDS_ACCEPTED_TEMPLATE_ID
+
+ def notified_users(self):
+ return NotifiedUser.objects.filter(
+ Q(enrol_packaging=True),
+ )
+
+ def notified_object(self) -> models.Model:
+ from publishing.models import PackagedWorkBasket
+
+ return (
+ PackagedWorkBasket.objects.get(self.notified_object_pk)
+ if self.notified_object_pk
+ else None
+ )
+
+
+class EnvelopeRejectedNotification(Notification):
+ """Manage sending notifications when envelopes have been rejected by
+ HMRC."""
+
+ class Meta:
+ proxy = True
+
+ def __init__(self, notified_object_pk: int):
+ super(EnvelopeRejectedNotification, self).__init__(
+ notified_object_pk=notified_object_pk,
+ notification_type=NotificationTypeChoices.PACKAGING_REJECTED,
+ )
+
+ def get_personalisation(self) -> dict:
+ packaged_workbasket = self.notified_object()
+ loading_report_message = "Loading report: No loading report was provided."
+ loading_reports = packaged_workbasket.loadingreports.exclude(
+ file_name="",
+ ).values_list(
+ "file_name",
+ flat=True,
+ )
+ if loading_reports:
+ file_names = ", ".join(loading_reports)
+ loading_report_message = f"Loading report(s): {file_names}"
+
+ personalisation = {
+ "envelope_id": packaged_workbasket.envelope.envelope_id,
+ "transaction_count": packaged_workbasket.workbasket.transactions.count(),
+ "loading_report_message": loading_report_message,
+ "comments": packaged_workbasket.loadingreports.first().comments,
+ }
+ return personalisation
+
+ def notify_template_id(self) -> str:
+ return settings.CDS_REJECTED_TEMPLATE_ID
+
+ def notified_users(self):
+ return NotifiedUser.objects.filter(
+ Q(enrol_packaging=True),
+ )
+
+ def notified_object(self) -> models.Model:
+ from publishing.models import PackagedWorkBasket
+
+ return (
+ PackagedWorkBasket.objects.get(pk=self.notified_object_pk)
+ if self.notified_object_pk
+ else None
+ )
+
+
+class CrownDependenciesEnvelopeSuccessNotification(Notification):
+ """Manage sending notifications when envelopes have been successfully
+ published to the Crown Dependencies api."""
+
+ class Meta:
+ proxy = True
+
+ def __init__(self, notified_object_pk: int):
+ super(CrownDependenciesEnvelopeSuccessNotification, self).__init__(
+ notified_object_pk=notified_object_pk,
+ notification_type=NotificationTypeChoices.PUBLISHING_SUCCESS,
+ )
+
+ def get_personalisation(self) -> dict:
+ crown_dependicies_envelope = self.notified_object()
+ personalisation = {
+ "envelope_id": crown_dependicies_envelope.packagedworkbaskets.last().envelope.envelope_id,
+ }
+ return personalisation
+
+ def notify_template_id(self) -> str:
+ return settings.API_PUBLISH_SUCCESS_TEMPLATE_ID
+
+ def notified_users(self):
+ return NotifiedUser.objects.filter(
+ Q(enrol_api_publishing=True),
+ )
+
+ def notified_object(self) -> models.Model:
+ from publishing.models import CrownDependenciesEnvelope
+
+ return (
+ CrownDependenciesEnvelope.objects.get(pk=self.notified_object_pk)
+ if self.notified_object_pk
+ else None
+ )
+
+
+class CrownDependenciesEnvelopeFailedNotification(Notification):
+ """Manage sending notifications when envelopes have been failed being
+ published to the Crown Dependencies api."""
+
+ class Meta:
+ proxy = True
+
+ def __init__(self, notified_object_pk: int):
+ super(CrownDependenciesEnvelopeFailedNotification, self).__init__(
+ notified_object_pk=notified_object_pk,
+ notification_type=NotificationTypeChoices.PUBLISHING_FAILED,
+ )
+
+ def get_personalisation(self) -> dict:
+ self.notified_object()
+ personalisation = {
+ "envelope_id": self.packagedworkbaskets.last().envelope.envelope_id,
+ }
+ return personalisation
+
+ def notify_template_id(self) -> str:
+ return settings.API_PUBLISH_FAILED_TEMPLATE_ID
+
+ def notified_users(self):
+ return NotifiedUser.objects.filter(
+ Q(enrol_api_publishing=True),
+ )
+
+ def notified_object(self) -> models.Model:
+ from publishing.models import CrownDependenciesEnvelope
+
+ return (
+ CrownDependenciesEnvelope.objects.get(pk=self.notified_object_pk)
+ if self.notified_object_pk
+ else None
+ )
+
+
+class GoodsSuccessfulImportNotification(Notification):
+ """Manage sending notifications when a goods report has been reviewed and
+ can be sent to Crown Dependencies."""
+
+ class Meta:
+ proxy = True
+
+ def __init__(self, notified_object_pk: int):
+ super(GoodsSuccessfulImportNotification, self).__init__(
+ notified_object_pk=notified_object_pk,
+ notification_type=NotificationTypeChoices.GOODS_REPORT,
+ )
+
+ def get_personalisation(self) -> dict:
+ import_batch = self.notified_object()
+ personalisation = {
+ "tgb_id": import_batch.name,
+ "link_to_file": prepare_link_to_file(
+ import_batch.taric_file,
+ ),
+ }
+ return personalisation
+
+ def notify_template_id(self) -> str:
+ return settings.GOODS_REPORT_TEMPLATE_ID
+
+ def notified_users(self):
+ return NotifiedUser.objects.filter(
+ Q(enrol_goods_report=True),
+ )
+
+ def notified_object(self) -> models.Model:
+ from importer.models import ImportBatch
+
+ return (
+ ImportBatch.objects.get(id=self.notified_object_pk)
+ if self.notified_object_pk
+ else None
+ )
diff --git a/notifications/notify.py b/notifications/notify.py
new file mode 100644
index 000000000..8d19c8e54
--- /dev/null
+++ b/notifications/notify.py
@@ -0,0 +1,86 @@
+import logging
+from tempfile import NamedTemporaryFile
+from typing import List
+
+from django.conf import settings
+from notifications_python_client import prepare_upload
+from notifications_python_client.notifications import NotificationsAPIClient
+
+from importer.goods_report import GoodsReporter
+
+logger = logging.getLogger(__name__)
+
+
+def get_notifications_client():
+ return NotificationsAPIClient(settings.NOTIFICATIONS_API_KEY)
+
+
+def prepare_link_to_file(
+ file,
+ is_csv=False,
+ confirm_email_before_download=True,
+ retention_period=None,
+):
+ """
+ Prepare importer file to upload. Improvement possibility have file pre
+ genreated and in s3 possibly.
+
+ params:
+ file: file which to generate report from
+ is_csv: if the file being attached is a csv set to True, default False
+ confirm_email_before_download: security feature where user opening files must be on Gov Notify email list, True by default
+ retention_period: how long the file link is valid for, default 6 months
+ """
+
+ with NamedTemporaryFile(suffix=".xlsx") as tmp:
+ reporter = GoodsReporter(file)
+ goods_report = reporter.create_report()
+ goods_report.xlsx_file(tmp)
+ return prepare_upload(
+ tmp,
+ is_csv,
+ confirm_email_before_download,
+ retention_period,
+ )
+
+
+def send_emails(template_id, personalisation: dict, email_addresses: List[str]) -> dict:
+ """
+ Generic send emails function which triggers a notification to Gov notify.
+
+ params:
+ template_id: email template Id
+ personalisation: email personalisation
+ email_addresses: list of emails to send emails to
+
+ returns:
+ dict(
+ "response_ids": string of successful email response ids
+ "recipients": string of successful emails recipients
+ "failed_recipients": string of unsuccessful emails recipients
+ )
+ """
+ notification_client = get_notifications_client()
+ recipients = ""
+ failed_recipients = ""
+ response_ids = ""
+ for email in email_addresses:
+ try:
+ response = notification_client.send_email_notification(
+ email_address=email,
+ template_id=template_id,
+ personalisation=personalisation,
+ )
+ response_ids += f"{response['id']} \n"
+ recipients += f"{email} \n"
+ except Exception as e:
+ failed_recipients += f"{email} \n"
+ logger.error(
+ f"Failed to send email notification to {email}, with status code {e.status_code}.",
+ )
+
+ return {
+ "response_ids": response_ids,
+ "recipients": recipients,
+ "failed_recipients": failed_recipients,
+ }
diff --git a/notifications/tasks.py b/notifications/tasks.py
index 407f8e657..a771c71b3 100644
--- a/notifications/tasks.py
+++ b/notifications/tasks.py
@@ -1,50 +1,18 @@
import logging
-from uuid import uuid4
from celery import shared_task
-from django.conf import settings
-from django.db.models.query_utils import Q
from django.db.transaction import atomic
-from notifications_python_client.notifications import NotificationsAPIClient
-
-from notifications.models import NotificationLog
-from notifications.models import NotifiedUser
logger = logging.getLogger(__name__)
-def get_notifications_client():
- return NotificationsAPIClient(settings.NOTIFICATIONS_API_KEY)
-
-
@shared_task
@atomic
-def send_emails(template_id: uuid4, personalisation: dict, email_type: str = None):
+def send_emails_task(notification_pk: int):
"""Task for emailing all users signed up to receive packaging updates and
creating a log to record which users received which email template."""
- user_filters = {
- "packaging": Q(enrol_packaging=True),
- "publishing": Q(enrol_api_publishing=True),
- }
- # Will get all users by default
- users = NotifiedUser.objects.filter(user_filters.get(email_type, Q()))
- if users.exists():
- notifications_client = get_notifications_client()
- recipients = ""
- for user in users:
- try:
- notifications_client.send_email_notification(
- email_address=user.email,
- template_id=template_id,
- personalisation=personalisation,
- )
- recipients += f"{user.email} \n"
- except:
- logger.error(
- f"Failed to send email notification to {user.email}.",
- )
+ from notifications.models import Notification
- NotificationLog.objects.create(
- template_id=template_id,
- recipients=recipients,
- )
+ notification = Notification.objects.get(pk=notification_pk)
+ sub_notification = notification.return_subclass_instance()
+ sub_notification.send_notification_emails()
diff --git a/notifications/tests/conftest.py b/notifications/tests/conftest.py
new file mode 100644
index 000000000..d771afd97
--- /dev/null
+++ b/notifications/tests/conftest.py
@@ -0,0 +1,56 @@
+import pytest
+
+from common.tests import factories
+from importer.models import ImportBatchStatus
+
+
+@pytest.fixture()
+def goods_report_notification():
+ factories.NotifiedUserFactory(
+ email="goods_report@email.co.uk", # /PS-IGNORE
+ enrol_packaging=False,
+ enrol_goods_report=True,
+ )
+ factories.NotifiedUserFactory(
+ email="no_goods_report@email.co.uk", # /PS-IGNORE
+ )
+ import_batch = factories.ImportBatchFactory.create(
+ status=ImportBatchStatus.SUCCEEDED,
+ goods_import=True,
+ taric_file="goods.xml",
+ )
+
+ return factories.GoodsSuccessfulImportNotificationFactory(
+ notified_object_pk=import_batch.id,
+ )
+
+
+@pytest.fixture()
+def ready_for_packaging_notification(published_envelope_factory):
+ factories.NotifiedUserFactory(
+ email="packaging@email.co.uk", # /PS-IGNORE
+ )
+ factories.NotifiedUserFactory(
+ email="no_packaging@email.co.uk", # /PS-IGNORE
+ enrol_packaging=False,
+ )
+ packaged_wb = published_envelope_factory()
+ return factories.EnvelopeReadyForProcessingNotificationFactory(
+ notified_object_pk=packaged_wb.id,
+ )
+
+
+@pytest.fixture()
+def successful_publishing_notification(crown_dependencies_envelope_factory):
+ factories.NotifiedUserFactory(
+ email="publishing@email.co.uk", # /PS-IGNORE
+ enrol_packaging=False,
+ enrol_api_publishing=True,
+ )
+ factories.NotifiedUserFactory(
+ email="no_publishing@email.co.uk", # /PS-IGNORE
+ )
+ cde = crown_dependencies_envelope_factory()
+ return factories.CrownDependenciesEnvelopeSuccessNotificationFactory(
+ notified_object_pk=cde.id,
+ )
diff --git a/notifications/tests/test_models.py b/notifications/tests/test_models.py
new file mode 100644
index 000000000..145981eb2
--- /dev/null
+++ b/notifications/tests/test_models.py
@@ -0,0 +1,135 @@
+from unittest.mock import patch
+
+import factory
+import pytest
+
+from importer.models import ImportBatch
+from notifications.models import NotificationLog
+from publishing.models import CrownDependenciesEnvelope
+from publishing.models import PackagedWorkBasket
+
+pytestmark = pytest.mark.django_db
+
+
+def test_create_goods_report_notification(goods_report_notification):
+ """Test that the creating a notification correctly assigns users."""
+
+ expected_present_email = f"goods_report@email.co.uk" # /PS-IGNORE
+ expected_not_present_email = f"no_goods_report@email.co.uk" # /PS-IGNORE
+
+ users = goods_report_notification.notified_users()
+
+ for user in users:
+ assert user.email == expected_present_email
+ assert user.email != expected_not_present_email
+
+ import_batch = goods_report_notification.notified_object()
+ assert isinstance(import_batch, ImportBatch)
+
+ return_value = {
+ "file": "VGVzdA==",
+ "is_csv": False,
+ "confirm_email_before_download": True,
+ "retention_period": None,
+ }
+ with patch(
+ "notifications.models.prepare_link_to_file",
+ return_value=return_value,
+ ) as mocked_prepare_link_to_file:
+ personalisation = goods_report_notification.get_personalisation()
+
+ assert personalisation == {
+ "tgb_id": import_batch.name,
+ "link_to_file": return_value,
+ }
+
+
+def test_create_packaging_notification(ready_for_packaging_notification):
+ """Test that the creating a notification correctly assigns users."""
+
+ expected_present_email = f"packaging@email.co.uk" # /PS-IGNORE
+ expected_not_present_email = f"no_packaging@email.co.uk" # /PS-IGNORE
+
+ users = ready_for_packaging_notification.notified_users()
+
+ for user in users:
+ assert user.email == expected_present_email
+ assert user.email != expected_not_present_email
+
+ assert isinstance(
+ ready_for_packaging_notification.notified_object(),
+ PackagedWorkBasket,
+ )
+
+ content = ready_for_packaging_notification.get_personalisation()
+ assert content == {
+ "envelope_id": "230001",
+ "description": "",
+ "download_url": "http://localhost/publishing/envelope-queue/",
+ "theme": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "eif": "Immediately",
+ "embargo": "None",
+ "jira_url": "www.fakejiraticket.com",
+ }
+
+
+def test_create_successful_publishing_notification(successful_publishing_notification):
+ """Test that the creating a notification correctly assigns users."""
+
+ expected_present_email = f"publishing@email.co.uk" # /PS-IGNORE
+ expected_not_present_email = f"no_publishing@email.co.uk" # /PS-IGNORE
+
+ users = successful_publishing_notification.notified_users()
+
+ for user in users:
+ assert user.email == expected_present_email
+ assert user.email != expected_not_present_email
+
+ assert isinstance(
+ successful_publishing_notification.notified_object(),
+ CrownDependenciesEnvelope,
+ )
+
+ content = successful_publishing_notification.get_personalisation()
+ assert content == {"envelope_id": "230001"}
+
+
+def test_send_notification_emails(ready_for_packaging_notification):
+ expected_present_email = f"packaging@email.co.uk" # /PS-IGNORE
+ expected_not_present_email = f"no_packaging@email.co.uk" # /PS-IGNORE
+ with patch(
+ "notifications.models.send_emails",
+ return_value={
+ "response_ids": " \n".join([str(factory.Faker("uuid"))]),
+ "recipients": " \n".join([expected_present_email]),
+ "failed_recipients": "",
+ },
+ ) as mocked_send_emails:
+ ready_for_packaging_notification.send_notification_emails()
+ mocked_send_emails.assert_called_once()
+
+ log_success = NotificationLog.objects.get(
+ notification=ready_for_packaging_notification,
+ recipients=expected_present_email,
+ success=True,
+ )
+
+ assert expected_present_email in log_success.recipients
+
+ with patch(
+ "notifications.models.send_emails",
+ return_value={
+ "response_ids": "",
+ "recipients": "",
+ "failed_recipients": " \n".join([expected_present_email]),
+ },
+ ) as mocked_send_emails:
+ ready_for_packaging_notification.send_notification_emails()
+ mocked_send_emails.assert_called_once()
+
+ log_fail = NotificationLog.objects.get(
+ notification=ready_for_packaging_notification,
+ success=False,
+ )
+
+ assert expected_present_email in log_fail.recipients
diff --git a/notifications/tests/test_tasks.py b/notifications/tests/test_tasks.py
index b824cf6fc..a0e82bd00 100644
--- a/notifications/tests/test_tasks.py
+++ b/notifications/tests/test_tasks.py
@@ -1,93 +1,119 @@
from unittest.mock import patch
-from uuid import uuid4
+import factory
import pytest
-from common.tests import factories
from notifications import models
from notifications import tasks
pytestmark = pytest.mark.django_db
-@pytest.mark.skip(reason="TODO mock Notify client correctly")
-@patch("notifications.tasks.NotificationsAPIClient.send_email_notification")
-def test_send_emails_cds(send_email_notification, settings):
- """Tests that email notifications are only sent to users subscribed to
- packaging emails and that a log is created with this user's email and
- template id."""
- enrolled_user = factories.NotifiedUserFactory.create()
- unenrolled_user = factories.NotifiedUserFactory.create(enrol_packaging=False)
- template_id = uuid4()
-
- personalisation = {
- "envelope_id": "220001",
- "description": "description",
- "download_url": "https://example.com",
- "theme": "theme",
- "eif": None,
- "embargo": "None",
- "jira_url": "https://example.com",
+def test_send_emails_goods_report_notification(
+ goods_report_notification,
+):
+ """Tests that email notifications are only sent to users subscribed to email
+ type and that a log is created with this user's email."""
+ expected_present_email = "goods_report@email.co.uk" # /PS-IGNORE
+ expected_unenrolled_email = "no_goods_report@email.co.uk" # /PS-IGNORE
+
+ return_value = {
+ "file": "VGVzdA==",
+ "is_csv": False,
+ "confirm_email_before_download": True,
+ "retention_period": None,
}
+ with patch(
+ "notifications.models.prepare_link_to_file",
+ return_value=return_value,
+ ) as mocked_prepare_link_to_file:
+ with patch(
+ "notifications.models.send_emails",
+ return_value={
+ "response_ids": " \n".join([str(factory.Faker("uuid"))]),
+ "recipients": " \n".join([expected_present_email]),
+ "failed_recipients": "",
+ },
+ ) as mocked_send_emails:
+ tasks.send_emails_task.apply(
+ kwargs={
+ "notification_pk": goods_report_notification.id,
+ },
+ )
+ mocked_send_emails.assert_called_once()
+ mocked_prepare_link_to_file.assert_called_once()
- tasks.send_emails.apply(
- kwargs={
- "template_id": template_id,
- "personalisation": personalisation,
- "email_type": "packaging",
- },
+ log = models.NotificationLog.objects.get(
+ notification=goods_report_notification,
+ recipients=expected_present_email,
+ success=True,
)
- send_email_notification.assert_called_once_with(
- email_address=enrolled_user.email,
- template_id=template_id,
- personalisation=personalisation,
- )
+ assert expected_unenrolled_email not in log.recipients
+
+
+def test_send_emails_packaging_notification(
+ ready_for_packaging_notification,
+):
+ """Tests that email notifications are only sent to users subscribed to email
+ type and that a log is created with this user's email."""
+
+ expected_present_email = "packaging@email.co.uk" # /PS-IGNORE
+ expected_unenrolled_email = "no_packaging@email.co.uk" # /PS-IGNORE
+
+ with patch(
+ "notifications.models.send_emails",
+ return_value={
+ "response_ids": " \n".join([str(factory.Faker("uuid"))]),
+ "recipients": " \n".join([expected_present_email]),
+ "failed_recipients": "",
+ },
+ ) as mocked_send_emails:
+ tasks.send_emails_task.apply(
+ kwargs={
+ "notification_pk": ready_for_packaging_notification.id,
+ },
+ )
+ mocked_send_emails.assert_called_once()
- recipients = f"{enrolled_user.email} \n"
log = models.NotificationLog.objects.get(
- template_id=template_id,
- recipients=recipients,
+ notification=ready_for_packaging_notification,
+ recipients=expected_present_email,
+ success=True,
)
- assert unenrolled_user.email not in log.recipients
+ assert expected_unenrolled_email not in log.recipients
-@pytest.mark.skip(reason="TODO mock Notify client correctly")
-@patch("notifications.tasks.NotificationsAPIClient.send_email_notification")
-def test_send_emails_api(send_email_notification, settings):
- """Tests that email notifications are only sent to users subscribed to
- packaging emails and that a log is created with this user's email and
- template id."""
- enrolled_user = factories.NotifiedUserFactory.create(
- enrol_packaging=False,
- enrol_api_publishing=True,
- )
- unenrolled_user = factories.NotifiedUserFactory.create(enrol_packaging=False)
- template_id = uuid4()
+def test_send_emails_publishing_notification(
+ successful_publishing_notification,
+ # mock_notify_send_emails,
+):
+ """Tests that email notifications are only sent to users subscribed to email
+ type and that a log is created with this user's email."""
- personalisation = {
- "envelope_id": "220001",
- }
+ expected_present_email = "publishing@email.co.uk" # /PS-IGNORE
+ expected_unenrolled_email = "no_publishing@email.co.uk" # /PS-IGNORE
- tasks.send_emails.apply(
- kwargs={
- "template_id": template_id,
- "personalisation": personalisation,
- "email_type": "publishing",
+ with patch(
+ "notifications.models.send_emails",
+ return_value={
+ "response_ids": " \n".join([str(factory.Faker("uuid"))]),
+ "recipients": " \n".join([expected_present_email]),
+ "failed_recipients": "",
},
- )
-
- send_email_notification.assert_called_once_with(
- email_address=enrolled_user.email,
- template_id=template_id,
- personalisation=personalisation,
- )
+ ) as mocked_send_emails:
+ tasks.send_emails_task.apply(
+ kwargs={
+ "notification_pk": successful_publishing_notification.id,
+ },
+ )
+ mocked_send_emails.assert_called_once()
- recipients = f"{enrolled_user.email} \n"
log = models.NotificationLog.objects.get(
- template_id=template_id,
- recipients=recipients,
+ notification=successful_publishing_notification,
+ recipients=expected_present_email,
+ success=True,
)
- assert unenrolled_user.email not in log.recipients
+ assert expected_unenrolled_email not in log.recipients
diff --git a/notifications/utils.py b/notifications/utils.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/publishing/models/crown_dependencies_envelope.py b/publishing/models/crown_dependencies_envelope.py
index b1e778659..487e58dee 100644
--- a/publishing/models/crown_dependencies_envelope.py
+++ b/publishing/models/crown_dependencies_envelope.py
@@ -1,7 +1,6 @@
import logging
from datetime import datetime
-from django.conf import settings
from django.db.models import DateTimeField
from django.db.models import Manager
from django.db.models import QuerySet
@@ -10,7 +9,8 @@
from django_fsm import transition
from common.models.mixins import TimestampedMixin
-from notifications.tasks import send_emails
+from notifications.models import CrownDependenciesEnvelopeFailedNotification
+from notifications.models import CrownDependenciesEnvelopeSuccessNotification
from publishing.models.decorators import save_after
from publishing.models.decorators import skip_notifications_if_disabled
from publishing.models.packaged_workbasket import PackagedWorkBasket
@@ -79,11 +79,13 @@ class CrownDependenciesEnvelope(TimestampedMixin):
"""
Represents a crown dependencies envelope.
- This model contains the Envelope upload status to the Channel islands API and it's publishing times.
+ This model contains the Envelope upload status to the Channel islands API
+ and it's publishing times.
An Envelope contains one or more Transactions, listing changes to be applied
to the tariff in the sequence defined by the transaction IDs. Contains
- xml_file which is a reference to the envelope stored on s3. This can be found in the Envelope model.
+ xml_file which is a reference to the envelope stored on s3. This can be
+ found in the Envelope model.
"""
class Meta:
@@ -149,43 +151,33 @@ def publishing_failed(self):
with a failed outcome."""
self.notify_publishing_failed()
- def notify_publishing_completed(self, template_id: str):
- """
- Notify users that envelope publishing has completed (success or failure)
- for this instance.
-
- `template_id` should be the ID of the Notify email template of either
- the successfully published or failed publishing email.
- """
-
- personalisation = {
- "envelope_id": self.packagedworkbaskets.last().envelope.envelope_id,
- }
-
- send_emails.delay(
- template_id=template_id,
- personalisation=personalisation,
- email_type="publishing",
- )
-
@skip_notifications_if_disabled
def notify_publishing_success(self):
"""Notify users that an envelope has successfully publishing to api."""
- self.notify_publishing_completed(settings.API_PUBLISH_SUCCESS_TEMPLATE_ID)
+ notification = CrownDependenciesEnvelopeSuccessNotification(
+ notified_object_pk=self.pk,
+ )
+ print(notification)
+ notification.save()
+ notification.schedule_send_emails()
@skip_notifications_if_disabled
def notify_publishing_failed(self):
"""Notify users that an envelope has failed publishing to api."""
- self.notify_publishing_completed(settings.API_PUBLISH_FAILED_TEMPLATE_ID)
+ notification = CrownDependenciesEnvelopeFailedNotification(
+ notified_object_pk=self.pk,
+ )
+ notification.save()
+ notification.schedule_send_emails()
@atomic
def refresh_from_db(self, using=None, fields=None):
"""Reload instance from database but avoid writing to
self.publishing_state directly in order to avoid the exception
- 'AttributeError: Direct publishing_state modification is not allowed.'
- """
+ 'AttributeError: Direct publishing_state modification is not
+ allowed.'."""
if fields is None:
refresh_state = True
fields = [f.name for f in self._meta.concrete_fields]
diff --git a/publishing/models/packaged_workbasket.py b/publishing/models/packaged_workbasket.py
index 1c7bc3cc5..d6df65b07 100644
--- a/publishing/models/packaged_workbasket.py
+++ b/publishing/models/packaged_workbasket.py
@@ -1,7 +1,6 @@
import logging
from datetime import datetime
-from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import PROTECT
from django.db.models import SET_NULL
@@ -20,13 +19,14 @@
from django.db.models import Value
from django.db.models.functions import Coalesce
from django.db.transaction import atomic
-from django.urls import reverse
from django_fsm import FSMField
from django_fsm import transition
from common.models.mixins import TimestampedMixin
+from notifications.models import EnvelopeAcceptedNotification
+from notifications.models import EnvelopeReadyForProcessingNotification
+from notifications.models import EnvelopeRejectedNotification
from notifications.models import NotificationLog
-from notifications.tasks import send_emails
from publishing import models as publishing_models
from publishing.models.decorators import save_after
from publishing.models.decorators import skip_notifications_if_disabled
@@ -397,7 +397,7 @@ def create_envelope_for_top(cls):
def next_expected_to_api(self) -> bool:
"""
- checks if previous envelope in sequence has been published to the API.
+ Checks if previous envelope in sequence has been published to the API.
This check will check if the previous packaged workbasket has a
CrownDependenciesEnvelope OR has published_to_tariffs_api set in the
@@ -531,8 +531,8 @@ def abandon(self):
def refresh_from_db(self, using=None, fields=None):
"""Reload instance from database but avoid writing to
self.processing_state directly in order to avoid the exception
- 'AttributeError: Direct processing_state modification is not allowed.'
- """
+ 'AttributeError: Direct processing_state modification is not
+ allowed.'."""
if fields is None:
refresh_state = True
fields = [f.name for f in self._meta.concrete_fields]
@@ -563,71 +563,31 @@ def notify_ready_for_processing(self):
therefore normally called when the process for doing that has completed
(see `publishing.tasks.create_xml_envelope_file()`).
"""
- eif = "Immediately"
- if self.eif:
- eif = self.eif.strftime("%d/%m/%Y")
-
- personalisation = {
- "envelope_id": self.envelope.envelope_id,
- "description": self.description,
- "download_url": (
- settings.BASE_SERVICE_URL + reverse("publishing:envelope-queue-ui-list")
- ),
- "theme": self.theme,
- "eif": eif,
- "embargo": self.embargo if self.embargo else "None",
- "jira_url": self.jira_url,
- }
-
- send_emails.delay(
- template_id=settings.READY_FOR_CDS_TEMPLATE_ID,
- personalisation=personalisation,
- email_type="packaging",
- )
-
- def notify_processing_completed(self, template_id):
- """
- Notify users that envelope processing has completed (accepted or
- rejected) for this instance.
-
- `template_id` should be the ID of the Notify email template of either
- the successfully processed email or failed processing.
- """
- loading_report_message = "Loading report: No loading report was provided."
- loading_reports = self.loadingreports.exclude(file_name="").values_list(
- "file_name",
- flat=True,
- )
- if loading_reports:
- file_names = ", ".join(loading_reports)
- loading_report_message = f"Loading report(s): {file_names}"
-
- personalisation = {
- "envelope_id": self.envelope.envelope_id,
- "transaction_count": self.workbasket.transactions.count(),
- "loading_report_message": loading_report_message,
- "comments": self.loadingreports.first().comments,
- }
-
- send_emails.delay(
- template_id=template_id,
- personalisation=personalisation,
- email_type="packaging",
+ notification = EnvelopeReadyForProcessingNotification(
+ notified_object_pk=self.pk,
)
+ notification.save()
+ notification.schedule_send_emails()
@skip_notifications_if_disabled
def notify_processing_succeeded(self):
"""Notify users that envelope processing has succeeded (i.e. the
associated envelope was correctly ingested into HMRC systems)."""
-
- self.notify_processing_completed(settings.CDS_ACCEPTED_TEMPLATE_ID)
+ notification = EnvelopeAcceptedNotification(
+ notified_object_pk=self.pk,
+ )
+ notification.save()
+ notification.schedule_send_emails()
@skip_notifications_if_disabled
def notify_processing_failed(self):
"""Notify users that envelope processing has failed (i.e. HMRC systems
rejected this instance's associated envelope file)."""
-
- self.notify_processing_completed(settings.CDS_REJECTED_TEMPLATE_ID)
+ notification = EnvelopeRejectedNotification(
+ notified_object_pk=self.pk,
+ )
+ notification.save()
+ notification.schedule_send_emails()
@property
def cds_notified_notification_log(self) -> NotificationLog:
diff --git a/publishing/tests/conftest.py b/publishing/tests/conftest.py
index 6ee53dbc8..8b49dee1f 100644
--- a/publishing/tests/conftest.py
+++ b/publishing/tests/conftest.py
@@ -13,7 +13,6 @@
import pytest
from common.tests import factories
-from publishing.models import PackagedWorkBasket
from publishing.models import QueueState
from publishing.models.state import CrownDependenciesPublishingState
@@ -50,15 +49,6 @@ def unpause_publishing():
)
-@pytest.fixture()
-def mocked_publishing_models_send_emails_delay():
- with patch(
- "notifications.tasks.send_emails.delay",
- return_value=MagicMock(id=factory.Faker("uuid4")),
- ) as mocked_delay:
- yield mocked_delay
-
-
@pytest.fixture(scope="module", autouse=True)
def mocked_create_xml_envelope_file_apply_sync():
"""Mock the Celery delay() function on
@@ -70,119 +60,3 @@ def mocked_create_xml_envelope_file_apply_sync():
return_value=MagicMock(id=factory.Faker("uuid4")),
) as mocked_apply_sync:
yield mocked_apply_sync
-
-
-@pytest.fixture(scope="function")
-def packaged_workbasket_factory(queued_workbasket_factory):
- """
- Factory fixture to create a packaged workbasket.
-
- params:
- workbasket defaults to queued_workbasket_factory() which creates a
- Workbasket in the state QUEUED with an approved transaction and tracked models
- """
-
- def factory_method(workbasket=None, **kwargs):
- if not workbasket:
- workbasket = queued_workbasket_factory()
- with patch(
- "publishing.tasks.create_xml_envelope_file.apply_async",
- return_value=MagicMock(id=factory.Faker("uuid4")),
- ):
- packaged_workbasket = factories.QueuedPackagedWorkBasketFactory(
- workbasket=workbasket, **kwargs
- )
- return packaged_workbasket
-
- return factory_method
-
-
-@pytest.fixture(scope="function")
-def published_envelope_factory(packaged_workbasket_factory, envelope_storage):
- """
- Factory fixture to create an envelope and update the packaged_workbasket
- envelope field.
-
- params:
- packaged_workbasket defaults to packaged_workbasket_factory() which creates a
- Packaged workbasket with a Workbasket in the state QUEUED
- with an approved transaction and tracked models
- """
-
- def factory_method(packaged_workbasket=None, **kwargs):
- if not packaged_workbasket:
- packaged_workbasket = packaged_workbasket_factory()
-
- with patch(
- "publishing.storages.EnvelopeStorage.save",
- wraps=MagicMock(side_effect=envelope_storage.save),
- ) as mock_save:
- envelope = factories.PublishedEnvelopeFactory(
- packaged_work_basket=packaged_workbasket,
- **kwargs,
- )
- mock_save.assert_called_once()
-
- packaged_workbasket.envelope = envelope
- packaged_workbasket.save()
- return envelope
-
- return factory_method
-
-
-@pytest.fixture(scope="function")
-def successful_envelope_factory(published_envelope_factory):
- """
- Factory fixture to create a successfully processed envelope and update the
- packaged_workbasket envelope field.
-
- params:
- packaged_workbasket defaults to packaged_workbasket_factory() which creates a
- Packaged workbasket with a Workbasket in the state QUEUED
- with an approved transaction and tracked models
- """
-
- def factory_method(**kwargs):
- envelope = published_envelope_factory(**kwargs)
-
- packaged_workbasket = PackagedWorkBasket.objects.get(
- envelope=envelope,
- )
-
- packaged_workbasket.begin_processing()
- assert packaged_workbasket.position == 0
- assert (
- packaged_workbasket.pk
- == PackagedWorkBasket.objects.currently_processing().pk
- )
- factories.LoadingReportFactory.create(packaged_workbasket=packaged_workbasket)
- packaged_workbasket.processing_succeeded()
- packaged_workbasket.save()
- assert packaged_workbasket.position == 0
- return envelope
-
- return factory_method
-
-
-@pytest.fixture(scope="function")
-def crown_dependencies_envelope_factory(successful_envelope_factory):
- """
- Factory fixture to create a crown dependencies envelope.
-
- params:
- packaged_workbasket defaults to packaged_workbasket_factory() which creates a
- Packaged workbasket with a Workbasket in the state QUEUED
- with an approved transaction and tracked models
- """
-
- def factory_method(**kwargs):
- envelope = successful_envelope_factory(**kwargs)
-
- packaged_workbasket = PackagedWorkBasket.objects.get(
- envelope=envelope,
- )
- return factories.CrownDependenciesEnvelopeFactory(
- packaged_work_basket=packaged_workbasket,
- )
-
- return factory_method
diff --git a/publishing/tests/test_envelope_queue_views.py b/publishing/tests/test_envelope_queue_views.py
index 35b10f53c..c88ab3c03 100644
--- a/publishing/tests/test_envelope_queue_views.py
+++ b/publishing/tests/test_envelope_queue_views.py
@@ -146,7 +146,7 @@ def test_start_processing(valid_user_client, unpause_queue):
def test_accept_envelope(
packaged_workbasket_factory,
published_envelope_factory,
- mocked_publishing_models_send_emails_delay,
+ mocked_send_emails_apply_async,
valid_user_client,
):
packaged_work_basket = packaged_workbasket_factory()
@@ -183,7 +183,7 @@ def test_accept_envelope(
def test_reject_envelope(
packaged_workbasket_factory,
published_envelope_factory,
- mocked_publishing_models_send_emails_delay,
+ mocked_send_emails_apply_async,
superuser_client,
):
packaged_work_basket_1 = packaged_workbasket_factory()
diff --git a/publishing/tests/test_model_crown_dependencies_envelope.py b/publishing/tests/test_model_crown_dependencies_envelope.py
index e25f92543..b2ba46820 100644
--- a/publishing/tests/test_model_crown_dependencies_envelope.py
+++ b/publishing/tests/test_model_crown_dependencies_envelope.py
@@ -1,6 +1,6 @@
import pytest
-from django.conf import settings
+from notifications.models import Notification
from publishing.models import ApiPublishingState
from publishing.models import CrownDependenciesEnvelope
@@ -32,11 +32,14 @@ def test_create_crown_dependencies_envelope(
def test_notify_processing_succeeded(
- mocked_publishing_models_send_emails_delay,
+ mocked_send_emails_apply_async,
packaged_workbasket_factory,
successful_envelope_factory,
crown_dependencies_envelope_factory,
+ settings,
):
+ settings.ENABLE_PACKAGING_NOTIFICATIONS = True
+
pwb = packaged_workbasket_factory()
crown_dependencies_envelope_factory(packaged_workbasket=pwb)
@@ -45,22 +48,18 @@ def test_notify_processing_succeeded(
cd_envelope.notify_publishing_success()
- personalisation = {
- "envelope_id": pwb.envelope.envelope_id,
- }
- mocked_publishing_models_send_emails_delay.assert_called_with(
- template_id=settings.API_PUBLISH_SUCCESS_TEMPLATE_ID,
- personalisation=personalisation,
- email_type="publishing",
- )
+ Notification.objects.last()
+ mocked_send_emails_apply_async.assert_called()
def test_notify_processing_failed(
- mocked_publishing_models_send_emails_delay,
+ mocked_send_emails_apply_async,
packaged_workbasket_factory,
successful_envelope_factory,
crown_dependencies_envelope_factory,
+ settings,
):
+ settings.ENABLE_PACKAGING_NOTIFICATIONS = True
pwb = packaged_workbasket_factory()
crown_dependencies_envelope_factory(packaged_workbasket=pwb)
@@ -69,11 +68,5 @@ def test_notify_processing_failed(
cd_envelope.notify_publishing_failed()
- personalisation = {
- "envelope_id": pwb.envelope.envelope_id,
- }
- mocked_publishing_models_send_emails_delay.assert_called_with(
- template_id=settings.API_PUBLISH_FAILED_TEMPLATE_ID,
- personalisation=personalisation,
- email_type="publishing",
- )
+ Notification.objects.last()
+ mocked_send_emails_apply_async.assert_called()
diff --git a/publishing/tests/test_models.py b/publishing/tests/test_models.py
index 78c188feb..287339f8a 100644
--- a/publishing/tests/test_models.py
+++ b/publishing/tests/test_models.py
@@ -5,7 +5,6 @@
import factory
import freezegun
import pytest
-from django.conf import settings
from django_fsm import TransitionNotAllowed
from common.tests import factories
@@ -68,9 +67,11 @@ def test_create_from_invalid_status():
def test_notify_ready_for_processing(
packaged_workbasket_factory,
published_envelope_factory,
- mocked_publishing_models_send_emails_delay,
+ mocked_send_emails_apply_async,
settings,
):
+ settings.ENABLE_PACKAGING_NOTIFICATIONS = True
+
packaged_wb = packaged_workbasket_factory()
envelope = published_envelope_factory(packaged_workbasket=packaged_wb)
packaged_wb.notify_ready_for_processing()
@@ -83,18 +84,17 @@ def test_notify_ready_for_processing(
"embargo": str(packaged_wb.embargo),
"jira_url": packaged_wb.jira_url,
}
- mocked_publishing_models_send_emails_delay.assert_called_once_with(
- template_id=settings.READY_FOR_CDS_TEMPLATE_ID,
- personalisation=personalisation,
- email_type="packaging",
- )
+ mocked_send_emails_apply_async.assert_called_once()
def test_notify_processing_succeeded(
- mocked_publishing_models_send_emails_delay,
+ mocked_send_emails_apply_async,
packaged_workbasket_factory,
published_envelope_factory,
+ settings,
):
+ settings.ENABLE_PACKAGING_NOTIFICATIONS = True
+
packaged_wb = packaged_workbasket_factory()
loading_report = factories.LoadingReportFactory.create(
packaged_workbasket=packaged_wb,
@@ -110,18 +110,16 @@ def test_notify_processing_succeeded(
"loading_report_message": f"Loading report(s): {loading_report.file_name}",
"comments": packaged_wb.loadingreports.first().comments,
}
- mocked_publishing_models_send_emails_delay.assert_called_once_with(
- template_id=settings.CDS_ACCEPTED_TEMPLATE_ID,
- personalisation=personalisation,
- email_type="packaging",
- )
+ mocked_send_emails_apply_async.assert_called_once()
def test_notify_processing_failed(
- mocked_publishing_models_send_emails_delay,
+ mocked_send_emails_apply_async,
packaged_workbasket_factory,
published_envelope_factory,
+ settings,
):
+ settings.ENABLE_PACKAGING_NOTIFICATIONS = True
packaged_wb = packaged_workbasket_factory()
loading_report1 = factories.LoadingReportFactory.create(
packaged_workbasket=packaged_wb,
@@ -141,18 +139,14 @@ def test_notify_processing_failed(
"comments": packaged_wb.loadingreports.first().comments,
}
- mocked_publishing_models_send_emails_delay.assert_called_once_with(
- template_id=settings.CDS_REJECTED_TEMPLATE_ID,
- personalisation=personalisation,
- email_type="packaging",
- )
+ mocked_send_emails_apply_async.assert_called_once()
def test_success_processing_transition(
packaged_workbasket_factory,
published_envelope_factory,
envelope_storage,
- mocked_publishing_models_send_emails_delay,
+ mocked_send_emails_apply_async,
settings,
):
settings.ENABLE_PACKAGING_NOTIFICATIONS = False
diff --git a/sample.env b/sample.env
index d08a7e967..d363eecd8 100644
--- a/sample.env
+++ b/sample.env
@@ -58,6 +58,7 @@ CDS_ACCEPTED_TEMPLATE_ID=cds_accepted_template_id
CDS_REJECTED_TEMPLATE_ID=cds_rejected_template_id
API_PUBLISH_SUCCESS_TEMPLATE_ID=api_success_template_id
API_PUBLISH_FAILED_TEMPLATE_ID=api_failed_template_id
+GOODS_REPORT_TEMPLATE_ID=goods_report_template_id
# Base service URL.
BASE_SERVICE_URL=http://localhost:8000
diff --git a/settings/common.py b/settings/common.py
index 69febc37f..aa667ea4f 100644
--- a/settings/common.py
+++ b/settings/common.py
@@ -755,6 +755,7 @@
CDS_REJECTED_TEMPLATE_ID = os.environ.get("CDS_REJECTED_TEMPLATE_ID")
API_PUBLISH_SUCCESS_TEMPLATE_ID = os.environ.get("API_PUBLISH_SUCCESS_TEMPLATE_ID")
API_PUBLISH_FAILED_TEMPLATE_ID = os.environ.get("API_PUBLISH_FAILED_TEMPLATE_ID")
+GOODS_REPORT_TEMPLATE_ID = os.environ.get("GOODS_REPORT_TEMPLATE_ID")
# Base service URL - required when constructing an absolute TAP URL to a page
# from a Celery task where no HTTP request object is available.
diff --git a/workbaskets/jinja2/workbaskets/review-goods.jinja b/workbaskets/jinja2/workbaskets/review-goods.jinja
index 720cbb987..c39c982ea 100644
--- a/workbaskets/jinja2/workbaskets/review-goods.jinja
+++ b/workbaskets/jinja2/workbaskets/review-goods.jinja
@@ -37,6 +37,15 @@
"classes": "govuk-button--secondary",
}) }}
{% endif %}
+
+ {% if unsent_notification %}
+ {{ govukButton({
+ "text": "Notify Channel Islands",
+ "href": url("goods-report-notify", kwargs={"pk": import_batch_pk}),
+ "classes": "govuk-button--primary",
+ "preventDoubleClick": true,
+ }) }}
+ {% endif %}
{% if report_lines %}
diff --git a/workbaskets/jinja2/workbaskets/summary-workbasket.jinja b/workbaskets/jinja2/workbaskets/summary-workbasket.jinja
index c7d116e22..81f15cd6d 100644
--- a/workbaskets/jinja2/workbaskets/summary-workbasket.jinja
+++ b/workbaskets/jinja2/workbaskets/summary-workbasket.jinja
@@ -95,9 +95,18 @@
aria-labelledby="summary-title" role="region" data-module="govuk-notification-banner">
{{ rule_check_result_content() }}
-
- Send to packaging queue
-
+ {% if unsent_notification %}
+
+ For commodity code imports a channel islands notification must be sent from the review goods tab before packaging.
+
+
+ Send to packaging queue
+
+ {% else %}
+
+ Send to packaging queue
+
+ {% endif %}
{% endset %}
diff --git a/workbaskets/tests/test_views.py b/workbaskets/tests/test_views.py
index 5ecfc3fcb..e7b974fde 100644
--- a/workbaskets/tests/test_views.py
+++ b/workbaskets/tests/test_views.py
@@ -1,5 +1,7 @@
+import os
import re
from unittest.mock import MagicMock
+from unittest.mock import mock_open
from unittest.mock import patch
import pytest
@@ -13,9 +15,12 @@
from common.models.utils import override_current_transaction
from common.tests import factories
from exporter.tasks import upload_workbaskets
+from importer.models import ImportBatch
+from importer.models import ImportBatchStatus
from measures.models import Measure
from workbaskets import models
from workbaskets.forms import SelectableObjectsForm
+from workbaskets.tasks import check_workbasket_sync
from workbaskets.validators import WorkflowStatus
from workbaskets.views import ui
@@ -660,6 +665,109 @@ def test_submit_for_packaging(valid_user_client, session_workbasket):
assert response.url[: len(response_url)] == response_url
+@pytest.fixture
+def successful_business_rules_setup(session_workbasket, valid_user_client):
+ """Sets up data and runs business rules."""
+ with session_workbasket.new_transaction() as transaction:
+ good = factories.GoodsNomenclatureFactory.create(transaction=transaction)
+ measure = factories.MeasureFactory.create(transaction=transaction)
+ geo_area = factories.GeographicalAreaFactory.create(transaction=transaction)
+ objects = [good, measure, geo_area]
+ for obj in objects:
+ TrackedModelCheckFactory.create(
+ transaction_check__transaction=transaction,
+ model=obj,
+ successful=True,
+ )
+ session = valid_user_client.session
+ session["workbasket"] = {
+ "id": session_workbasket.pk,
+ "status": session_workbasket.status,
+ "title": session_workbasket.title,
+ "error_count": session_workbasket.tracked_model_check_errors.count(),
+ }
+ session.save()
+
+ # run rule checks so unchecked_or_errored_transactions is set
+ check_workbasket_sync(session_workbasket)
+
+
+def import_batch_with_notification():
+ import_batch = factories.ImportBatchFactory.create(
+ status=ImportBatchStatus.SUCCEEDED,
+ goods_import=True,
+ taric_file="goods.xml",
+ )
+
+ return factories.GoodsSuccessfulImportNotificationFactory(
+ notified_object_pk=import_batch.id,
+ )
+
+
+@pytest.mark.parametrize(
+ "import_batch_factory,disabled",
+ [
+ (
+ lambda: factories.ImportBatchFactory.create(
+ status=ImportBatchStatus.SUCCEEDED,
+ goods_import=True,
+ ),
+ True,
+ ),
+ (
+ import_batch_with_notification,
+ False,
+ ),
+ (
+ lambda: factories.ImportBatchFactory.create(
+ status=ImportBatchStatus.SUCCEEDED,
+ ),
+ False,
+ ),
+ (
+ lambda: None,
+ False,
+ ),
+ ],
+ ids=(
+ "goods_import_no_notification",
+ "goods_import_with_notification",
+ "master_import",
+ "no_import",
+ ),
+)
+def test_submit_for_packaging_disabled(
+ successful_business_rules_setup,
+ valid_user_client,
+ session_workbasket,
+ import_batch_factory,
+ disabled,
+):
+ """Test that the submit-for-packaging button is disabled when a notification
+ has not been sent for a commodity code import (goods)"""
+
+ import_batch = import_batch_factory()
+
+ if import_batch:
+ import_batch.workbasket_id = session_workbasket.id
+ if isinstance(import_batch, ImportBatch):
+ import_batch.save()
+
+ response = valid_user_client.get(
+ reverse("workbaskets:current-workbasket"),
+ )
+
+ assert response.status_code == 200
+ soup = BeautifulSoup(str(response.content), "html.parser")
+
+ packaging_button = soup.find("a", href="/publishing/create/")
+
+ if disabled:
+ assert packaging_button.has_attr("disabled")
+ else:
+ assert not packaging_button.has_attr("disabled")
+
+
def test_terminate_rule_check(valid_user_client, session_workbasket):
session_workbasket.rule_check_task_id = 123
@@ -1285,3 +1393,93 @@ def test_workbasket_compare_found_measures(
# measure found
assert len(soup.select("tbody")[1].select("tr")) == 1
+
+
+def make_goods_import_batch(importer_storage, **kwargs):
+ return factories.ImportBatchFactory.create(
+ status=ImportBatchStatus.SUCCEEDED,
+ goods_import=True,
+ taric_file="goods.xml",
+ **kwargs,
+ )
+
+
+@pytest.mark.skip(reason="Unable to mock s3 file read from within ET.parse currently")
+@pytest.mark.parametrize(
+ "import_batch_factory,visable",
+ [
+ (
+ lambda: factories.ImportBatchFactory.create(
+ status=ImportBatchStatus.SUCCEEDED,
+ goods_import=True,
+ taric_file="goods.xml",
+ ),
+ True,
+ ),
+ (
+ import_batch_with_notification,
+ False,
+ ),
+ (
+ lambda: factories.ImportBatchFactory.create(
+ status=ImportBatchStatus.SUCCEEDED,
+ ),
+ False,
+ ),
+ (
+ lambda: None,
+ False,
+ ),
+ ],
+ ids=(
+ "goods_import_no_notification",
+ "goods_import_with_notification",
+ "master_import",
+ "no_import",
+ ),
+)
+def test_review_goods_notification_button(
+ successful_business_rules_setup,
+ valid_user_client,
+ session_workbasket,
+ import_batch_factory,
+ visable,
+):
+ """Test that the submit-for-packaging button is disabled when a notification
+ has not been sent for a commodity code import (goods)"""
+
+ import_batch = import_batch_factory()
+
+ if import_batch:
+ import_batch.workbasket_id = session_workbasket.id
+ if isinstance(import_batch, ImportBatch):
+ import_batch.save()
+
+ def mock_xlsx_open(filename, mode):
+ if os.path.basename(filename) == "goods.xlsx":
+ return mock_open().return_value
+ return open(filename, mode)
+
+ with patch(
+ "importer.goods_report.GoodsReport.xlsx_file",
+ return_value="",
+ ) as mocked_xlsx_file:
+ # with patch(
+ # ".open",
+ # mock_xlsx_open,
+ # ):
+ response = valid_user_client.get(
+ reverse("workbaskets:workbasket-ui-review-goods"),
+ )
+
+ assert response.status_code == 200
+ soup = BeautifulSoup(str(response.content), "html.parser")
+
+ # notify_button = soup.find("a", href=f"/notify-goods-report/{import_batch.id}/")
+ notify_button = soup.select(".govuk-body")
+
+ print(notify_button)
+ if visable:
+ assert notify_button
+ else:
+ assert not notify_button
diff --git a/workbaskets/views/ui.py b/workbaskets/views/ui.py
index 66c1d3729..f11147878 100644
--- a/workbaskets/views/ui.py
+++ b/workbaskets/views/ui.py
@@ -38,6 +38,8 @@
from importer.goods_report import GoodsReportLine
from measures.filters import MeasureFilter
from measures.models import Measure
+from notifications.models import Notification
+from notifications.models import NotificationTypeChoices
from workbaskets import forms
from workbaskets.models import DataRow
from workbaskets.models import DataUpload
@@ -348,6 +350,15 @@ def get_context_data(self, *args, **kwargs):
]
context["import_batch_pk"] = import_batch.pk
+ # notifications only relevant to a goods import
+ context["unsent_notification"] = (
+ import_batch.goods_import
+ and not Notification.objects.filter(
+ notified_object_pk=import_batch.pk,
+ notification_type=NotificationTypeChoices.GOODS_REPORT,
+ ).exists()
+ )
+
return context
@@ -465,6 +476,19 @@ def get_context_data(self, **kwargs):
self.request.user.is_superuser
or self.request.user.has_perm("workbaskets.delete_workbasket")
)
+ # set to true if there is an associated goods import batch with an unsent notification
+ try:
+ import_batch = self.workbasket.importbatch
+ unsent_notifcation = (
+ import_batch
+ and import_batch.goods_import
+ and not Notification.objects.filter(
+ notified_object_pk=import_batch.pk,
+ notification_type=NotificationTypeChoices.GOODS_REPORT,
+ ).exists()
+ )
+ except ObjectDoesNotExist:
+ unsent_notifcation = False
context.update(
{
"workbasket": self.workbasket,
@@ -472,6 +496,7 @@ def get_context_data(self, **kwargs):
"uploaded_envelope_dates": self.uploaded_envelope_dates,
"rule_check_in_progress": False,
"user_can_delete_workbasket": user_can_delete_workbasket,
+ "unsent_notification": unsent_notifcation,
},
)
if self.workbasket.rule_check_task_id: