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: