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 <code@mjmair.com>
This commit is contained in:
Oliver 2022-06-07 08:11:11 +10:00 committed by GitHub
parent 00b75d792e
commit 6b038d85b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 232 additions and 37 deletions

View File

@ -18,6 +18,8 @@ from PIL import Image
import InvenTree.version import InvenTree.version
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from common.notifications import (InvenTreeNotificationBodies,
NotificationBody, trigger_notification)
from common.settings import currency_code_default from common.settings import currency_code_default
from .api_tester import UserMixin from .api_tester import UserMixin
@ -719,3 +721,46 @@ def inheritors(cls):
class InvenTreeTestCase(UserMixin, TestCase): class InvenTreeTestCase(UserMixin, TestCase):
"""Testcase with user setup buildin.""" """Testcase with user setup buildin."""
pass 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,
)

View File

@ -24,7 +24,7 @@ from mptt.exceptions import InvalidMove
from rest_framework import serializers from rest_framework import serializers
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode 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.models import InvenTreeAttachment, ReferenceIndexingMixin
from InvenTree.validators import validate_build_order_reference 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 # Run checks on required parts
InvenTree.tasks.offload_task(build_tasks.check_build_stock, instance) 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): class BuildOrderAttachment(InvenTreeAttachment):
"""Model for storing file attachments against a BuildOrder object.""" """Model for storing file attachments against a BuildOrder object."""

View File

@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from django.test import TestCase from django.test import TestCase
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from InvenTree import status_codes as status 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 build.models import Build, BuildItem, get_next_build_number
from part.models import Part, BomItem, BomItemSubstitute from part.models import Part, BomItem, BomItemSubstitute
from stock.models import StockItem from stock.models import StockItem
from users.models import Owner
class BuildTestBase(TestCase): class BuildTestBase(TestCase):
@ -382,6 +384,46 @@ class BuildTest(BuildTestBase):
for output in outputs: for output in outputs:
self.assertFalse(output.is_building) 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): class AutoAllocationTests(BuildTestBase):
"""Tests for auto allocating stock against a build order""" """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_1), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 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')

View File

@ -1,13 +1,15 @@
"""Base classes and functions for notifications.""" """Base classes and functions for notifications."""
import logging import logging
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group 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 common.models import NotificationEntry, NotificationMessage
from InvenTree.helpers import inheritors
from InvenTree.ready import isImportingData from InvenTree.ready import isImportingData
from plugin import registry from plugin import registry
from plugin.models import NotificationUserSetting 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. selected_classes (class, optional): References to the classes that should be registered. Defaults to None.
""" """
logger.info('collecting notification methods') 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 # for testing selective loading is made available
if selected_classes: if selected_classes:
@ -257,12 +259,51 @@ class UIMessageNotification(SingleNotificationMethod):
return True 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): def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
"""Send out a notification.""" """Send out a notification."""
targets = kwargs.get('targets', None) targets = kwargs.get('targets', None)
target_fnc = kwargs.get('target_fnc', None) target_fnc = kwargs.get('target_fnc', None)
target_args = kwargs.get('target_args', []) target_args = kwargs.get('target_args', [])
target_kwargs = kwargs.get('target_kwargs', {}) target_kwargs = kwargs.get('target_kwargs', {})
target_exclude = kwargs.get('target_exclude', None)
context = kwargs.get('context', {}) context = kwargs.get('context', {})
delivery_methods = kwargs.get('delivery_methods', None) 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}'") logger.info(f"Gathering users for notification '{category}'")
if target_exclude is None:
target_exclude = set()
# Collect possible targets # Collect possible targets
if not targets: if not targets:
targets = target_fnc(*target_args, **target_kwargs) 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: for target in targets:
# User instance is provided # User instance is provided
if isinstance(target, get_user_model()): if isinstance(target, get_user_model()):
target_users.add(target) if target not in target_exclude:
target_users.add(target)
# Group instance is provided # Group instance is provided
elif isinstance(target, Group): elif isinstance(target, Group):
for user in get_user_model().objects.filter(groups__name=target.name): 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) # Owner instance (either 'user' or 'group' is provided)
elif isinstance(target, Owner): elif isinstance(target, Owner):
for owner in target.get_related_owners(include_group=False): 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 # Unhandled type
else: else:
logger.error(f"Unknown target passed to trigger_notification method: {target}") logger.error(f"Unknown target passed to trigger_notification method: {target}")

View File

@ -30,7 +30,8 @@ import InvenTree.ready
from common.settings import currency_code_default from common.settings import currency_code_default
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField 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.models import InvenTreeAttachment, ReferenceIndexingMixin
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus, from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
StockHistoryCode, StockStatus) StockHistoryCode, StockStatus)
@ -574,6 +575,17 @@ class PurchaseOrder(Order):
self.complete_order() # This will save the model 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): class SalesOrder(Order):
"""A SalesOrder represents a list of goods shipped outwards to a customer. """A SalesOrder represents a list of goods shipped outwards to a customer.
@ -839,29 +851,29 @@ class SalesOrder(Order):
return self.pending_shipments().count() 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): 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 - If the SALESORDER_DEFAULT_SHIPMENT setting is enabled, create a default shipment
- Ignore if the database is not ready for access - Ignore if the database is not ready for access
- Ignore if data import is active - Ignore if data import is active
""" """
if not InvenTree.ready.canAppAccessDatabase(allow_test=True) or InvenTree.ready.isImportingData():
if not InvenTree.ready.canAppAccessDatabase(allow_test=True):
return return
if InvenTree.ready.isImportingData(): if created:
return
if created and getSetting('SALESORDER_DEFAULT_SHIPMENT'):
# A new SalesOrder has just been created # A new SalesOrder has just been created
# Create default shipment if getSetting('SALESORDER_DEFAULT_SHIPMENT'):
SalesOrderShipment.objects.create( # Create default shipment
order=instance, SalesOrderShipment.objects.create(
reference='1', 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): class PurchaseOrderAttachment(InvenTreeAttachment):

View File

@ -260,3 +260,27 @@ class SalesOrderTest(TestCase):
) )
self.assertEqual(len(messages), 2) 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())

View File

@ -9,7 +9,7 @@ from django.test import TestCase
import common.models import common.models
import order.tasks import order.tasks
from company.models import SupplierPart from company.models import Company, SupplierPart
from InvenTree.status_codes import PurchaseOrderStatus from InvenTree.status_codes import PurchaseOrderStatus
from part.models import Part from part.models import Part
from stock.models import StockLocation from stock.models import StockLocation
@ -237,3 +237,29 @@ class OrderTest(TestCase):
self.assertEqual(msg.target_object_id, 1) self.assertEqual(msg.target_object_id, 1)
self.assertEqual(msg.name, 'Overdue Purchase Order') 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())

View File

@ -0,0 +1,11 @@
{% extends "email/email.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block title %}
{{ message }}
{% if link %}
<p>{% trans "Click on the following link to view this order" %}: <a href="{{ link }}">{{ link }}</a></p>
{% endif %}
{% endblock title %}