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
This commit is contained in:
Oliver 2022-06-06 19:12:29 +10:00 committed by GitHub
parent 7b4d0605b8
commit 1e6bdfbcab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 439 additions and 22 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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')

View File

@ -274,6 +274,7 @@ class NotificationList(generics.ListAPIView):
'category',
'name',
'read',
'creation',
]
search_fields = [

View File

@ -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'),
},

View File

@ -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)

View File

@ -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(

136
InvenTree/order/tasks.py Normal file
View File

@ -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)

View File

@ -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)

View File

@ -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')

View File

@ -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,
},
}

View File

@ -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"""

View File

@ -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

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 "Build Order" %}</th>
<th>{% trans "Part" %}</th>
</tr>
<tr style="height: 3rem">
<td style="text-align: center;">{{ order }}</td>
<td style="text-align: center;">{{ order.part.full_name }}</td>
</tr>
{% endblock body %}

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 "Purchase Order" %}</th>
<th>{% trans "Supplier" %}</th>
</tr>
<tr style="height: 3rem">
<td style="text-align: center;">{{ order }}</td>
<td style="text-align: center;">{{ order.supplier }}</td>
</tr>
{% endblock body %}

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 "Sales 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 %}

View File

@ -238,7 +238,7 @@ function getReadEditButton(pk, state, small=false) {
}
var style = (small) ? 'btn-sm ' : '';
return `<button title='${bReadText}' class='notification-read btn ${style}btn-outline-secondary' type='button' pk='${pk}' target='${bReadTarget}'><span class='${bReadIcon}'></span></button>`;
return `<button title='${bReadText}' class='notification-read btn ${style}btn-outline-secondary float-right' type='button' pk='${pk}' target='${bReadTarget}'><span class='${bReadIcon}'></span></button>`;
}
/**
@ -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 += '<li class="list-group-item">';
// d-flex justify-content-between align-items-start
html += '<div>';
html += `<span class="badge rounded-pill bg-primary">${item.category}</span><span class="ms-2">${item.name}</span>`;
html += '</div>';
html += `<div>`;
html += `<span class="badge bg-secondary rounded-pill">${item.name}</span>`;
html += getReadEditButton(item.pk, item.read, true);
html += `</div>`;
if (item.target) {
var link_text = `${item.target.model}: ${item.target.name}`;
var link_text = `${item.target.name}`;
if (item.target.link) {
link_text = `<a href='${item.target.link}'>${link_text}</a>`;
}
html += link_text;
}
html += '<div>';
html += `<span class="text-muted">${item.age_human}</span>`;
html += getReadEditButton(item.pk, item.read, true);
html += `<span class="text-muted"><small>${item.age_human}</small></span>`;
html += '</div></li>';
});