From 1e6bdfbcab89e83e6b2d1119a7fc198a0572569e Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 6 Jun 2022 19:12:29 +1000 Subject: [PATCH] Overdue order notification (#3114) * Adds a background task to notify users when a PurchaseOrder becomes overdue * Schedule the overdue purchaseorder check to occur daily * Allow notifications to be sent to "Owner" instances - Extract user information from the Owner instance * add unit test to ensure notifications are sent for overdue purchase orders * Adds notification for overdue sales orders * Clean up notification display panel - Simplify rendering - Order "newest at top" - Element alignment tweaks * style fixes * More style fixes * Tweak notification padding * Fix import order * Adds task to notify user of overdue build orders * Adds unit tests for build order notifications * Refactor subject line for emails: - Use the configured instance title as a prefix for the subject line * Add email template for overdue build orders * Fix unit tests to accommodate new default value * Logic error fix --- InvenTree/InvenTree/apps.py | 19 +++ InvenTree/build/tasks.py | 65 ++++++++- InvenTree/build/test_build.py | 30 +++- InvenTree/common/api.py | 1 + InvenTree/common/models.py | 2 +- InvenTree/common/notifications.py | 30 +++- InvenTree/common/tests.py | 4 +- InvenTree/order/tasks.py | 136 ++++++++++++++++++ InvenTree/order/test_sales_order.py | 27 +++- InvenTree/order/tests.py | 42 +++++- InvenTree/part/tasks.py | 2 +- InvenTree/part/test_part.py | 2 +- .../builtin/integration/core_notifications.py | 11 +- .../templates/email/overdue_build_order.html | 24 ++++ .../email/overdue_purchase_order.html | 24 ++++ .../templates/email/overdue_sales_order.html | 24 ++++ .../templates/js/translated/notification.js | 18 +-- 17 files changed, 439 insertions(+), 22 deletions(-) create mode 100644 InvenTree/order/tasks.py create mode 100644 InvenTree/templates/email/overdue_build_order.html create mode 100644 InvenTree/templates/email/overdue_purchase_order.html create mode 100644 InvenTree/templates/email/overdue_sales_order.html diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 764f1748a6..3ec084a2d1 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -57,6 +57,7 @@ class InvenTreeConfig(AppConfig): try: from django_q.models import Schedule except AppRegistryNotReady: # pragma: no cover + logger.warning("Cannot start background tasks - app registry not ready") return logger.info("Starting background tasks...") @@ -98,6 +99,24 @@ class InvenTreeConfig(AppConfig): schedule_type=Schedule.DAILY, ) + # Check for overdue purchase orders + InvenTree.tasks.schedule_task( + 'order.tasks.check_overdue_purchase_orders', + schedule_type=Schedule.DAILY + ) + + # Check for overdue sales orders + InvenTree.tasks.schedule_task( + 'order.tasks.check_overdue_sales_orders', + schedule_type=Schedule.DAILY, + ) + + # Check for overdue build orders + InvenTree.tasks.schedule_task( + 'build.tasks.check_overdue_build_orders', + schedule_type=Schedule.DAILY + ) + def update_exchange_rates(self): # pragma: no cover """Update exchange rates each time the server is started. diff --git a/InvenTree/build/tasks.py b/InvenTree/build/tasks.py index ef32fc73ee..fcda1f8bff 100644 --- a/InvenTree/build/tasks.py +++ b/InvenTree/build/tasks.py @@ -1,5 +1,6 @@ """Background task definitions for the BuildOrder app""" +from datetime import datetime, timedelta from decimal import Decimal import logging @@ -8,9 +9,12 @@ from django.template.loader import render_to_string from allauth.account.models import EmailAddress +from plugin.events import trigger_event +import common.notifications import build.models import InvenTree.helpers import InvenTree.tasks +from InvenTree.status_codes import BuildStatus from InvenTree.ready import isImportingData import part.models as part_models @@ -93,8 +97,67 @@ def check_build_stock(build: build.models.Build): # Render the HTML message html_message = render_to_string('email/build_order_required_stock.html', context) - subject = "[InvenTree] " + _("Stock required for build order") + subject = _("Stock required for build order") recipients = emails.values_list('email', flat=True) InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message) + + +def notify_overdue_build_order(bo: build.models.Build): + """Notify appropriate users that a Build has just become 'overdue'""" + + targets = [] + + if bo.issued_by: + targets.append(bo.issued_by) + + if bo.responsible: + targets.append(bo.responsible) + + name = _('Overdue Build Order') + + context = { + 'order': bo, + 'name': name, + 'message': _(f"Build order {bo} is now overdue"), + 'link': InvenTree.helpers.construct_absolute_url( + bo.get_absolute_url(), + ), + 'template': { + 'html': 'email/overdue_build_order.html', + 'subject': name, + } + } + + event_name = 'build.overdue_build_order' + + # Send a notification to the appropriate users + common.notifications.trigger_notification( + bo, + event_name, + targets=targets, + context=context + ) + + # Register a matching event to the plugin system + trigger_event(event_name, build_order=bo.pk) + + +def check_overdue_build_orders(): + """Check if any outstanding BuildOrders have just become overdue + + - This check is performed daily + - Look at the 'target_date' of any outstanding BuildOrder objects + - If the 'target_date' expired *yesterday* then the order is just out of date + """ + + yesterday = datetime.now().date() - timedelta(days=1) + + overdue_orders = build.models.Build.objects.filter( + target_date=yesterday, + status__in=BuildStatus.ACTIVE_CODES + ) + + for bo in overdue_orders: + notify_overdue_build_order(bo) diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index e17103ae36..cd411df6b1 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -1,11 +1,16 @@ """Unit tests for the 'build' models""" +from datetime import datetime, timedelta + from django.test import TestCase +from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from InvenTree import status_codes as status +import common.models +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 @@ -14,6 +19,10 @@ from stock.models import StockItem class BuildTestBase(TestCase): """Run some tests to ensure that the Build model is working properly.""" + fixtures = [ + 'users', + ] + def setUp(self): """Initialize data to use for these tests. @@ -84,7 +93,8 @@ class BuildTestBase(TestCase): reference=ref, title="This is a build", part=self.assembly, - quantity=10 + quantity=10, + issued_by=get_user_model().objects.get(pk=1), ) # Create some build output (StockItem) objects @@ -450,8 +460,6 @@ class AutoAllocationTests(BuildTestBase): substitutes=True, ) - # self.assertTrue(self.build.are_untracked_parts_allocated()) - # self.assertEqual(self.build.allocated_stock.count(), 8) self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0) @@ -471,3 +479,19 @@ 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/api.py b/InvenTree/common/api.py index 0323778129..afc0b1272b 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -274,6 +274,7 @@ class NotificationList(generics.ListAPIView): 'category', 'name', 'read', + 'creation', ] search_fields = [ diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index b48e5ff1da..35693ee56e 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -710,7 +710,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'INVENTREE_INSTANCE': { 'name': _('Server Instance Name'), - 'default': 'InvenTree server', + 'default': 'InvenTree', 'description': _('String descriptor for the server instance'), }, diff --git a/InvenTree/common/notifications.py b/InvenTree/common/notifications.py index 4003276763..13d0450043 100644 --- a/InvenTree/common/notifications.py +++ b/InvenTree/common/notifications.py @@ -3,11 +3,15 @@ import logging from datetime import timedelta +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group + 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 +from users.models import Owner logger = logging.getLogger('inventree') @@ -266,7 +270,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): if isImportingData(): return - # Resolve objekt reference + # Resolve object reference obj_ref_value = getattr(obj, obj_ref) # Try with some defaults @@ -285,11 +289,33 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): return logger.info(f"Gathering users for notification '{category}'") + # Collect possible targets if not targets: targets = target_fnc(*target_args, **target_kwargs) + # Convert list of targets to a list of users + # (targets may include 'owner' or 'group' classes) + target_users = set() + if targets: + for target in targets: + # User instance is provided + if isinstance(target, get_user_model()): + 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) + # 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) + # Unhandled type + else: + logger.error(f"Unknown target passed to trigger_notification method: {target}") + + if target_users: logger.info(f"Sending notification '{category}' for '{str(obj)}'") # Collect possible methods @@ -301,7 +327,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): for method in delivery_methods: logger.info(f"Triggering notification method '{method.METHOD_NAME}'") try: - deliver_notification(method, obj, category, targets, context) + deliver_notification(method, obj, category, target_users, context) except NotImplementedError as error: # Allow any single notification method to fail, without failing the others logger.error(error) diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index b5e4ee330c..f0f06a3e7c 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -78,7 +78,7 @@ class SettingsTest(InvenTreeTestCase): # check as_int self.assertEqual(stale_days.as_int(), 0) - self.assertEqual(instance_obj.as_int(), 'InvenTree server') # not an int -> return default + self.assertEqual(instance_obj.as_int(), 'InvenTree') # not an int -> return default # check as_bool self.assertEqual(report_test_obj.as_bool(), True) @@ -258,7 +258,7 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase): # Access via the API, and the default value should be received response = self.get(url, expected_code=200) - self.assertEqual(response.data['value'], 'InvenTree server') + self.assertEqual(response.data['value'], 'InvenTree') # Now, the object should have been created in the DB self.patch( diff --git a/InvenTree/order/tasks.py b/InvenTree/order/tasks.py new file mode 100644 index 0000000000..103fc3cc46 --- /dev/null +++ b/InvenTree/order/tasks.py @@ -0,0 +1,136 @@ +"""Background tasks for the 'order' app""" + +from datetime import datetime, timedelta + +from django.utils.translation import gettext_lazy as _ + +import common.notifications +import InvenTree.helpers +import InvenTree.tasks +import order.models +from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus +from plugin.events import trigger_event + + +def notify_overdue_purchase_order(po: order.models.PurchaseOrder): + """Notify users that a PurchaseOrder has just become 'overdue'""" + + targets = [] + + if po.created_by: + targets.append(po.created_by) + + if po.responsible: + targets.append(po.responsible) + + name = _('Overdue Purchase Order') + + context = { + 'order': po, + 'name': name, + 'message': _(f'Purchase order {po} is now overdue'), + 'link': InvenTree.helpers.construct_absolute_url( + po.get_absolute_url(), + ), + 'template': { + 'html': 'email/overdue_purchase_order.html', + 'subject': name, + } + } + + event_name = 'order.overdue_purchase_order' + + # Send a notification to the appropriate users + common.notifications.trigger_notification( + po, + event_name, + targets=targets, + context=context, + ) + + # Register a matching event to the plugin system + trigger_event( + event_name, + purchase_order=po.pk, + ) + + +def check_overdue_purchase_orders(): + """Check if any outstanding PurchaseOrders have just become overdue: + + - This check is performed daily + - Look at the 'target_date' of any outstanding PurchaseOrder objects + - If the 'target_date' expired *yesterday* then the order is just out of date + """ + + yesterday = datetime.now().date() - timedelta(days=1) + + overdue_orders = order.models.PurchaseOrder.objects.filter( + target_date=yesterday, + status__in=PurchaseOrderStatus.OPEN + ) + + for po in overdue_orders: + notify_overdue_purchase_order(po) + + +def notify_overdue_sales_order(so: order.models.SalesOrder): + """Notify appropriate users that a SalesOrder has just become 'overdue'""" + + targets = [] + + if so.created_by: + targets.append(so.created_by) + + if so.responsible: + targets.append(so.responsible) + + name = _('Overdue Sales Order') + + context = { + 'order': so, + 'name': name, + 'message': _(f"Sales order {so} is now overdue"), + 'link': InvenTree.helpers.construct_absolute_url( + so.get_absolute_url(), + ), + 'template': { + 'html': 'email/overdue_sales_order.html', + 'subject': name, + } + } + + event_name = 'order.overdue_sales_order' + + # Send a notification to the appropriate users + common.notifications.trigger_notification( + so, + event_name, + targets=targets, + context=context, + ) + + # Register a matching event to the plugin system + trigger_event( + event_name, + sales_order=so.pk, + ) + + +def check_overdue_sales_orders(): + """Check if any outstanding SalesOrders have just become overdue + + - This check is performed daily + - Look at the 'target_date' of any outstanding SalesOrder objects + - If the 'target_date' expired *yesterday* then the order is just out of date + """ + + yesterday = datetime.now().date() - timedelta(days=1) + + overdue_orders = order.models.SalesOrder.objects.filter( + target_date=yesterday, + status__in=SalesOrderStatus.OPEN + ) + + for po in overdue_orders: + notify_overdue_sales_order(po) diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index aeb11f72bc..b0d570cef2 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -2,21 +2,29 @@ from datetime import datetime, timedelta +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.core.exceptions import ValidationError from django.test import TestCase -from common.models import InvenTreeSetting +import order.tasks +from common.models import InvenTreeSetting, NotificationMessage from company.models import Company from InvenTree import status_codes as status from order.models import (SalesOrder, SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment) from part.models import Part from stock.models import StockItem +from users.models import Owner class SalesOrderTest(TestCase): """Run tests to ensure that the SalesOrder model is working correctly.""" + fixtures = [ + 'users', + ] + def setUp(self): """Initial setup for this set of unit tests""" # Create a Company to ship the goods to @@ -235,3 +243,20 @@ class SalesOrderTest(TestCase): # Shipment should have default reference of '1' self.assertEqual('1', order_2.pending_shipments()[0].reference) + + def test_overdue_notification(self): + """Test overdue sales order notification""" + + self.order.created_by = get_user_model().objects.get(pk=3) + self.order.responsible = Owner.create(obj=Group.objects.get(pk=2)) + self.order.target_date = datetime.now().date() - timedelta(days=1) + self.order.save() + + # Check for overdue sales orders + order.tasks.check_overdue_sales_orders() + + messages = NotificationMessage.objects.filter( + category='order.overdue_sales_order', + ) + + self.assertEqual(len(messages), 2) diff --git a/InvenTree/order/tests.py b/InvenTree/order/tests.py index b8fd945518..9b6077a14e 100644 --- a/InvenTree/order/tests.py +++ b/InvenTree/order/tests.py @@ -3,12 +3,17 @@ from datetime import datetime, timedelta import django.core.exceptions as django_exceptions +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.test import TestCase +import common.models +import order.tasks from company.models import SupplierPart from InvenTree.status_codes import PurchaseOrderStatus from part.models import Part from stock.models import StockLocation +from users.models import Owner from .models import PurchaseOrder, PurchaseOrderLineItem @@ -24,7 +29,8 @@ class OrderTest(TestCase): 'part', 'location', 'stock', - 'order' + 'order', + 'users', ] def test_basics(self): @@ -197,3 +203,37 @@ class OrderTest(TestCase): order.receive_line_item(line, loc, line.quantity, user=None) self.assertEqual(order.status, PurchaseOrderStatus.COMPLETE) + + def test_overdue_notification(self): + """Test overdue purchase order notification + + Ensure that a notification is sent when a PurchaseOrder becomes overdue + """ + po = PurchaseOrder.objects.get(pk=1) + + # Created by 'sam' + po.created_by = get_user_model().objects.get(pk=4) + + # Responsible : 'Engineers' group + responsible = Owner.create(obj=Group.objects.get(pk=2)) + po.responsible = responsible + + # Target date = yesterday + po.target_date = datetime.now().date() - timedelta(days=1) + po.save() + + # Check for overdue purchase orders + order.tasks.check_overdue_purchase_orders() + + for user_id in [2, 3, 4]: + messages = common.models.NotificationMessage.objects.filter( + category='order.overdue_purchase_order', + user__id=user_id, + ) + + self.assertTrue(messages.exists()) + + msg = messages.first() + + self.assertEqual(msg.target_object_id, 1) + self.assertEqual(msg.name, 'Overdue Purchase Order') diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py index db1443042a..fc932239a9 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -27,7 +27,7 @@ def notify_low_stock(part: part.models.Part): 'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()), 'template': { 'html': 'email/low_stock_notification.html', - 'subject': "[InvenTree] " + name, + 'subject': name, }, } diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 97e690c535..d11cc5d142 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -44,7 +44,7 @@ class TemplateTagTest(InvenTreeTestCase): def test_inventree_instance_name(self): """Test the 'instance name' setting""" - self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree server') + self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree') def test_inventree_base_url(self): """Test that the base URL tag returns correctly""" diff --git a/InvenTree/plugin/builtin/integration/core_notifications.py b/InvenTree/plugin/builtin/integration/core_notifications.py index d5c3cffd51..6c0cee5f02 100644 --- a/InvenTree/plugin/builtin/integration/core_notifications.py +++ b/InvenTree/plugin/builtin/integration/core_notifications.py @@ -5,6 +5,7 @@ from django.utils.translation import ugettext_lazy as _ from allauth.account.models import EmailAddress +import common.models import InvenTree.tasks from plugin import InvenTreePlugin from plugin.mixins import BulkNotificationMethod, SettingsMixin @@ -74,6 +75,14 @@ class CoreNotificationsPlugin(SettingsMixin, InvenTreePlugin): html_message = render_to_string(self.context['template']['html'], self.context) targets = self.targets.values_list('email', flat=True) - InvenTree.tasks.send_email(self.context['template']['subject'], '', targets, html_message=html_message) + # Prefix the 'instance title' to the email subject + instance_title = common.models.InvenTreeSetting.get_setting('INVENTREE_INSTANCE') + + subject = self.context['template'].get('subject', '') + + if instance_title: + subject = f'[{instance_title}] {subject}' + + InvenTree.tasks.send_email(subject, '', targets, html_message=html_message) return True diff --git a/InvenTree/templates/email/overdue_build_order.html b/InvenTree/templates/email/overdue_build_order.html new file mode 100644 index 0000000000..f5412f4fd3 --- /dev/null +++ b/InvenTree/templates/email/overdue_build_order.html @@ -0,0 +1,24 @@ +{% 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 %} + +{% block body %} + + {% trans "Build Order" %} + {% trans "Part" %} + + + + {{ order }} + {{ order.part.full_name }} + + +{% endblock body %} diff --git a/InvenTree/templates/email/overdue_purchase_order.html b/InvenTree/templates/email/overdue_purchase_order.html new file mode 100644 index 0000000000..bca6c8fb49 --- /dev/null +++ b/InvenTree/templates/email/overdue_purchase_order.html @@ -0,0 +1,24 @@ +{% 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 %} + +{% block body %} + + {% trans "Purchase Order" %} + {% trans "Supplier" %} + + + + {{ order }} + {{ order.supplier }} + + +{% endblock body %} diff --git a/InvenTree/templates/email/overdue_sales_order.html b/InvenTree/templates/email/overdue_sales_order.html new file mode 100644 index 0000000000..33c3cc24cc --- /dev/null +++ b/InvenTree/templates/email/overdue_sales_order.html @@ -0,0 +1,24 @@ +{% 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 %} + +{% block body %} + + {% trans "Sales Order" %} + {% trans "Customer" %} + + + + {{ order }} + {{ order.customer }} + + +{% endblock body %} diff --git a/InvenTree/templates/js/translated/notification.js b/InvenTree/templates/js/translated/notification.js index f88c8fbcbb..7c48eef695 100644 --- a/InvenTree/templates/js/translated/notification.js +++ b/InvenTree/templates/js/translated/notification.js @@ -238,7 +238,7 @@ function getReadEditButton(pk, state, small=false) { } var style = (small) ? 'btn-sm ' : ''; - return ``; + return ``; } /** @@ -252,6 +252,7 @@ function openNotificationPanel() { '/api/notifications/', { read: false, + ordering: '-creation', }, { success: function(response) { @@ -261,20 +262,21 @@ function openNotificationPanel() { // build up items response.forEach(function(item, index) { html += '
  • '; - // d-flex justify-content-between align-items-start - html += '
    '; - html += `${item.category}${item.name}`; - html += '
    '; + html += `
    `; + html += `${item.name}`; + html += getReadEditButton(item.pk, item.read, true); + html += `
    `; + if (item.target) { - var link_text = `${item.target.model}: ${item.target.name}`; + var link_text = `${item.target.name}`; if (item.target.link) { link_text = `${link_text}`; } html += link_text; } + html += '
    '; - html += `${item.age_human}`; - html += getReadEditButton(item.pk, item.read, true); + html += `${item.age_human}`; html += '
  • '; });