From 6db6a70fc29840d688d1c8ed87866f613644df35 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 13:32:14 +1100 Subject: [PATCH 1/4] Add task to check required stock for build order --- InvenTree/build/tasks.py | 85 +++++++++++++++++++ InvenTree/part/models.py | 17 ++-- InvenTree/part/tasks.py | 2 +- .../email/build_order_required_stock.html | 37 ++++++++ 4 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 InvenTree/build/tasks.py create mode 100644 InvenTree/templates/email/build_order_required_stock.html diff --git a/InvenTree/build/tasks.py b/InvenTree/build/tasks.py new file mode 100644 index 0000000000..a087b66129 --- /dev/null +++ b/InvenTree/build/tasks.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import logging + +from django.utils.translation import ugettext_lazy as _ +from django.template.loader import render_to_string + +from allauth.account.models import EmailAddress + +from common.models import NotificationEntry + +import build.models +import InvenTree.helpers +import InvenTree.tasks + + +logger = logging.getLogger('inventree') + + +def check_build_stock(build: build.models.Build): + """ + Check the required stock for a newly created build order, + and send an email out to any subscribed users if stock is low. + """ + + # Iterate through each of the parts required for this build + + lines = [] + + for bom_item in build.part.get_bom_items(): + + sub_part = bom_item.sub_part + + # The 'in stock' quantity depends on whether the bom_item allows variants + in_stock = sub_part.get_stock_count(include_variants=bom_item.allow_variants) + + allocated = sub_part.allocation_count() + + available = max(0, in_stock - allocated) + + required = bom_item.quantity * build.quantity + + if available < required: + # There is not sufficient stock for this part + + lines.append({ + 'link': InvenTree.helpers.construct_absolute_url(sub_part.get_absolute_url()), + 'part': sub_part, + 'in_stock': in_stock, + 'allocated': allocated, + 'available': available, + 'required': required, + }) + + if len(lines) == 0: + # Nothing to do + return + + # Are there any users subscribed to these parts? + subscribers = build.part.get_subscribers() + + emails = EmailAddress.objects.filter( + user__in=subscribers, + ) + + if len(emails) > 0: + + logger.info(f"Notifying users of stock required for build {build.pk}") + + context = { + 'link': InvenTree.helpers.construct_absolute_url(build.get_absolute_url()), + 'build': build, + 'part': build.part, + 'lines': lines, + } + + # Render the HTML message + html_message = render_to_string('email/build_order_required_stock.html', context) + + subject = "[InvenTree] " + _("Stock required for build order") + + recipients = emails.values_list('email', flat=True) + + InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 1c50bc321e..7a15657a90 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1324,6 +1324,17 @@ class Part(MPTTModel): return query + def get_stock_count(self, include_variants=True): + """ + Return the total "in stock" count for this part + """ + + entries = self.stock_entries(in_stock=True, include_variants=include_variants) + + query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0))) + + return query['t'] + @property def total_stock(self): """ Return the total stock quantity for this part. @@ -1332,11 +1343,7 @@ class Part(MPTTModel): - If this part is a "template" (variants exist) then these are counted too """ - entries = self.stock_entries(in_stock=True) - - query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0))) - - return query['t'] + return self.get_stock_count() def get_bom_item_filter(self, include_inherited=True): """ diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py index f4f1459214..0cd9cf09a7 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -50,7 +50,7 @@ def notify_low_stock(part: part.models.Part): 'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()), } - subject = _(f'[InvenTree] {part.name} is low on stock') + subject = "[InvenTree] " + _("Low stock notification") html_message = render_to_string('email/low_stock_notification.html', context) recipients = emails.values_list('email', flat=True) diff --git a/InvenTree/templates/email/build_order_required_stock.html b/InvenTree/templates/email/build_order_required_stock.html new file mode 100644 index 0000000000..6b28d39f8e --- /dev/null +++ b/InvenTree/templates/email/build_order_required_stock.html @@ -0,0 +1,37 @@ +{% extends "email/email.html" %} + +{% load i18n %} +{% load inventree_extras %} + +{% block title %} +{% trans "Stock is required for the following build order" %}
+{% blocktrans with build=build.reference part=part.full_name quantity=build.quantity %}Build order {{ build }} - building {{ quantity }} x {{ part }}{% endblocktrans %} +
+

{% trans "Click on the following link to view this build order" %}: {{ link }}

+{% endblock title %} + +{% block body %} +{% trans "The following parts are low on required stock" %} + + + {% trans "Part" %} + {% trans "Required Quantity" %} + {% trans "Available" %} + + +{% for line in lines %} + + {{ line.part.full_name }} + + {% decimal line.required %} {% if line.part.units %}{{ line.part.units }}{% endif %} + + {% decimal line.available %} {% if line.part.units %}{{ line.part.units }}{% endif %} + + +{% endfor %} + +{% endblock body %} + +{% block footer_prefix %} +

{% blocktrans with part=part.name %}You are receiving this email because you are subscribed to notifications for this part {% endblocktrans %}.

+{% endblock footer_prefix %} From 99b324d1ef4ab063364576837e5deff4ee2ae0fc Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 14:30:23 +1100 Subject: [PATCH 2/4] Add a post-save hook the "Build" model to check stock --- InvenTree/build/models.py | 28 +++++++++++++++---- InvenTree/build/tasks.py | 3 +- .../email/build_order_required_stock.html | 4 ++- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 403b3a9430..e8263285b1 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -8,17 +8,19 @@ import decimal import os from datetime import datetime +from django import dispatch -from django.utils.translation import ugettext_lazy as _ from django.contrib.auth.models import User from django.core.exceptions import ValidationError - -from django.urls import reverse +from django.core.validators import MinValueValidator from django.db import models, transaction from django.db.models import Sum, Q from django.db.models.functions import Coalesce -from django.core.validators import MinValueValidator +from django.db.models.signals import post_save +from django.dispatch.dispatcher import receiver +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ from markdownx.models import MarkdownxField @@ -27,16 +29,17 @@ from mptt.exceptions import InvalidMove from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode -from InvenTree.validators import validate_build_order_reference from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin +from InvenTree.validators import validate_build_order_reference import common.models import InvenTree.fields import InvenTree.helpers +import InvenTree.tasks -from stock import models as StockModels from part import models as PartModels +from stock import models as StockModels from users import models as UserModels @@ -1014,6 +1017,19 @@ class Build(MPTTModel, ReferenceIndexingMixin): return self.status == BuildStatus.COMPLETE +@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log') +def after_save_build(sender, instance: Build, created: bool, **kwargs): + """ + Callback function to be executed after a Build instance is saved + """ + + if created: + # A new Build has just been created + + # Run checks on required parts + InvenTree.tasks.offload_task('build.tasks.check_build_stock', instance) + + class BuildOrderAttachment(InvenTreeAttachment): """ Model for storing file attachments against a BuildOrder object diff --git a/InvenTree/build/tasks.py b/InvenTree/build/tasks.py index a087b66129..8b5a0a1831 100644 --- a/InvenTree/build/tasks.py +++ b/InvenTree/build/tasks.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from decimal import Decimal import logging from django.utils.translation import ugettext_lazy as _ @@ -39,7 +40,7 @@ def check_build_stock(build: build.models.Build): available = max(0, in_stock - allocated) - required = bom_item.quantity * build.quantity + required = Decimal(bom_item.quantity) * Decimal(build.quantity) if available < required: # There is not sufficient stock for this part diff --git a/InvenTree/templates/email/build_order_required_stock.html b/InvenTree/templates/email/build_order_required_stock.html index 6b28d39f8e..5f4015da27 100644 --- a/InvenTree/templates/email/build_order_required_stock.html +++ b/InvenTree/templates/email/build_order_required_stock.html @@ -21,7 +21,9 @@ {% for line in lines %} - {{ line.part.full_name }} + + {{ line.part.full_name }}{% if part.description %} - {{ part.description }}{% endif %} + {% decimal line.required %} {% if line.part.units %}{{ line.part.units }}{% endif %} From 01191d84c56ba7094d7e26a18c79377c8db72f79 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 14:32:42 +1100 Subject: [PATCH 3/4] Only run check stock function when updating an existing part --- InvenTree/build/models.py | 2 -- InvenTree/build/tasks.py | 2 -- InvenTree/part/models.py | 9 ++++++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index e8263285b1..0dd6a404e5 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -8,8 +8,6 @@ import decimal import os from datetime import datetime -from django import dispatch - from django.contrib.auth.models import User from django.core.exceptions import ValidationError diff --git a/InvenTree/build/tasks.py b/InvenTree/build/tasks.py index 8b5a0a1831..7455d6eac2 100644 --- a/InvenTree/build/tasks.py +++ b/InvenTree/build/tasks.py @@ -9,8 +9,6 @@ from django.template.loader import render_to_string from allauth.account.models import EmailAddress -from common.models import NotificationEntry - import build.models import InvenTree.helpers import InvenTree.tasks diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 7a15657a90..f37b61864d 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2102,13 +2102,16 @@ class Part(MPTTModel): @receiver(post_save, sender=Part, dispatch_uid='part_post_save_log') -def after_save_part(sender, instance: Part, **kwargs): +def after_save_part(sender, instance: Part, created, **kwargs): """ Function to be executed after a Part is saved """ - # Run this check in the background - InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance) + if not created: + # Check part stock only if we are *updating* the part (not creating it) + + # Run this check in the background + InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance) def attach_file(instance, filename): From 42a794e8e47213294e2c3e190c56b93e94ee08f6 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 15:05:54 +1100 Subject: [PATCH 4/4] Fix CI errors --- InvenTree/build/tasks.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/InvenTree/build/tasks.py b/InvenTree/build/tasks.py index 7455d6eac2..6fe4be5119 100644 --- a/InvenTree/build/tasks.py +++ b/InvenTree/build/tasks.py @@ -12,6 +12,7 @@ from allauth.account.models import EmailAddress import build.models import InvenTree.helpers import InvenTree.tasks +import part.models as part_models logger = logging.getLogger('inventree') @@ -27,7 +28,18 @@ def check_build_stock(build: build.models.Build): lines = [] - for bom_item in build.part.get_bom_items(): + if not build: + logger.error("Invalid build passed to 'build.tasks.check_build_stock'") + return + + try: + part = build.part + except part_models.Part.DoesNotExist: + # Note: This error may be thrown during unit testing... + logger.error("Invalid build.part passed to 'build.tasks.check_build_stock'") + return + + for bom_item in part.get_bom_items(): sub_part = bom_item.sub_part