mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
00b75d792e
commit
6b038d85b6
@ -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,
|
||||
)
|
||||
|
@ -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."""
|
||||
|
@ -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')
|
||||
|
@ -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}")
|
||||
|
@ -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):
|
||||
|
@ -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())
|
||||
|
@ -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())
|
||||
|
11
InvenTree/templates/email/new_order_assigned.html
Normal file
11
InvenTree/templates/email/new_order_assigned.html
Normal 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 %}
|
Loading…
Reference in New Issue
Block a user