diff --git a/src/backend/InvenTree/InvenTree/helpers_model.py b/src/backend/InvenTree/InvenTree/helpers_model.py index b00034618d..23a7c71f1c 100644 --- a/src/backend/InvenTree/InvenTree/helpers_model.py +++ b/src/backend/InvenTree/InvenTree/helpers_model.py @@ -265,6 +265,7 @@ def notify_responsible( sender, content: NotificationBody = InvenTreeNotificationBodies.NewOrder, exclude=None, + extra_users: Optional[list] = None, ): """Notify all responsible parties of a change in an instance. @@ -276,15 +277,19 @@ def notify_responsible( sender: Sender model reference content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder. exclude (User, optional): User instance that should be excluded. Defaults to None. + extra_users (list, optional): List of extra users to notify. Defaults to None. """ import InvenTree.ready if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations(): return - notify_users( - [instance.responsible], instance, sender, content=content, exclude=exclude - ) + users = [instance.responsible] + + if extra_users: + users.extend(extra_users) + + notify_users(users, instance, sender, content=content, exclude=exclude) def notify_users( diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 52bcd51bba..37e3e4faa7 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -628,8 +628,13 @@ class Build( self.allocated_stock.delete() @transaction.atomic - def complete_build(self, user, trim_allocated_stock=False): - """Mark this build as complete.""" + def complete_build(self, user: User, trim_allocated_stock: bool = False): + """Mark this build as complete. + + Arguments: + user: The user who is completing the build + trim_allocated_stock: If True, trim any allocated stock + """ return self.handle_transition( self.status, BuildStatus.COMPLETE.value, @@ -681,6 +686,9 @@ class Build( # Notify users that this build has been completed targets = [self.issued_by, self.responsible] + # Also inform anyone subscribed to the assembly part + targets.extend(self.part.get_subscribers()) + # Notify those users interested in the parent build if self.parent: targets.append(self.parent.issued_by) @@ -817,6 +825,7 @@ class Build( Build, exclude=self.issued_by, content=InvenTreeNotificationBodies.OrderCanceled, + extra_users=self.part.get_subscribers(), ) trigger_event(BuildEvents.CANCELLED, id=self.pk) @@ -1470,7 +1479,10 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs): # Notify the responsible users that the build order has been created InvenTree.helpers_model.notify_responsible( - instance, sender, exclude=instance.issued_by + instance, + sender, + exclude=instance.issued_by, + extra_users=instance.part.get_subscribers(), ) else: diff --git a/src/backend/InvenTree/build/tasks.py b/src/backend/InvenTree/build/tasks.py index 6ee55bf094..38a2198cc2 100644 --- a/src/backend/InvenTree/build/tasks.py +++ b/src/backend/InvenTree/build/tasks.py @@ -262,6 +262,8 @@ def notify_overdue_build_order(bo: build_models.Build): if bo.responsible: targets.append(bo.responsible) + targets.extend(bo.part.get_subscribers()) + name = _('Overdue Build Order') context = { diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 92bcd2e7e3..f726cd1619 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -575,6 +575,21 @@ class PurchaseOrder(TotalPriceMixin, Order): """Return the associated barcode model type code for this model.""" return 'PO' + def subscribed_users(self) -> list[User]: + """Return a list of users subscribed to this PurchaseOrder. + + By this, we mean users to are interested in any of the parts associated with this order. + """ + subscribed_users = set() + + for line in self.lines.all(): + if line.part and line.part.part: + # Add the part to the list of subscribed users + for user in line.part.part.get_subscribers(): + subscribed_users.add(user) + + return list(subscribed_users) + def __str__(self): """Render a string representation of this PurchaseOrder.""" return f'{self.reference} - {self.supplier.name if self.supplier else _("deleted")}' @@ -747,6 +762,7 @@ class PurchaseOrder(TotalPriceMixin, Order): PurchaseOrder, exclude=self.created_by, content=InvenTreeNotificationBodies.NewOrder, + extra_users=self.subscribed_users(), ) def _action_complete(self, *args, **kwargs): @@ -841,6 +857,7 @@ class PurchaseOrder(TotalPriceMixin, Order): PurchaseOrder, exclude=self.created_by, content=InvenTreeNotificationBodies.OrderCanceled, + extra_users=self.subscribed_users(), ) @property @@ -1045,6 +1062,7 @@ class PurchaseOrder(TotalPriceMixin, Order): PurchaseOrder, exclude=user, content=InvenTreeNotificationBodies.ItemsReceived, + extra_users=line.part.part.get_subscribers(), ) @@ -1096,6 +1114,21 @@ class SalesOrder(TotalPriceMixin, Order): """Return the associated barcode model type code for this model.""" return 'SO' + def subscribed_users(self) -> list[User]: + """Return a list of users subscribed to this SalesOrder. + + By this, we mean users to are interested in any of the parts associated with this order. + """ + subscribed_users = set() + + for line in self.lines.all(): + if line.part: + # Add the part to the list of subscribed users + for user in line.part.get_subscribers(): + subscribed_users.add(user) + + return list(subscribed_users) + def __str__(self): """Render a string representation of this SalesOrder.""" return f'{self.reference} - {self.customer.name if self.customer else _("deleted")}' @@ -1248,6 +1281,15 @@ class SalesOrder(TotalPriceMixin, Order): trigger_event(SalesOrderEvents.ISSUED, id=self.pk) + # Notify users that the order has been placed + notify_responsible( + self, + SalesOrder, + exclude=self.created_by, + content=InvenTreeNotificationBodies.NewOrder, + extra_users=self.subscribed_users(), + ) + @property def can_hold(self): """Return True if this order can be placed on hold.""" @@ -1325,6 +1367,7 @@ class SalesOrder(TotalPriceMixin, Order): SalesOrder, exclude=self.created_by, content=InvenTreeNotificationBodies.OrderCanceled, + extra_users=self.subscribed_users(), ) return True @@ -1466,9 +1509,6 @@ def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs # 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 OrderLineItem(InvenTree.models.InvenTreeMetadataModel): """Abstract model for an order line item. @@ -2337,6 +2377,21 @@ class ReturnOrder(TotalPriceMixin, Order): """Return the associated barcode model type code for this model.""" return 'RO' + def subscribed_users(self) -> list[User]: + """Return a list of users subscribed to this ReturnOrder. + + By this, we mean users to are interested in any of the parts associated with this order. + """ + subscribed_users = set() + + for line in self.lines.all(): + if line.item and line.item.part: + # Add the part to the list of subscribed users + for user in line.item.part.get_subscribers(): + subscribed_users.add(user) + + return list(subscribed_users) + def __str__(self): """Render a string representation of this ReturnOrder.""" return f'{self.reference} - {self.customer.name if self.customer else _("no customer")}' @@ -2439,6 +2494,7 @@ class ReturnOrder(TotalPriceMixin, Order): ReturnOrder, exclude=self.created_by, content=InvenTreeNotificationBodies.OrderCanceled, + extra_users=self.subscribed_users(), ) def _action_complete(self, *args, **kwargs): @@ -2471,6 +2527,15 @@ class ReturnOrder(TotalPriceMixin, Order): trigger_event(ReturnOrderEvents.ISSUED, id=self.pk) + # Notify users that the order has been placed + notify_responsible( + self, + ReturnOrder, + exclude=self.created_by, + content=InvenTreeNotificationBodies.NewOrder, + extra_users=self.subscribed_users(), + ) + @transaction.atomic def hold_order(self): """Attempt to tranasition to ON_HOLD status.""" @@ -2575,6 +2640,7 @@ class ReturnOrder(TotalPriceMixin, Order): ReturnOrder, exclude=user, content=InvenTreeNotificationBodies.ReturnOrderItemsReceived, + extra_users=line.item.part.get_subscribers(), ) diff --git a/src/backend/InvenTree/order/tasks.py b/src/backend/InvenTree/order/tasks.py index 467cdc6bd3..21babfe2c1 100644 --- a/src/backend/InvenTree/order/tasks.py +++ b/src/backend/InvenTree/order/tasks.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta -from django.contrib.auth.models import User +from django.contrib.auth.models import Group, User from django.db import transaction from django.db.models import F from django.utils.translation import gettext_lazy as _ @@ -14,15 +14,24 @@ import InvenTree.helpers_model import order.models from InvenTree.tasks import ScheduledTask, scheduled_task from order.events import PurchaseOrderEvents, SalesOrderEvents -from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups +from order.status_codes import ( + PurchaseOrderStatusGroups, + ReturnOrderStatusGroups, + SalesOrderStatusGroups, +) from plugin.events import trigger_event +from users.models import Owner logger = structlog.get_logger('inventree') -def notify_overdue_purchase_order(po: order.models.PurchaseOrder): - """Notify users that a PurchaseOrder has just become 'overdue'.""" - targets = [] +def notify_overdue_purchase_order(po: order.models.PurchaseOrder) -> None: + """Notify users that a PurchaseOrder has just become 'overdue'. + + Arguments: + po: The PurchaseOrder object that is overdue. + """ + targets: list[User | Group | Owner] = [] if po.created_by: targets.append(po.created_by) @@ -30,6 +39,8 @@ def notify_overdue_purchase_order(po: order.models.PurchaseOrder): if po.responsible: targets.append(po.responsible) + targets.extend(po.subscribed_users()) + name = _('Overdue Purchase Order') context = { @@ -86,9 +97,9 @@ def check_overdue_purchase_orders(): notified_orders.add(line.order.pk) -def notify_overdue_sales_order(so: order.models.SalesOrder): +def notify_overdue_sales_order(so: order.models.SalesOrder) -> None: """Notify appropriate users that a SalesOrder has just become 'overdue'.""" - targets = [] + targets: list[User, Group, Owner] = [] if so.created_by: targets.append(so.created_by) @@ -96,6 +107,8 @@ def notify_overdue_sales_order(so: order.models.SalesOrder): if so.responsible: targets.append(so.responsible) + targets.extend(so.subscribed_users()) + name = _('Overdue Sales Order') context = { @@ -149,6 +162,71 @@ def check_overdue_sales_orders(): notified_orders.add(line.order.pk) +def notify_overdue_return_order(ro: order.models.ReturnOrder) -> None: + """Notify appropriate users that a ReturnOrder has just become 'overdue'.""" + targets: list[User, Group, Owner] = [] + + if ro.created_by: + targets.append(ro.created_by) + + if ro.responsible: + targets.append(ro.responsible) + + targets.extend(ro.subscribed_users()) + + name = _('Overdue Return Order') + + context = { + 'order': ro, + 'name': name, + 'message': _(f'Return order {ro} is now overdue'), + 'link': InvenTree.helpers_model.construct_absolute_url(ro.get_absolute_url()), + 'template': {'html': 'email/overdue_return_order.html', 'subject': name}, + } + + event_name = SalesOrderEvents.OVERDUE + + # Send a notification to the appropriate users + common.notifications.trigger_notification( + ro, event_name, targets=targets, context=context + ) + + # Register a matching event to the plugin system + trigger_event(event_name, return_order=ro.pk) + + +@scheduled_task(ScheduledTask.DAILY) +def check_overdue_return_orders(): + """Check if any outstanding return orders have just become overdue. + + - This check is performed daily + - Look at the 'target_date' of any outstanding return order 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.ReturnOrder.objects.filter( + target_date=yesterday, status__in=ReturnOrderStatusGroups.OPEN + ) + + overdue_lines = order.models.ReturnOrderLineItem.objects.filter( + target_date=yesterday, + order__status__in=ReturnOrderStatusGroups.OPEN, + received_date__isnull=True, + ) + + notified_orders = set() + + for ro in overdue_orders: + notify_overdue_return_order(ro) + notified_orders.add(ro.pk) + + for line in overdue_lines: + if line.order.pk not in notified_orders: + notify_overdue_return_order(line.order) + notified_orders.add(line.order.pk) + + def complete_sales_order_shipment(shipment_id: int, user_id: int) -> None: """Complete allocations for a pending shipment against a SalesOrder. diff --git a/src/backend/InvenTree/order/test_sales_order.py b/src/backend/InvenTree/order/test_sales_order.py index b394147971..1fb0242819 100644 --- a/src/backend/InvenTree/order/test_sales_order.py +++ b/src/backend/InvenTree/order/test_sales_order.py @@ -333,18 +333,20 @@ class SalesOrderTest(TestCase): self.assertEqual(len(messages), 1) def test_new_so_notification(self): - """Test that a notification is sent when a new SalesOrder is created. + """Test that a notification is sent when a new SalesOrder is issued. - The responsible user should receive a notification - The creating user should *not* receive a notification """ - SalesOrder.objects.create( + so = 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)), ) + so.issue_order() + messages = NotificationMessage.objects.filter(category='order.new_salesorder') # A notification should have been generated for user 4 (who is a member of group 3) diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 5b8e45da5c..8208ba7081 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -286,8 +286,15 @@ class PartCategory(InvenTree.models.InvenTreeTree): return prefetch.filter(category=self.id) - def get_subscribers(self, include_parents=True): - """Return a list of users who subscribe to this PartCategory.""" + def get_subscribers(self, include_parents: bool = True) -> list[User]: + """Return a list of users who subscribe to this PartCategory. + + Arguments: + include_parents (bool): If True, include users who subscribe to parent categories. + + Returns: + list[User]: List of users who subscribe to this category. + """ subscribers = set() if include_parents: @@ -1429,9 +1436,18 @@ class Part( """ return self.total_stock - self.allocation_count() + self.on_order - def get_subscribers(self, include_variants=True, include_categories=True): + def get_subscribers( + self, include_variants: bool = True, include_categories: bool = True + ) -> list[User]: """Return a list of users who are 'subscribed' to this part. + Arguments: + include_variants: If True, include users who are subscribed to a variant part + include_categories: If True, include users who are subscribed to the category + + Returns: + list[User]: A list of users who are subscribed to this part + A user may 'subscribe' to this part in the following ways: a) Subscribing to the part instance directly diff --git a/src/backend/InvenTree/templates/email/overdue_return_order.html b/src/backend/InvenTree/templates/email/overdue_return_order.html new file mode 100644 index 0000000000..a543f89686 --- /dev/null +++ b/src/backend/InvenTree/templates/email/overdue_return_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 "Return Order" %} + {% trans "Customer" %} + + + + {{ order }} + {{ order.customer }} + + +{% endblock body %}