2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

Notify subscribed users (#9446)

* Update docstrings

* Update 'notify_responsible' function

* Update build notifications

* Update notifications for external orders

* Update more notifications

* Notify for overdue return orders

* Fix typos

* Fixes
This commit is contained in:
Oliver 2025-04-03 13:42:21 +11:00 committed by GitHub
parent 67bdf3162a
commit 9c419e6ac1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 226 additions and 21 deletions

View File

@ -265,6 +265,7 @@ def notify_responsible(
sender, sender,
content: NotificationBody = InvenTreeNotificationBodies.NewOrder, content: NotificationBody = InvenTreeNotificationBodies.NewOrder,
exclude=None, exclude=None,
extra_users: Optional[list] = None,
): ):
"""Notify all responsible parties of a change in an instance. """Notify all responsible parties of a change in an instance.
@ -276,15 +277,19 @@ def notify_responsible(
sender: Sender model reference sender: Sender model reference
content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder. content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder.
exclude (User, optional): User instance that should be excluded. Defaults to None. 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 import InvenTree.ready
if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations(): if InvenTree.ready.isImportingData() or InvenTree.ready.isRunningMigrations():
return return
notify_users( users = [instance.responsible]
[instance.responsible], instance, sender, content=content, exclude=exclude
) if extra_users:
users.extend(extra_users)
notify_users(users, instance, sender, content=content, exclude=exclude)
def notify_users( def notify_users(

View File

@ -628,8 +628,13 @@ class Build(
self.allocated_stock.delete() self.allocated_stock.delete()
@transaction.atomic @transaction.atomic
def complete_build(self, user, trim_allocated_stock=False): def complete_build(self, user: User, trim_allocated_stock: bool = False):
"""Mark this build as complete.""" """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( return self.handle_transition(
self.status, self.status,
BuildStatus.COMPLETE.value, BuildStatus.COMPLETE.value,
@ -681,6 +686,9 @@ class Build(
# Notify users that this build has been completed # Notify users that this build has been completed
targets = [self.issued_by, self.responsible] 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 # Notify those users interested in the parent build
if self.parent: if self.parent:
targets.append(self.parent.issued_by) targets.append(self.parent.issued_by)
@ -817,6 +825,7 @@ class Build(
Build, Build,
exclude=self.issued_by, exclude=self.issued_by,
content=InvenTreeNotificationBodies.OrderCanceled, content=InvenTreeNotificationBodies.OrderCanceled,
extra_users=self.part.get_subscribers(),
) )
trigger_event(BuildEvents.CANCELLED, id=self.pk) 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 # Notify the responsible users that the build order has been created
InvenTree.helpers_model.notify_responsible( InvenTree.helpers_model.notify_responsible(
instance, sender, exclude=instance.issued_by instance,
sender,
exclude=instance.issued_by,
extra_users=instance.part.get_subscribers(),
) )
else: else:

View File

@ -262,6 +262,8 @@ def notify_overdue_build_order(bo: build_models.Build):
if bo.responsible: if bo.responsible:
targets.append(bo.responsible) targets.append(bo.responsible)
targets.extend(bo.part.get_subscribers())
name = _('Overdue Build Order') name = _('Overdue Build Order')
context = { context = {

View File

@ -575,6 +575,21 @@ class PurchaseOrder(TotalPriceMixin, Order):
"""Return the associated barcode model type code for this model.""" """Return the associated barcode model type code for this model."""
return 'PO' 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): def __str__(self):
"""Render a string representation of this PurchaseOrder.""" """Render a string representation of this PurchaseOrder."""
return f'{self.reference} - {self.supplier.name if self.supplier else _("deleted")}' return f'{self.reference} - {self.supplier.name if self.supplier else _("deleted")}'
@ -747,6 +762,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
PurchaseOrder, PurchaseOrder,
exclude=self.created_by, exclude=self.created_by,
content=InvenTreeNotificationBodies.NewOrder, content=InvenTreeNotificationBodies.NewOrder,
extra_users=self.subscribed_users(),
) )
def _action_complete(self, *args, **kwargs): def _action_complete(self, *args, **kwargs):
@ -841,6 +857,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
PurchaseOrder, PurchaseOrder,
exclude=self.created_by, exclude=self.created_by,
content=InvenTreeNotificationBodies.OrderCanceled, content=InvenTreeNotificationBodies.OrderCanceled,
extra_users=self.subscribed_users(),
) )
@property @property
@ -1045,6 +1062,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
PurchaseOrder, PurchaseOrder,
exclude=user, exclude=user,
content=InvenTreeNotificationBodies.ItemsReceived, 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 the associated barcode model type code for this model."""
return 'SO' 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): def __str__(self):
"""Render a string representation of this SalesOrder.""" """Render a string representation of this SalesOrder."""
return f'{self.reference} - {self.customer.name if self.customer else _("deleted")}' 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) 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 @property
def can_hold(self): def can_hold(self):
"""Return True if this order can be placed on hold.""" """Return True if this order can be placed on hold."""
@ -1325,6 +1367,7 @@ class SalesOrder(TotalPriceMixin, Order):
SalesOrder, SalesOrder,
exclude=self.created_by, exclude=self.created_by,
content=InvenTreeNotificationBodies.OrderCanceled, content=InvenTreeNotificationBodies.OrderCanceled,
extra_users=self.subscribed_users(),
) )
return True return True
@ -1466,9 +1509,6 @@ def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs
# Create default shipment # Create default shipment
SalesOrderShipment.objects.create(order=instance, reference='1') 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): class OrderLineItem(InvenTree.models.InvenTreeMetadataModel):
"""Abstract model for an order line item. """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 the associated barcode model type code for this model."""
return 'RO' 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): def __str__(self):
"""Render a string representation of this ReturnOrder.""" """Render a string representation of this ReturnOrder."""
return f'{self.reference} - {self.customer.name if self.customer else _("no customer")}' return f'{self.reference} - {self.customer.name if self.customer else _("no customer")}'
@ -2439,6 +2494,7 @@ class ReturnOrder(TotalPriceMixin, Order):
ReturnOrder, ReturnOrder,
exclude=self.created_by, exclude=self.created_by,
content=InvenTreeNotificationBodies.OrderCanceled, content=InvenTreeNotificationBodies.OrderCanceled,
extra_users=self.subscribed_users(),
) )
def _action_complete(self, *args, **kwargs): def _action_complete(self, *args, **kwargs):
@ -2471,6 +2527,15 @@ class ReturnOrder(TotalPriceMixin, Order):
trigger_event(ReturnOrderEvents.ISSUED, id=self.pk) 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 @transaction.atomic
def hold_order(self): def hold_order(self):
"""Attempt to tranasition to ON_HOLD status.""" """Attempt to tranasition to ON_HOLD status."""
@ -2575,6 +2640,7 @@ class ReturnOrder(TotalPriceMixin, Order):
ReturnOrder, ReturnOrder,
exclude=user, exclude=user,
content=InvenTreeNotificationBodies.ReturnOrderItemsReceived, content=InvenTreeNotificationBodies.ReturnOrderItemsReceived,
extra_users=line.item.part.get_subscribers(),
) )

View File

@ -2,7 +2,7 @@
from datetime import datetime, timedelta 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 import transaction
from django.db.models import F from django.db.models import F
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -14,15 +14,24 @@ import InvenTree.helpers_model
import order.models import order.models
from InvenTree.tasks import ScheduledTask, scheduled_task from InvenTree.tasks import ScheduledTask, scheduled_task
from order.events import PurchaseOrderEvents, SalesOrderEvents 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 plugin.events import trigger_event
from users.models import Owner
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
def notify_overdue_purchase_order(po: order.models.PurchaseOrder): def notify_overdue_purchase_order(po: order.models.PurchaseOrder) -> None:
"""Notify users that a PurchaseOrder has just become 'overdue'.""" """Notify users that a PurchaseOrder has just become 'overdue'.
targets = []
Arguments:
po: The PurchaseOrder object that is overdue.
"""
targets: list[User | Group | Owner] = []
if po.created_by: if po.created_by:
targets.append(po.created_by) targets.append(po.created_by)
@ -30,6 +39,8 @@ def notify_overdue_purchase_order(po: order.models.PurchaseOrder):
if po.responsible: if po.responsible:
targets.append(po.responsible) targets.append(po.responsible)
targets.extend(po.subscribed_users())
name = _('Overdue Purchase Order') name = _('Overdue Purchase Order')
context = { context = {
@ -86,9 +97,9 @@ def check_overdue_purchase_orders():
notified_orders.add(line.order.pk) 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'.""" """Notify appropriate users that a SalesOrder has just become 'overdue'."""
targets = [] targets: list[User, Group, Owner] = []
if so.created_by: if so.created_by:
targets.append(so.created_by) targets.append(so.created_by)
@ -96,6 +107,8 @@ def notify_overdue_sales_order(so: order.models.SalesOrder):
if so.responsible: if so.responsible:
targets.append(so.responsible) targets.append(so.responsible)
targets.extend(so.subscribed_users())
name = _('Overdue Sales Order') name = _('Overdue Sales Order')
context = { context = {
@ -149,6 +162,71 @@ def check_overdue_sales_orders():
notified_orders.add(line.order.pk) 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: def complete_sales_order_shipment(shipment_id: int, user_id: int) -> None:
"""Complete allocations for a pending shipment against a SalesOrder. """Complete allocations for a pending shipment against a SalesOrder.

View File

@ -333,18 +333,20 @@ class SalesOrderTest(TestCase):
self.assertEqual(len(messages), 1) self.assertEqual(len(messages), 1)
def test_new_so_notification(self): 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 responsible user should receive a notification
- The creating user should *not* receive a notification - The creating user should *not* receive a notification
""" """
SalesOrder.objects.create( so = SalesOrder.objects.create(
customer=self.customer, customer=self.customer,
reference='1234567', reference='1234567',
created_by=get_user_model().objects.get(pk=3), created_by=get_user_model().objects.get(pk=3),
responsible=Owner.create(obj=Group.objects.get(pk=3)), responsible=Owner.create(obj=Group.objects.get(pk=3)),
) )
so.issue_order()
messages = NotificationMessage.objects.filter(category='order.new_salesorder') messages = NotificationMessage.objects.filter(category='order.new_salesorder')
# A notification should have been generated for user 4 (who is a member of group 3) # A notification should have been generated for user 4 (who is a member of group 3)

View File

@ -286,8 +286,15 @@ class PartCategory(InvenTree.models.InvenTreeTree):
return prefetch.filter(category=self.id) return prefetch.filter(category=self.id)
def get_subscribers(self, include_parents=True): def get_subscribers(self, include_parents: bool = True) -> list[User]:
"""Return a list of users who subscribe to this PartCategory.""" """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() subscribers = set()
if include_parents: if include_parents:
@ -1429,9 +1436,18 @@ class Part(
""" """
return self.total_stock - self.allocation_count() + self.on_order 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. """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 user may 'subscribe' to this part in the following ways:
a) Subscribing to the part instance directly a) Subscribing to the part instance directly

View File

@ -0,0 +1,24 @@
{% 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 %}
{% block body %}
<tr style="height: 3rem; border-bottom: 1px solid">
<th>{% trans "Return Order" %}</th>
<th>{% trans "Customer" %}</th>
</tr>
<tr style="height: 3rem">
<td style="text-align: center;">{{ order }}</td>
<td style="text-align: center;">{{ order.customer }}</td>
</tr>
{% endblock body %}