diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 319b88cb09..fd86306627 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -69,6 +69,35 @@ def getStaticUrl(filename): return os.path.join(STATIC_URL, str(filename)) +def construct_absolute_url(*arg): + """ + Construct (or attempt to construct) an absolute URL from a relative URL. + + This is useful when (for example) sending an email to a user with a link + to something in the InvenTree web framework. + + This requires the BASE_URL configuration option to be set! + """ + + base = str(InvenTreeSetting.get_setting('INVENTREE_BASE_URL')) + + url = '/'.join(arg) + + if not base: + return url + + # Strip trailing slash from base url + if base.endswith('/'): + base = base[:-1] + + if url.startswith('/'): + url = url[1:] + + url = f"{base}/{url}" + + return url + + def getBlankImage(): """ Return the qualified path for the 'blank image' placeholder. diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index aa17ef8603..801c75aa26 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -52,7 +52,7 @@ def schedule_task(taskname, **kwargs): pass -def offload_task(taskname, force_sync=False, *args, **kwargs): +def offload_task(taskname, *args, force_sync=False, **kwargs): """ Create an AsyncTask if workers are running. This is different to a 'scheduled' task, @@ -108,7 +108,7 @@ def offload_task(taskname, force_sync=False, *args, **kwargs): return # Workers are not running: run it as synchronous task - _func() + _func(*args, **kwargs) def heartbeat(): @@ -290,7 +290,7 @@ def update_exchange_rates(): Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete() -def send_email(subject, body, recipients, from_email=None): +def send_email(subject, body, recipients, from_email=None, html_message=None): """ Send an email with the specified subject and body, to the specified recipients list. @@ -306,4 +306,5 @@ def send_email(subject, body, recipients, from_email=None): from_email, recipients, fail_silently=False, + html_message=html_message ) diff --git a/InvenTree/InvenTree/test_api.py b/InvenTree/InvenTree/test_api.py index dfe94c034e..6ace21b576 100644 --- a/InvenTree/InvenTree/test_api.py +++ b/InvenTree/InvenTree/test_api.py @@ -102,9 +102,9 @@ class APITests(InvenTreeAPITestCase): fixtures = [ 'location', - 'stock', - 'part', 'category', + 'part', + 'stock' ] token = None diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index ade2c4a439..f87f5bff3e 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -807,19 +807,19 @@ class InvenTreeSetting(BaseInvenTreeSetting): # login / SSO 'LOGIN_ENABLE_PWD_FORGOT': { 'name': _('Enable password forgot'), - 'description': _('Enable password forgot function on the login-pages'), + 'description': _('Enable password forgot function on the login pages'), 'default': True, 'validator': bool, }, 'LOGIN_ENABLE_REG': { 'name': _('Enable registration'), - 'description': _('Enable self-registration for users on the login-pages'), + 'description': _('Enable self-registration for users on the login pages'), 'default': False, 'validator': bool, }, 'LOGIN_ENABLE_SSO': { 'name': _('Enable SSO'), - 'description': _('Enable SSO on the login-pages'), + 'description': _('Enable SSO on the login pages'), 'default': False, 'validator': bool, }, diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 5cd9fa3180..050b46058a 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1988,6 +1988,9 @@ class Part(MPTTModel): def related_count(self): return len(self.get_related_parts()) + def is_part_low_on_stock(self): + return self.total_stock <= self.minimum_stock + def attach_file(instance, filename): """ Function for storing a file for a PartAttachment diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py new file mode 100644 index 0000000000..72d996e772 --- /dev/null +++ b/InvenTree/part/tasks.py @@ -0,0 +1,58 @@ +# -*- 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 InvenTree + +import InvenTree.helpers +import InvenTree.tasks + +from part.models import Part + +logger = logging.getLogger("inventree") + + +def notify_low_stock(part: Part): + """ + Notify users who have starred a part when its stock quantity falls below the minimum threshold + """ + + logger.info(f"Sending low stock notification email for {part.full_name}") + + starred_users_email = EmailAddress.objects.filter(user__starred_parts__part=part) + + # TODO: In the future, include the part image in the email template + + if len(starred_users_email) > 0: + logger.info(f"Notify users regarding low stock of {part.name}") + context = { + # Pass the "Part" object through to the template context + 'part': part, + 'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()), + } + + subject = _(f'[InvenTree] {part.name} is low on stock') + html_message = render_to_string('email/low_stock_notification.html', context) + recipients = starred_users_email.values_list('email', flat=True) + + InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message) + + +def notify_low_stock_if_required(part: Part): + """ + Check if the stock quantity has fallen below the minimum threshold of part. + + If true, notify the users who have subscribed to the part + """ + + if part.is_part_low_on_stock(): + InvenTree.tasks.offload_task( + 'part.tasks.notify_low_stock', + part + ) diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 2230077a81..590ea20a6f 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -122,6 +122,12 @@ def inventree_title(*args, **kwargs): return version.inventreeInstanceTitle() +@register.simple_tag() +def inventree_base_url(*args, **kwargs): + """ Return the INVENTREE_BASE_URL setting """ + return InvenTreeSetting.get_setting('INVENTREE_BASE_URL') + + @register.simple_tag() def python_version(*args, **kwargs): """ diff --git a/InvenTree/report/templatetags/report.py b/InvenTree/report/templatetags/report.py index 45d11c1d2d..9593db1885 100644 --- a/InvenTree/report/templatetags/report.py +++ b/InvenTree/report/templatetags/report.py @@ -14,6 +14,8 @@ from stock.models import StockItem from common.models import InvenTreeSetting +import InvenTree.helpers + register = template.Library() @@ -119,18 +121,10 @@ def internal_link(link, text): text = str(text) - base_url = InvenTreeSetting.get_setting('INVENTREE_BASE_URL') + url = InvenTree.helpers.construct_absolute_url(link) # If the base URL is not set, just return the text - if not base_url: + if not url: return text - if not base_url.endswith('/'): - base_url += '/' - - if base_url.endswith('/') and link.startswith('/'): - link = link[1:] - - url = f"{base_url}{link}" - return mark_safe(f'{text}') diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 1372e63406..657469a744 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -17,7 +17,7 @@ from django.db.models import Sum, Q from django.db.models.functions import Coalesce from django.core.validators import MinValueValidator from django.contrib.auth.models import User -from django.db.models.signals import pre_delete +from django.db.models.signals import pre_delete, post_save, post_delete from django.dispatch import receiver from markdownx.models import MarkdownxField @@ -41,6 +41,7 @@ from users.models import Owner from company import models as CompanyModels from part import models as PartModels +from part import tasks as part_tasks class StockLocation(InvenTreeTree): @@ -1651,6 +1652,24 @@ def before_delete_stock_item(sender, instance, using, **kwargs): child.save() +@receiver(post_delete, sender=StockItem, dispatch_uid='stock_item_post_delete_log') +def after_delete_stock_item(sender, instance: StockItem, **kwargs): + """ + Function to be executed after a StockItem object is deleted + """ + + part_tasks.notify_low_stock_if_required(instance.part) + + +@receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log') +def after_save_stock_item(sender, instance: StockItem, **kwargs): + """ + Hook function to be executed after StockItem object is saved/updated + """ + + part_tasks.notify_low_stock_if_required(instance.part) + + class StockItemAttachment(InvenTreeAttachment): """ Model for storing file attachments against a StockItem object. diff --git a/InvenTree/templates/InvenTree/settings/category.html b/InvenTree/templates/InvenTree/settings/category.html index 9eb595ddde..f90d1e8d11 100644 --- a/InvenTree/templates/InvenTree/settings/category.html +++ b/InvenTree/templates/InvenTree/settings/category.html @@ -7,6 +7,12 @@ {% trans "Category Settings" %} {% endblock %} +{% block actions %} + +{% endblock %} + {% block content %}
@@ -21,12 +27,6 @@
-
- -
-
diff --git a/InvenTree/templates/InvenTree/settings/currencies.html b/InvenTree/templates/InvenTree/settings/currencies.html index ba6e782508..706f836317 100644 --- a/InvenTree/templates/InvenTree/settings/currencies.html +++ b/InvenTree/templates/InvenTree/settings/currencies.html @@ -13,29 +13,31 @@ {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-globe" %} - -
- - + - + + {% for rate in rates %} - + + + + {% endfor %} + - diff --git a/InvenTree/templates/InvenTree/settings/login.html b/InvenTree/templates/InvenTree/settings/login.html index 4ce4cd2408..b7f32465c6 100644 --- a/InvenTree/templates/InvenTree/settings/login.html +++ b/InvenTree/templates/InvenTree/settings/login.html @@ -18,7 +18,7 @@ {% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" %} - + {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %} diff --git a/InvenTree/templates/InvenTree/settings/part.html b/InvenTree/templates/InvenTree/settings/part.html index 351810b7dc..1b2a3e5498 100644 --- a/InvenTree/templates/InvenTree/settings/part.html +++ b/InvenTree/templates/InvenTree/settings/part.html @@ -9,8 +9,6 @@ {% block content %} -

{% trans "Part Options" %}

-
{% trans "Base Currency" %} {{ base_currency }}
{% trans "Exchange Rates" %}{% trans "Exchange Rates" %}
{{ rate.currency }} {{ rate.value }}{{ rate.currency }}
{% trans "Last Update" %} + {% if rates_updated %} {{ rates_updated }} {% else %} @@ -44,7 +46,7 @@
{% csrf_token %} - +
{% trans 'Signup' %}
{% trans 'Signup' %}
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %} @@ -40,12 +38,17 @@
-

{% trans "Part Import" %}

- - - +
+
+

{% trans "Part Import" %}

+ {% include "spacer.html" %} +
+ +
+
+
@@ -53,14 +56,16 @@
- - -

{% trans "Part Parameter Templates" %}

- -
- +
+ +

{% trans "Part Parameter Templates" %}

+ {% include "spacer.html" %} +
+ +
+
diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html index 4a506b46e6..7419b7ff34 100644 --- a/InvenTree/templates/InvenTree/settings/setting.html +++ b/InvenTree/templates/InvenTree/settings/setting.html @@ -21,15 +21,13 @@ {% else %}
- {% if setting.value %} - {{ setting.value }} + {{ setting.value }} {% else %} - {% trans "No value set" %} + {% trans "No value set" %} {% endif %} - {{ setting.units }}
{% endif %} diff --git a/InvenTree/templates/InvenTree/settings/user.html b/InvenTree/templates/InvenTree/settings/user.html index 676a4e6ca9..b86cd3d47f 100644 --- a/InvenTree/templates/InvenTree/settings/user.html +++ b/InvenTree/templates/InvenTree/settings/user.html @@ -11,18 +11,18 @@ {% trans "Account Settings" %} {% endblock %} +{% block actions %} +
+ {% trans "Edit" %} +
+
+ {% trans "Set Password" %} +
+{% endblock %} + {% block content %} {% mail_configured as mail_conf %} -
-
- {% trans "Edit" %} -
-
- {% trans "Set Password" %} -
-
-
@@ -40,10 +40,12 @@
+

{% trans "Email" %}

+
-
+
{% if user.emailaddress_set.all %}

{% trans 'The following email addresses are associated with your account:' %}

@@ -52,20 +54,26 @@
{% for emailaddress in user.emailaddress_set.all %} +
-
{% endfor %} @@ -88,7 +96,7 @@
{% if can_add_email %} -
+
{% trans "Add Email Address" %}
@@ -207,26 +215,26 @@

{% trans "Theme Settings" %}

- - {% csrf_token %} - -
-
-
- +
+ + {% csrf_token %} + + +
+ +
+
-
-
- -
- - + +
@@ -238,33 +246,32 @@
{% csrf_token %} -
-
-
- + {% get_current_language as LANGUAGE_CODE %} + {% get_available_languages as LANGUAGES %} + {% get_language_info_list for LANGUAGES as languages %} + {% for language in languages %} + {% define language.code as lang_code %} + {% define locale_stats|keyvalue:lang_code as lang_translated %} + - {% endfor %} - -
+ {% endif %} + + {% endfor %} + +
+
-
- -
- +

{% trans "Help the translation efforts!" %}

diff --git a/InvenTree/templates/email/email.html b/InvenTree/templates/email/email.html new file mode 100644 index 0000000000..97e9a40f37 --- /dev/null +++ b/InvenTree/templates/email/email.html @@ -0,0 +1,43 @@ +{% load i18n %} +{% load static %} +{% load inventree_extras %} + +
{% trans "Username" %}
+ + {% block header %} + + + + + {% endblock %} + + {% block body %} + + {% block body_row %} + + {% endblock %} + + {% endblock %} + + {% block footer %} + + + + {% endblock %} + +
+ {% block header_row %} +

{% block title %}{% endblock %}

+ {% block subtitle %} + + {% endblock %} + {% endblock %} +
+ {% block footer_prefix %} + + {% endblock %} +

{% trans "InvenTree version" %}: {% inventree_version %} - inventree.readthedocs.io

+ {% block footer_suffix %} + + {% endblock %} +
diff --git a/InvenTree/templates/email/low_stock_notification.html b/InvenTree/templates/email/low_stock_notification.html new file mode 100644 index 0000000000..ecb350925a --- /dev/null +++ b/InvenTree/templates/email/low_stock_notification.html @@ -0,0 +1,29 @@ +{% extends "email/email.html" %} + +{% load i18n %} +{% load inventree_extras %} + +{% block title %} +{% blocktrans with part=part.name %} The available stock for {{ part }} has fallen below the configured minimum level{% endblocktrans %} +{% if link %} +

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

+{% endif %} +{% endblock %} + +{% block subtitle %} +

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

+{% endblock %} + +{% block body %} + + {% trans "Part Name" %} + {% trans "Available Quantity" %} + {% trans "Minimum Quantity" %} + + + + {{ part.full_name }} + {{ part.total_stock }} + {{ part.minimum_stock }} + +{% endblock %} diff --git a/InvenTree/templates/panel.html b/InvenTree/templates/panel.html index 1491991e8c..86867f07b4 100644 --- a/InvenTree/templates/panel.html +++ b/InvenTree/templates/panel.html @@ -1,7 +1,14 @@
{% block panel_heading %}
-

{% block heading %}HEADING{% endblock %}

+
+

{% block heading %}HEADING{% endblock %}

+ {% include "spacer.html" %} +
+ {% block actions %} + {% endblock %} +
+
{% endblock %} {% block panel_content %}