diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 403b3a9430..0dd6a404e5 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -9,16 +9,16 @@ import decimal import os from datetime import datetime -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 +27,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 +1015,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 new file mode 100644 index 0000000000..6fe4be5119 --- /dev/null +++ b/InvenTree/build/tasks.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from decimal import Decimal +import logging + +from django.utils.translation import ugettext_lazy as _ +from django.template.loader import render_to_string + +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') + + +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 = [] + + 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 + + # 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 = Decimal(bom_item.quantity) * Decimal(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..f37b61864d 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): """ @@ -2095,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): 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..5f4015da27 --- /dev/null +++ b/InvenTree/templates/email/build_order_required_stock.html @@ -0,0 +1,39 @@ +{% 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 }}{% if part.description %} - {{ part.description }}{% endif %} + + + {% 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 %}