From 6b038d85b6088e4585881da08e43ef604bb221dc Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 7 Jun 2022 08:11:11 +1000 Subject: [PATCH] Notification on new orders (#3145) * Trigger a notification when a new SalesOrder is created - Notify the "responsible" owners (excluding the creator) - Add unit test for new notification * Adds notification when a new PurchaseOrder is created * Add notification when a new build order is created - Includes unit tests * Refactor order notification code - Adds a "exclude users" option for sending notifications * Fixes for notification refactoring * make notification a helper * reduce statements togehter * make reuse easier * Add docs * Make context variables clearer * fix assertation * Fix set notation Co-authored-by: Matthias --- InvenTree/InvenTree/helpers.py | 45 ++++++++++++++ InvenTree/build/models.py | 5 +- InvenTree/build/test_build.py | 58 ++++++++++++++----- InvenTree/common/notifications.py | 58 +++++++++++++++++-- InvenTree/order/models.py | 40 ++++++++----- InvenTree/order/test_sales_order.py | 24 ++++++++ InvenTree/order/tests.py | 28 ++++++++- .../templates/email/new_order_assigned.html | 11 ++++ 8 files changed, 232 insertions(+), 37 deletions(-) create mode 100644 InvenTree/templates/email/new_order_assigned.html diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 69369c9f3b..34f296c2f8 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -18,6 +18,8 @@ from PIL import Image import InvenTree.version from common.models import InvenTreeSetting +from common.notifications import (InvenTreeNotificationBodies, + NotificationBody, trigger_notification) from common.settings import currency_code_default from .api_tester import UserMixin @@ -719,3 +721,46 @@ def inheritors(cls): class InvenTreeTestCase(UserMixin, TestCase): """Testcase with user setup buildin.""" pass + + +def notify_responsible(instance, sender, content: NotificationBody = InvenTreeNotificationBodies.NewOrder, exclude=None): + """Notify all responsible parties of a change in an instance. + + Parses the supplied content with the provided instance and sender and sends a notification to all responsible users, + excluding the optional excluded list. + + Args: + instance: The newly created instance + sender: Sender model reference + content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder. + exclude (User, optional): User instance that should be excluded. Defaults to None. + """ + if instance.responsible is not None: + # Setup context for notification parsing + content_context = { + 'instance': str(instance), + 'verbose_name': sender._meta.verbose_name, + 'app_label': sender._meta.app_label, + 'model_name': sender._meta.model_name, + } + + # Setup notification context + context = { + 'instance': instance, + 'name': content.name.format(**content_context), + 'message': content.message.format(**content_context), + 'link': InvenTree.helpers.construct_absolute_url(instance.get_absolute_url()), + 'template': { + 'html': content.template.format(**content_context), + 'subject': content.name.format(**content_context), + } + } + + # Create notification + trigger_notification( + instance, + content.slug.format(**content_context), + targets=[instance.responsible], + target_exclude=[exclude], + context=context, + ) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index cbcfc72c87..3659ad4b52 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -24,7 +24,7 @@ from mptt.exceptions import InvalidMove from rest_framework import serializers from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode -from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode +from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode, notify_responsible from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin from InvenTree.validators import validate_build_order_reference @@ -1049,6 +1049,9 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs): # Run checks on required parts InvenTree.tasks.offload_task(build_tasks.check_build_stock, instance) + # Notify the responsible users that the build order has been created + notify_responsible(instance, sender, exclude=instance.issued_by) + class BuildOrderAttachment(InvenTreeAttachment): """Model for storing file attachments against a BuildOrder object.""" diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index cd411df6b1..663a247adf 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from django.test import TestCase from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.core.exceptions import ValidationError from InvenTree import status_codes as status @@ -14,6 +15,7 @@ import build.tasks from build.models import Build, BuildItem, get_next_build_number from part.models import Part, BomItem, BomItemSubstitute from stock.models import StockItem +from users.models import Owner class BuildTestBase(TestCase): @@ -382,6 +384,46 @@ class BuildTest(BuildTestBase): for output in outputs: self.assertFalse(output.is_building) + def test_overdue_notification(self): + """Test sending of notifications when a build order is overdue.""" + + self.build.target_date = datetime.now().date() - timedelta(days=1) + self.build.save() + + # Check for overdue orders + build.tasks.check_overdue_build_orders() + + message = common.models.NotificationMessage.objects.get( + category='build.overdue_build_order', + user__id=1, + ) + + self.assertEqual(message.name, 'Overdue Build Order') + + def test_new_build_notification(self): + """Test that a notification is sent when a new build is created""" + + Build.objects.create( + reference='IIIII', + title='Some new build', + part=self.assembly, + quantity=5, + issued_by=get_user_model().objects.get(pk=2), + responsible=Owner.create(obj=Group.objects.get(pk=3)) + ) + + # Two notifications should have been sent + messages = common.models.NotificationMessage.objects.filter( + category='build.new_build', + ) + + self.assertEqual(messages.count(), 2) + + self.assertFalse(messages.filter(user__pk=2).exists()) + + self.assertTrue(messages.filter(user__pk=3).exists()) + self.assertTrue(messages.filter(user__pk=4).exists()) + class AutoAllocationTests(BuildTestBase): """Tests for auto allocating stock against a build order""" @@ -479,19 +521,3 @@ class AutoAllocationTests(BuildTestBase): self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0) - - def test_overdue_notification(self): - """Test sending of notifications when a build order is overdue.""" - - self.build.target_date = datetime.now().date() - timedelta(days=1) - self.build.save() - - # Check for overdue orders - build.tasks.check_overdue_build_orders() - - message = common.models.NotificationMessage.objects.get( - category='build.overdue_build_order', - user__id=1, - ) - - self.assertEqual(message.name, 'Overdue Build Order') diff --git a/InvenTree/common/notifications.py b/InvenTree/common/notifications.py index 13d0450043..6f8a47ca5a 100644 --- a/InvenTree/common/notifications.py +++ b/InvenTree/common/notifications.py @@ -1,13 +1,15 @@ """Base classes and functions for notifications.""" import logging +from dataclasses import dataclass from datetime import timedelta from django.contrib.auth import get_user_model from django.contrib.auth.models import Group +from django.utils.translation import gettext_lazy as _ +import InvenTree.helpers from common.models import NotificationEntry, NotificationMessage -from InvenTree.helpers import inheritors from InvenTree.ready import isImportingData from plugin import registry from plugin.models import NotificationUserSetting @@ -179,7 +181,7 @@ class MethodStorageClass: selected_classes (class, optional): References to the classes that should be registered. Defaults to None. """ logger.info('collecting notification methods') - current_method = inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS + current_method = InvenTree.helpers.inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS # for testing selective loading is made available if selected_classes: @@ -257,12 +259,51 @@ class UIMessageNotification(SingleNotificationMethod): return True +@dataclass() +class NotificationBody: + """Information needed to create a notification. + + Attributes: + name (str): Name (or subject) of the notification + slug (str): Slugified reference for notification + message (str): Notification message as text. Should not be longer than 120 chars. + template (str): Reference to the html template for the notification. + + The strings support f-string sytle fomratting with context variables parsed at runtime. + + Context variables: + instance: Text representing the instance + verbose_name: Verbose name of the model + app_label: App label (slugified) of the model + model_name': Name (slugified) of the model + """ + name: str + slug: str + message: str + template: str + + +class InvenTreeNotificationBodies: + """Default set of notifications for InvenTree. + + Contains regularly used notification bodies. + """ + NewOrder = NotificationBody( + name=_("New {verbose_name}"), + slug='{app_label}.new_{model_name}', + message=_("A new {verbose_name} has been created and ,assigned to you"), + template='email/new_order_assigned.html', + ) + """Send when a new order (build, sale or purchase) was created.""" + + def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): """Send out a notification.""" targets = kwargs.get('targets', None) target_fnc = kwargs.get('target_fnc', None) target_args = kwargs.get('target_args', []) target_kwargs = kwargs.get('target_kwargs', {}) + target_exclude = kwargs.get('target_exclude', None) context = kwargs.get('context', {}) delivery_methods = kwargs.get('delivery_methods', None) @@ -290,6 +331,9 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): logger.info(f"Gathering users for notification '{category}'") + if target_exclude is None: + target_exclude = set() + # Collect possible targets if not targets: targets = target_fnc(*target_args, **target_kwargs) @@ -302,15 +346,19 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): for target in targets: # User instance is provided if isinstance(target, get_user_model()): - target_users.add(target) + if target not in target_exclude: + target_users.add(target) # Group instance is provided elif isinstance(target, Group): for user in get_user_model().objects.filter(groups__name=target.name): - target_users.add(user) + if user not in target_exclude: + target_users.add(user) # Owner instance (either 'user' or 'group' is provided) elif isinstance(target, Owner): for owner in target.get_related_owners(include_group=False): - target_users.add(owner.owner) + user = owner.owner + if user not in target_exclude: + target_users.add(user) # Unhandled type else: logger.error(f"Unknown target passed to trigger_notification method: {target}") diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index bba4e91909..be96a8fa2b 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -30,7 +30,8 @@ import InvenTree.ready from common.settings import currency_code_default from company.models import Company, SupplierPart from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField -from InvenTree.helpers import decimal2string, getSetting, increment +from InvenTree.helpers import (decimal2string, getSetting, increment, + notify_responsible) from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus, StockHistoryCode, StockStatus) @@ -574,6 +575,17 @@ class PurchaseOrder(Order): self.complete_order() # This will save the model +@receiver(post_save, sender=PurchaseOrder, dispatch_uid='purchase_order_post_save') +def after_save_purchase_order(sender, instance: PurchaseOrder, created: bool, **kwargs): + """Callback function to be executed after a PurchaseOrder is saved.""" + if not InvenTree.ready.canAppAccessDatabase(allow_test=True) or InvenTree.ready.isImportingData(): + return + + if created: + # Notify the responsible users that the purchase order has been created + notify_responsible(instance, sender, exclude=instance.created_by) + + class SalesOrder(Order): """A SalesOrder represents a list of goods shipped outwards to a customer. @@ -839,29 +851,29 @@ class SalesOrder(Order): return self.pending_shipments().count() -@receiver(post_save, sender=SalesOrder, dispatch_uid='build_post_save_log') +@receiver(post_save, sender=SalesOrder, dispatch_uid='sales_order_post_save') def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs): - """Callback function to be executed after a SalesOrder instance is saved. + """Callback function to be executed after a SalesOrder is saved. - If the SALESORDER_DEFAULT_SHIPMENT setting is enabled, create a default shipment - Ignore if the database is not ready for access - Ignore if data import is active """ - - if not InvenTree.ready.canAppAccessDatabase(allow_test=True): + if not InvenTree.ready.canAppAccessDatabase(allow_test=True) or InvenTree.ready.isImportingData(): return - if InvenTree.ready.isImportingData(): - return - - if created and getSetting('SALESORDER_DEFAULT_SHIPMENT'): + if created: # A new SalesOrder has just been created - # Create default shipment - SalesOrderShipment.objects.create( - order=instance, - reference='1', - ) + if getSetting('SALESORDER_DEFAULT_SHIPMENT'): + # Create default shipment + SalesOrderShipment.objects.create( + order=instance, + reference='1', + ) + + # Notify the responsible users that the sales order has been created + notify_responsible(instance, sender, exclude=instance.created_by) class PurchaseOrderAttachment(InvenTreeAttachment): diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index b0d570cef2..21303a2c2c 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -260,3 +260,27 @@ class SalesOrderTest(TestCase): ) self.assertEqual(len(messages), 2) + + def test_new_so_notification(self): + """Test that a notification is sent when a new SalesOrder is created. + + - The responsible user should receive a notification + - The creating user should *not* receive a notification + """ + + SalesOrder.objects.create( + customer=self.customer, + reference='1234567', + created_by=get_user_model().objects.get(pk=3), + responsible=Owner.create(obj=Group.objects.get(pk=3)) + ) + + messages = NotificationMessage.objects.filter( + category='order.new_salesorder', + ) + + # A notification should have been generated for user 4 (who is a member of group 3) + self.assertTrue(messages.filter(user__pk=4).exists()) + + # However *no* notification should have been generated for the creating user + self.assertFalse(messages.filter(user__pk=3).exists()) diff --git a/InvenTree/order/tests.py b/InvenTree/order/tests.py index 9b6077a14e..a2042f08ed 100644 --- a/InvenTree/order/tests.py +++ b/InvenTree/order/tests.py @@ -9,7 +9,7 @@ from django.test import TestCase import common.models import order.tasks -from company.models import SupplierPart +from company.models import Company, SupplierPart from InvenTree.status_codes import PurchaseOrderStatus from part.models import Part from stock.models import StockLocation @@ -237,3 +237,29 @@ class OrderTest(TestCase): self.assertEqual(msg.target_object_id, 1) self.assertEqual(msg.name, 'Overdue Purchase Order') + + def test_new_po_notification(self): + """Test that a notification is sent when a new PurchaseOrder is created + + - The responsible user(s) should receive a notification + - The creating user should *not* receive a notification + """ + + PurchaseOrder.objects.create( + supplier=Company.objects.get(pk=1), + reference='XYZABC', + created_by=get_user_model().objects.get(pk=3), + responsible=Owner.create(obj=get_user_model().objects.get(pk=4)), + ) + + messages = common.models.NotificationMessage.objects.filter( + category='order.new_purchaseorder', + ) + + self.assertEqual(messages.count(), 1) + + # A notification should have been generated for user 4 (who is a member of group 3) + self.assertTrue(messages.filter(user__pk=4).exists()) + + # However *no* notification should have been generated for the creating user + self.assertFalse(messages.filter(user__pk=3).exists()) diff --git a/InvenTree/templates/email/new_order_assigned.html b/InvenTree/templates/email/new_order_assigned.html new file mode 100644 index 0000000000..9d4161d352 --- /dev/null +++ b/InvenTree/templates/email/new_order_assigned.html @@ -0,0 +1,11 @@ +{% extends "email/email.html" %} + +{% load i18n %} +{% load inventree_extras %} + +{% block title %} +{{ message }} +{% if link %} +

{% trans "Click on the following link to view this order" %}: {{ link }}

+{% endif %} +{% endblock title %}