From 4bfdf211072cb7f3b08fbe967e8054bab383a1be Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Tue, 2 Nov 2021 15:07:20 +1100 Subject: [PATCH 01/20] Change "star" icon to "bullhorn" icon --- InvenTree/InvenTree/views.py | 11 ----------- InvenTree/common/models.py | 4 ++-- InvenTree/part/templates/part/part_base.html | 4 ++-- InvenTree/templates/InvenTree/index.html | 2 +- .../templates/InvenTree/settings/user_homepage.html | 2 +- InvenTree/templates/js/translated/part.js | 4 ++-- 6 files changed, 8 insertions(+), 19 deletions(-) diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 00f38ce89d..989fb1bc9d 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -655,17 +655,6 @@ class IndexView(TemplateView): context = super(TemplateView, self).get_context_data(**kwargs) - # TODO - Re-implement this when a less expensive method is worked out - # context['starred'] = [star.part for star in self.request.user.starred_parts.all()] - - # Generate a list of orderable parts which have stock below their minimum values - # TODO - Is there a less expensive way to get these from the database - # context['to_order'] = [part for part in Part.objects.filter(purchaseable=True) if part.need_to_restock()] - - # Generate a list of assembly parts which have stock below their minimum values - # TODO - Is there a less expensive way to get these from the database - # context['to_build'] = [part for part in Part.objects.filter(assembly=True) if part.need_to_restock()] - return context diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 1809f437f7..125941be14 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -874,8 +874,8 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): GLOBAL_SETTINGS = { 'HOMEPAGE_PART_STARRED': { - 'name': _('Show starred parts'), - 'description': _('Show starred parts on the homepage'), + 'name': _('Show subscribed parts'), + 'description': _('Show subscribed parts on the homepage'), 'default': True, 'validator': bool, }, diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 16b119e02d..3e00b56158 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -23,8 +23,8 @@ {% include "admin_button.html" with url=url %} {% endif %} -<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Star this part" %}'> - <span id='part-star-icon' class='fas fa-star {% if starred %}icon-yellow{% endif %}'/> +<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this part" %}'> + <span id='part-star-icon' class='fas fa-bullhorn {% if starred %}icon-green{% endif %}'/> </button> {% if barcodes %} diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html index 95b6c15bf9..6847e41095 100644 --- a/InvenTree/templates/InvenTree/index.html +++ b/InvenTree/templates/InvenTree/index.html @@ -84,7 +84,7 @@ function addHeaderAction(label, title, icon, options) { addHeaderTitle('{% trans "Parts" %}'); {% if setting_part_starred %} -addHeaderAction('starred-parts', '{% trans "Starred Parts" %}', 'fa-star'); +addHeaderAction('starred-parts', '{% trans "Subscribed Parts" %}', 'fa-bullhorn'); loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", { params: { "starred": true, diff --git a/InvenTree/templates/InvenTree/settings/user_homepage.html b/InvenTree/templates/InvenTree/settings/user_homepage.html index 7eed850ad5..455f7f2a8b 100644 --- a/InvenTree/templates/InvenTree/settings/user_homepage.html +++ b/InvenTree/templates/InvenTree/settings/user_homepage.html @@ -14,7 +14,7 @@ <div class='row'> <table class='table table-striped table-condensed'> <tbody> - {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-star' user_setting=True %} + {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-bullhorn' user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_LATEST" icon='fa-history' user_setting=True %} {% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BOM_VALIDATION" user_setting=True %} diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index ec72d2682c..0ff6bd400c 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -398,9 +398,9 @@ function toggleStar(options) { method: 'PATCH', success: function(response) { if (response.starred) { - $(options.button).addClass('icon-yellow'); + $(options.button).addClass('icon-green'); } else { - $(options.button).removeClass('icon-yellow'); + $(options.button).removeClass('icon-green'); } } } From 85adf842f69cd953bbf87c713a653f1f983b16ad Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Wed, 3 Nov 2021 16:59:59 +1100 Subject: [PATCH 02/20] Change bullhorn icon to bell icon --- InvenTree/part/templates/part/part_base.html | 6 +++++- InvenTree/templates/InvenTree/index.html | 2 +- .../InvenTree/settings/user_homepage.html | 2 +- InvenTree/templates/js/translated/part.js | 20 +++++++++---------- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 3e00b56158..6cbd3f92db 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -24,7 +24,11 @@ {% endif %} <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this part" %}'> - <span id='part-star-icon' class='fas fa-bullhorn {% if starred %}icon-green{% endif %}'/> + {% if starred %} + <span id='part-star-icon' class='fas fa-bell icon-green'/> + {% else %} + <span id='part-star-icon' class='fa fa-bell-slash'/> + {% endif %} </button> {% if barcodes %} diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html index 6847e41095..2c407fdcd9 100644 --- a/InvenTree/templates/InvenTree/index.html +++ b/InvenTree/templates/InvenTree/index.html @@ -84,7 +84,7 @@ function addHeaderAction(label, title, icon, options) { addHeaderTitle('{% trans "Parts" %}'); {% if setting_part_starred %} -addHeaderAction('starred-parts', '{% trans "Subscribed Parts" %}', 'fa-bullhorn'); +addHeaderAction('starred-parts', '{% trans "Subscribed Parts" %}', 'fa-bell'); loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", { params: { "starred": true, diff --git a/InvenTree/templates/InvenTree/settings/user_homepage.html b/InvenTree/templates/InvenTree/settings/user_homepage.html index 455f7f2a8b..8219187044 100644 --- a/InvenTree/templates/InvenTree/settings/user_homepage.html +++ b/InvenTree/templates/InvenTree/settings/user_homepage.html @@ -14,7 +14,7 @@ <div class='row'> <table class='table table-striped table-condensed'> <tbody> - {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-bullhorn' user_setting=True %} + {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-bell' user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_LATEST" icon='fa-history' user_setting=True %} {% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BOM_VALIDATION" user_setting=True %} diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 5863855a33..2c59723f14 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -373,15 +373,15 @@ function duplicatePart(pk, options={}) { } +/* Toggle the 'starred' status of a part. + * Performs AJAX queries and updates the display on the button. + * + * options: + * - button: ID of the button (default = '#part-star-icon') + * - part: pk of the part object + * - user: pk of the user + */ function toggleStar(options) { - /* Toggle the 'starred' status of a part. - * Performs AJAX queries and updates the display on the button. - * - * options: - * - button: ID of the button (default = '#part-star-icon') - * - part: pk of the part object - * - user: pk of the user - */ var url = `/api/part/${options.part}/`; @@ -398,9 +398,9 @@ function toggleStar(options) { method: 'PATCH', success: function(response) { if (response.starred) { - $(options.button).addClass('icon-green'); + $(options.button).removeClass('fa fa-bell-slash').addClass('fas fa-bell icon-green'); } else { - $(options.button).removeClass('icon-green'); + $(options.button).removeClass('fas fa-bell icon-green').addClass('fa fa-bell-slash'); } } } From e7f6268640b82f7cb84e3a3a3e3c36383ff48043 Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Wed, 3 Nov 2021 17:55:30 +1100 Subject: [PATCH 03/20] Improvements for alert notifications - Dismissable - Delete after a certain amount of time --- InvenTree/InvenTree/static/css/inventree.css | 14 +-- .../static/script/inventree/notification.js | 90 +++++++++++++++---- InvenTree/part/templates/part/part_base.html | 12 +-- InvenTree/templates/base.html | 8 +- InvenTree/templates/js/translated/part.js | 10 +++ InvenTree/templates/notification.html | 18 ---- 6 files changed, 99 insertions(+), 53 deletions(-) delete mode 100644 InvenTree/templates/notification.html diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 61037c9c54..670d577497 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -745,13 +745,7 @@ input[type="submit"] { } .notification-area { - position: fixed; - top: 0px; - margin-top: 20px; - width: 100%; - padding: 20px; - z-index: 5000; - pointer-events: none; /* Prevent this div from blocking links underneath */ + opacity: 0.8; } .notes { @@ -761,7 +755,6 @@ input[type="submit"] { } .alert { - display: none; border-radius: 5px; opacity: 0.9; pointer-events: all; @@ -771,9 +764,8 @@ input[type="submit"] { display: block; } -.btn { - margin-left: 2px; - margin-right: 2px; +.navbar .btn { + margin-left: 5px; } .btn-secondary { diff --git a/InvenTree/InvenTree/static/script/inventree/notification.js b/InvenTree/InvenTree/static/script/inventree/notification.js index 01754bceaf..0e8a19ed87 100644 --- a/InvenTree/InvenTree/static/script/inventree/notification.js +++ b/InvenTree/InvenTree/static/script/inventree/notification.js @@ -16,29 +16,83 @@ function showAlertOrCache(alertType, message, cache, timeout=5000) { } } + +/* + * Display cached alert messages when loading a page + */ function showCachedAlerts() { - // Success Message - if (sessionStorage.getItem("inventree-alert-success")) { - showAlert("#alert-success", sessionStorage.getItem("inventree-alert-success")); - sessionStorage.removeItem("inventree-alert-success"); + var styles = [ + 'primary', + 'secondary', + 'success', + 'info', + 'warning', + 'danger', + ]; + + styles.forEach(function(style) { + + var msg = sessionStorage.getItem(`inventree-alert-${style}`); + + if (msg) { + showMessage(msg, { + style: style, + }); + } + }); +} + + +/* + * Display an alert message at the top of the screen. + * The message will contain a "close" button, + * and also dismiss automatically after a certain amount of time. + * + * arguments: + * - message: Text / HTML content to display + * + * options: + * - style: alert style e.g. 'success' / 'warning' + * - timeout: Time (in milliseconds) after which the message will be dismissed + */ +function showMessage(message, options={}) { + + var style = options.style || 'info'; + + var timeout = options.timeout || 5000; + + // Hacky function to get the next available ID + var id = 1; + + while ($(`#alert-${id}`).exists()) { + id++; } - // Info Message - if (sessionStorage.getItem("inventree-alert-info")) { - showAlert("#alert-info", sessionStorage.getItem("inventree-alert-info")); - sessionStorage.removeItem("inventree-alert-info"); + var icon = ''; + + if (options.icon) { + icon = `<span class='${options.icon}></span>`; } - // Warning Message - if (sessionStorage.getItem("inventree-alert-warning")) { - showAlert("#alert-warning", sessionStorage.getItem("inventree-alert-warning")); - sessionStorage.removeItem("inventree-alert-warning"); - } + // Construct the alert + var html = ` + <div id='alert-${id}' class='alert alert-${style} alert-dismissible fade show' role='alert'> + ${icon} + ${message} + <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> + </div> + `; - // Danger Message - if (sessionStorage.getItem("inventree-alert-danger")) { - showAlert("#alert-danger", sessionStorage.getItem("inventree-alert-danger")); - sessionStorage.removeItem("inventree-alert-danger"); - } + $('#alerts').append(html); + + // Remove the alert automatically after a specified period of time + setInterval(function() { + $(`#alert-${id}`).animate({ + 'opacity': 0.0, + 'height': '0px', + }, 250, function() { + $(`#alert-${id}`).remove(); + }); + }, timeout); } \ No newline at end of file diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 6cbd3f92db..1dcb509a59 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -23,13 +23,15 @@ {% include "admin_button.html" with url=url %} {% endif %} -<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this part" %}'> - {% if starred %} +{% if starred %} +<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to nofications for this part" %}'> <span id='part-star-icon' class='fas fa-bell icon-green'/> - {% else %} - <span id='part-star-icon' class='fa fa-bell-slash'/> - {% endif %} </button> +{% else %} +<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this part" %}'> + <span id='part-star-icon' class='fa fa-bell-slash'/> +</button> +{% endif %} {% if barcodes %} <!-- Barcode actions menu --> diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index 3b6350e40b..d01d5051f6 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -84,6 +84,13 @@ </div> </div> <main class='col ps-md-2 pt-2'> + + {% block alerts %} + <div class='notification-area' id='alerts'> + <!-- Div for displayed alerts --> + </div> + {% endblock %} + {% block breadcrumb_list %} <div class='container-fluid navigation'> <nav aria-label='breadcrumb'> @@ -102,7 +109,6 @@ </div> {% include 'modals.html' %} {% include 'about.html' %} - {% include 'notification.html' %} </div> <!-- Scripts --> diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 2c59723f14..adc10566d7 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -399,8 +399,18 @@ function toggleStar(options) { success: function(response) { if (response.starred) { $(options.button).removeClass('fa fa-bell-slash').addClass('fas fa-bell icon-green'); + $(options.button).attr('title', '{% trans "You are subscribed to notifications for this part" %}'); + + showMessage('{% trans "You have subscribed to notifications for this part" %}', { + style: 'success', + }); } else { $(options.button).removeClass('fas fa-bell icon-green').addClass('fa fa-bell-slash'); + $(options.button).attr('title', '{% trans "Subscribe to notifications for this part" %}'); + + showMessage('{% trans "You have unsubscribed to notifications for this part" %}', { + style: 'warning', + }); } } } diff --git a/InvenTree/templates/notification.html b/InvenTree/templates/notification.html deleted file mode 100644 index 9919b0d6f5..0000000000 --- a/InvenTree/templates/notification.html +++ /dev/null @@ -1,18 +0,0 @@ -<div class='notification-area'> -<div class="alert alert-success alert-dismissable" id="alert-success"> - <a href="#" class="close" data-bs-dismiss="alert" aria-label="close">×</a> - <div class='alert-msg'>Success alert</div> -</div> -<div class='alert alert-info alert-dismissable' id='alert-info'> - <a href="#" class="close" data-bs-dismiss="alert" aria-label="close">×</a> - <div class='alert-msg'>Info alert</div> -</div> -<div class='alert alert-warning alert-dismissable' id='alert-warning'> - <a href="#" class="close" data-bs-dismiss="alert" aria-label="close">×</a> - <div class='alert-msg'>Warning alert</div> -</div> -<div class='alert alert-danger alert-dismissable' id='alert-danger'> - <a href="#" class="close" data-bs-dismiss="alert" aria-label="close">×</a> - <div class='alert-msg'>Danger alert</div> -</div> -</div> \ No newline at end of file From 4cf6b9bd319fa2d8d49a97db23f0068b6aefc399 Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Wed, 3 Nov 2021 17:59:08 +1100 Subject: [PATCH 04/20] Remove old function --- .../static/script/inventree/notification.js | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/InvenTree/InvenTree/static/script/inventree/notification.js b/InvenTree/InvenTree/static/script/inventree/notification.js index 0e8a19ed87..4ed1333ac6 100644 --- a/InvenTree/InvenTree/static/script/inventree/notification.js +++ b/InvenTree/InvenTree/static/script/inventree/notification.js @@ -1,18 +1,10 @@ -function showAlert(target, message, timeout=5000) { - - $(target).find(".alert-msg").html(message); - $(target).show(); - $(target).delay(timeout).slideUp(200, function() { - $(this).alert(close); - }); -} function showAlertOrCache(alertType, message, cache, timeout=5000) { if (cache) { sessionStorage.setItem("inventree-" + alertType, message); } else { - showAlert('#' + alertType, message, timeout); + showMessage('#' + alertType, message, timeout); } } @@ -87,12 +79,7 @@ function showMessage(message, options={}) { $('#alerts').append(html); // Remove the alert automatically after a specified period of time - setInterval(function() { - $(`#alert-${id}`).animate({ - 'opacity': 0.0, - 'height': '0px', - }, 250, function() { - $(`#alert-${id}`).remove(); - }); - }, timeout); + $(`#alert-${id}`).delay(timeout).slideUp(200, function() { + $(this).alert(close); + }); } \ No newline at end of file From cf023e2cc17541e082ea85726bf22d487e5360e7 Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Wed, 3 Nov 2021 18:10:34 +1100 Subject: [PATCH 05/20] Create new model for "PartCategory" --- .../part/migrations/0074_partcategorystar.py | 27 +++++++++++++++++ InvenTree/part/models.py | 30 ++++++++++++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 InvenTree/part/migrations/0074_partcategorystar.py diff --git a/InvenTree/part/migrations/0074_partcategorystar.py b/InvenTree/part/migrations/0074_partcategorystar.py new file mode 100644 index 0000000000..0015212d2e --- /dev/null +++ b/InvenTree/part/migrations/0074_partcategorystar.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.5 on 2021-11-03 07:03 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('part', '0073_auto_20211013_1048'), + ] + + operations = [ + migrations.CreateModel( + name='PartCategoryStar', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_users', to='part.partcategory', verbose_name='Category')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_categories', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'unique_together': {('category', 'user')}, + }, + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 050b46058a..fc7382ac62 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2062,10 +2062,9 @@ class PartInternalPriceBreak(common.models.PriceBreak): class PartStar(models.Model): - """ A PartStar object creates a relationship between a User and a Part. + """ A PartStar object creates a subscription relationship between a User and a Part. - It is used to designate a Part as 'starred' (or favourited) for a given User, - so that the user can track a list of their favourite parts. + It is used to designate a Part as 'subscribed' for a given User. Attributes: part: Link to a Part object @@ -2077,7 +2076,30 @@ class PartStar(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_parts') class Meta: - unique_together = ['part', 'user'] + unique_together = [ + 'part', + 'user' + ] + + +class PartCategoryStar(models.Model): + """ + A PartCategoryStar creates a subscription relationship between a User and a PartCategory. + + Attributes: + category: Link to a PartCategory object + user: Link to a User object + """ + + category = models.ForeignKey(PartCategory, on_delete=models.CASCADE, verbose_name=_('Category'), related_name='starred_users') + + user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_categories') + + class Meta: + unique_together = [ + 'category', + 'user', + ] class PartTestTemplate(models.Model): From f9a00b7a903011e899b6518886ab2e2541c713e4 Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Wed, 3 Nov 2021 22:57:49 +1100 Subject: [PATCH 06/20] Adds extra subsctiption functionality for Part and PartCategory - Allows variants and templates - Allows categories and sub-categories - Unit testing --- InvenTree/InvenTree/static/css/inventree.css | 4 - InvenTree/part/api.py | 2 +- InvenTree/part/models.py | 121 ++++++++++++++++--- InvenTree/part/test_part.py | 119 +++++++++++++++++- 4 files changed, 224 insertions(+), 22 deletions(-) diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 670d577497..273f2ec527 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -180,10 +180,6 @@ float: right; } -.starred-part { - color: #ffbb00; -} - .red-cell { background-color: #ec7f7f; } diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index a11bb1b088..0b754dffe8 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -420,7 +420,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView): if 'starred' in request.data: starred = str2bool(request.data.get('starred', None)) - self.get_object().setStarred(request.user, starred) + self.get_object().set_subscription(request.user, starred) response = super().update(request, *args, **kwargs) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index fc7382ac62..0b99b8dac5 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -15,7 +15,7 @@ from django.urls import reverse from django.db import models, transaction from django.db.utils import IntegrityError -from django.db.models import Q, Sum, UniqueConstraint +from django.db.models import Q, Sum, UniqueConstraint, query from django.db.models.functions import Coalesce from django.core.validators import MinValueValidator @@ -201,6 +201,60 @@ class PartCategory(InvenTreeTree): return prefetch.filter(category=self.id) + def get_subscribers(self, include_parents=True): + """ + Return a list of users who subscribe to this PartCategory + """ + + cats = self.get_ancestors(include_self=True) + + subscribers = set() + + if include_parents: + queryset = PartCategoryStar.objects.filter( + category__pk__in=[cat.pk for cat in cats] + ) + else: + queryset = PartCategoryStar.objects.filter( + category=self, + ) + + for result in queryset: + subscribers.add(result.user) + + return [s for s in subscribers] + + def is_subscribed_by(self, user, **kwargs): + """ + Returns True if the specified user subscribes to this category + """ + + return user in self.get_subscribers(**kwargs) + + def set_subscription(self, user, status): + """ + Set the "subscription" status of this PartCategory against the specified user + """ + + if not user: + return + + if self.is_subscribed_by(user) == status: + return + + if status: + PartCategoryStar.objects.create( + category=self, + user=user + ) + else: + # Note that this won't actually stop the user being subscribed, + # if the user is subscribed to a parent category + PartCategoryStar.objects.filter( + category=self, + user=user, + ).delete() + @receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log') def before_delete_part_category(sender, instance, using, **kwargs): @@ -332,7 +386,7 @@ class Part(MPTTModel): context = {} - context['starred'] = self.isStarredBy(request.user) + context['starred'] = self.is_subscribed_by(request.user) context['disabled'] = not self.active # Pre-calculate complex queries so they only need to be performed once @@ -1040,30 +1094,65 @@ class Part(MPTTModel): return self.total_stock - self.allocation_count() + self.on_order - def isStarredBy(self, user): - """ Return True if this part has been starred by a particular user """ - - try: - PartStar.objects.get(part=self, user=user) - return True - except PartStar.DoesNotExist: - return False - - def setStarred(self, user, starred): + def get_subscribers(self, include_variants=True, include_categories=True): """ - Set the "starred" status of this Part for the given user + Return a list of users who are 'subscribed' to this part. + + A user may 'subscribe' to this part in the following ways: + + a) Subscribing to the part instance directly + b) Subscribing to a template part "above" this part (if it is a variant) + c) Subscribing to the part category that this part belongs to + d) Subscribing to a parent category of the category in c) + + """ + + subscribers = set() + + # Start by looking at direct subscriptions to a Part model + queryset = PartStar.objects.all() + + if include_variants: + queryset = queryset.filter( + part__pk__in=[part.pk for part in self.get_ancestors(include_self=True)] + ) + else: + queryset = queryset.filter(part=self) + + for star in queryset: + subscribers.add(star.user) + + if include_categories and self.category: + + for sub in self.category.get_subscribers(): + subscribers.add(sub) + + return [s for s in subscribers] + + def is_subscribed_by(self, user, **kwargs): + """ + Return True if the specified user subscribes to this part + """ + + return user in self.get_subscribers(**kwargs) + + def set_subscription(self, user, status): + """ + Set the "subscription" status of this Part against the specified user """ if not user: return - # Do not duplicate efforts - if self.isStarredBy(user) == starred: + # Already subscribed? + if self.is_subscribed_by(user) == status: return - if starred: + if status: PartStar.objects.create(part=self, user=user) else: + # Note that this won't actually stop the user being subscribed, + # if the user is subscribed to a parent part or category PartStar.objects.filter(part=self, user=user).delete() def need_to_restock(self): diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 1bd9fdf87d..39bb6a39af 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError import os -from .models import Part, PartCategory, PartTestTemplate +from .models import Part, PartCategory, PartCategoryStar, PartStar, PartTestTemplate from .models import rename_part_image from .templatetags import inventree_extras @@ -347,3 +347,120 @@ class PartSettingsTest(TestCase): with self.assertRaises(ValidationError): part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C') part.full_clean() + + +class PartSubscriptionTests(TestCase): + + fixtures = [ + 'location', + 'category', + 'part', + ] + + def setUp(self): + # Create a user for auth + user = get_user_model() + + self.user = user.objects.create_user( + username='testuser', + email='test@testing.com', + password='password', + is_staff=True + ) + + # electronics / IC / MCU + self.category = PartCategory.objects.get(pk=4) + + self.part = Part.objects.create( + category=self.category, + name='STM32F103', + description='Currently worth a lot of money', + is_template=True, + ) + + def test_part_subcription(self): + """ + Test basic subscription against a part + """ + + # First check that the user is *not* subscribed to the part + self.assertFalse(self.part.is_subscribed_by(self.user)) + + # Now, subscribe directly to the part + self.part.set_subscription(self.user, True) + + self.assertEqual(PartStar.objects.count(), 1) + + self.assertTrue(self.part.is_subscribed_by(self.user)) + + # Now, unsubscribe + self.part.set_subscription(self.user, False) + + self.assertFalse(self.part.is_subscribed_by(self.user)) + + def test_variant_subscription(self): + """ + Test subscription against a parent part + """ + + # Construct a sub-part to star against + sub_part = Part.objects.create( + name='sub_part', + description='a sub part', + variant_of=self.part, + ) + + self.assertFalse(sub_part.is_subscribed_by(self.user)) + + # Subscribe to the "parent" part + self.part.set_subscription(self.user, True) + + self.assertTrue(self.part.is_subscribed_by(self.user)) + self.assertTrue(sub_part.is_subscribed_by(self.user)) + + def test_category_subscription(self): + """ + Test subscription against a PartCategory + """ + + self.assertEqual(PartCategoryStar.objects.count(), 0) + + self.assertFalse(self.part.is_subscribed_by(self.user)) + self.assertFalse(self.category.is_subscribed_by(self.user)) + + # Subscribe to the direct parent category + self.category.set_subscription(self.user, True) + + self.assertEqual(PartStar.objects.count(), 0) + self.assertEqual(PartCategoryStar.objects.count(), 1) + + self.assertTrue(self.category.is_subscribed_by(self.user)) + self.assertTrue(self.part.is_subscribed_by(self.user)) + + # Check that the "parent" category is not starred + self.assertFalse(self.category.parent.is_subscribed_by(self.user)) + + # Un-subscribe + self.category.set_subscription(self.user, False) + + self.assertFalse(self.category.is_subscribed_by(self.user)) + self.assertFalse(self.part.is_subscribed_by(self.user)) + + def test_parent_category_subscription(self): + """ + Check that a parent category can be subscribed to + """ + + # Top-level "electronics" category + cat = PartCategory.objects.get(pk=1) + + cat.set_subscription(self.user, True) + + # Check base category + self.assertTrue(cat.is_subscribed_by(self.user)) + + # Check lower level category + self.assertTrue(self.category.is_subscribed_by(self.user)) + + # Check part + self.assertTrue(self.part.is_subscribed_by(self.user)) From 7567b8dd63d1d8c26a663cf7eb2146bb4eb38ab4 Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Wed, 3 Nov 2021 23:22:31 +1100 Subject: [PATCH 07/20] MOAR FEATURES: - Add admin view for PartCategoryStar - Add starred status to partcategory API - Can filter by "starred" status - Rename internal functions back to using "starred" (front-end now uses the term "subscribe") --- InvenTree/part/admin.py | 68 ++++++++++---------- InvenTree/part/api.py | 32 ++++++++- InvenTree/part/models.py | 29 +++++---- InvenTree/part/serializers.py | 16 +++++ InvenTree/part/templates/part/part_base.html | 6 +- InvenTree/part/test_part.py | 44 ++++++------- InvenTree/part/views.py | 1 + 7 files changed, 126 insertions(+), 70 deletions(-) diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index 2e434d928d..90543d429d 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -8,13 +8,7 @@ from import_export.resources import ModelResource from import_export.fields import Field import import_export.widgets as widgets -from .models import PartCategory, Part -from .models import PartAttachment, PartStar, PartRelated -from .models import BomItem -from .models import PartParameterTemplate, PartParameter -from .models import PartCategoryParameterTemplate -from .models import PartTestTemplate -from .models import PartSellPriceBreak, PartInternalPriceBreak +import part.models as models from stock.models import StockLocation from company.models import SupplierPart @@ -24,7 +18,7 @@ class PartResource(ModelResource): """ Class for managing Part data import/export """ # ForeignKey fields - category = Field(attribute='category', widget=widgets.ForeignKeyWidget(PartCategory)) + category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory)) default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation)) @@ -32,7 +26,7 @@ class PartResource(ModelResource): category_name = Field(attribute='category__name', readonly=True) - variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(Part)) + variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(models.Part)) suppliers = Field(attribute='supplier_count', readonly=True) @@ -48,7 +42,7 @@ class PartResource(ModelResource): building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget()) class Meta: - model = Part + model = models.Part skip_unchanged = True report_skipped = False clean_model_instances = True @@ -86,14 +80,14 @@ class PartAdmin(ImportExportModelAdmin): class PartCategoryResource(ModelResource): """ Class for managing PartCategory data import/export """ - parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(PartCategory)) + parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory)) parent_name = Field(attribute='parent__name', readonly=True) default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation)) class Meta: - model = PartCategory + model = models.PartCategory skip_unchanged = True report_skipped = False clean_model_instances = True @@ -108,14 +102,14 @@ class PartCategoryResource(ModelResource): super().after_import(dataset, result, using_transactions, dry_run, **kwargs) # Rebuild the PartCategory tree(s) - PartCategory.objects.rebuild() + models.PartCategory.objects.rebuild() class PartCategoryInline(admin.TabularInline): """ Inline for PartCategory model """ - model = PartCategory + model = models.PartCategory class PartCategoryAdmin(ImportExportModelAdmin): @@ -146,6 +140,11 @@ class PartStarAdmin(admin.ModelAdmin): list_display = ('part', 'user') +class PartCategoryStarAdmin(admin.ModelAdmin): + + list_display = ('category', 'user') + + class PartTestTemplateAdmin(admin.ModelAdmin): list_display = ('part', 'test_name', 'required') @@ -159,7 +158,7 @@ class BomItemResource(ModelResource): bom_id = Field(attribute='pk') # ID of the parent part - parent_part_id = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) + parent_part_id = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part)) # IPN of the parent part parent_part_ipn = Field(attribute='part__IPN', readonly=True) @@ -168,7 +167,7 @@ class BomItemResource(ModelResource): parent_part_name = Field(attribute='part__name', readonly=True) # ID of the sub-part - part_id = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(Part)) + part_id = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(models.Part)) # IPN of the sub-part part_ipn = Field(attribute='sub_part__IPN', readonly=True) @@ -233,7 +232,7 @@ class BomItemResource(ModelResource): return fields class Meta: - model = BomItem + model = models.BomItem skip_unchanged = True report_skipped = False clean_model_instances = True @@ -262,16 +261,16 @@ class ParameterTemplateAdmin(ImportExportModelAdmin): class ParameterResource(ModelResource): """ Class for managing PartParameter data import/export """ - part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) + part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part)) part_name = Field(attribute='part__name', readonly=True) - template = Field(attribute='template', widget=widgets.ForeignKeyWidget(PartParameterTemplate)) + template = Field(attribute='template', widget=widgets.ForeignKeyWidget(models.PartParameterTemplate)) template_name = Field(attribute='template__name', readonly=True) class Meta: - model = PartParameter + model = models.PartParameter skip_unchanged = True report_skipped = False clean_model_instance = True @@ -292,7 +291,7 @@ class PartCategoryParameterAdmin(admin.ModelAdmin): class PartSellPriceBreakAdmin(admin.ModelAdmin): class Meta: - model = PartSellPriceBreak + model = models.PartSellPriceBreak list_display = ('part', 'quantity', 'price',) @@ -300,20 +299,21 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin): class PartInternalPriceBreakAdmin(admin.ModelAdmin): class Meta: - model = PartInternalPriceBreak + model = models.PartInternalPriceBreak list_display = ('part', 'quantity', 'price',) -admin.site.register(Part, PartAdmin) -admin.site.register(PartCategory, PartCategoryAdmin) -admin.site.register(PartRelated, PartRelatedAdmin) -admin.site.register(PartAttachment, PartAttachmentAdmin) -admin.site.register(PartStar, PartStarAdmin) -admin.site.register(BomItem, BomItemAdmin) -admin.site.register(PartParameterTemplate, ParameterTemplateAdmin) -admin.site.register(PartParameter, ParameterAdmin) -admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin) -admin.site.register(PartTestTemplate, PartTestTemplateAdmin) -admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin) -admin.site.register(PartInternalPriceBreak, PartInternalPriceBreakAdmin) +admin.site.register(models.Part, PartAdmin) +admin.site.register(models.PartCategory, PartCategoryAdmin) +admin.site.register(models.PartRelated, PartRelatedAdmin) +admin.site.register(models.PartAttachment, PartAttachmentAdmin) +admin.site.register(models.PartStar, PartStarAdmin) +admin.site.register(models.PartCategoryStar, PartCategoryStarAdmin) +admin.site.register(models.BomItem, BomItemAdmin) +admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin) +admin.site.register(models.PartParameter, ParameterAdmin) +admin.site.register(models.PartCategoryParameterTemplate, PartCategoryParameterAdmin) +admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin) +admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin) +admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 0b754dffe8..20447a4d26 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -58,6 +58,14 @@ class CategoryList(generics.ListCreateAPIView): queryset = PartCategory.objects.all() serializer_class = part_serializers.CategorySerializer + def get_serializer_context(self): + + ctx = super().get_serializer_context() + + ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()] + + return ctx + def filter_queryset(self, queryset): """ Custom filtering: @@ -110,6 +118,18 @@ class CategoryList(generics.ListCreateAPIView): except (ValueError, PartCategory.DoesNotExist): pass + # Filter by "starred" status + starred = params.get('starred', None) + + if starred is not None: + starred = str2bool(starred) + starred_categories = [star.category.pk for star in self.request.user.starred_categories.all()] + + if starred: + queryset = queryset.filter(pk__in=starred_categories) + else: + queryset = queryset.exclude(pk__in=starred_categories) + return queryset filter_backends = [ @@ -149,6 +169,14 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = part_serializers.CategorySerializer queryset = PartCategory.objects.all() + def get_serializer_context(self): + + ctx = super().get_serializer_context() + + ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()] + + return ctx + class CategoryParameterList(generics.ListAPIView): """ API endpoint for accessing a list of PartCategoryParameterTemplate objects. @@ -389,7 +417,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView): # Ensure the request context is passed through kwargs['context'] = self.get_serializer_context() - # Pass a list of "starred" parts fo the current user to the serializer + # Pass a list of "starred" parts of the current user to the serializer # We do this to reduce the number of database queries required! if self.starred_parts is None and self.request is not None: self.starred_parts = [star.part for star in self.request.user.starred_parts.all()] @@ -420,7 +448,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView): if 'starred' in request.data: starred = str2bool(request.data.get('starred', None)) - self.get_object().set_subscription(request.user, starred) + self.get_object().set_starred(request.user, starred) response = super().update(request, *args, **kwargs) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 0b99b8dac5..dada6f125b 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -15,7 +15,7 @@ from django.urls import reverse from django.db import models, transaction from django.db.utils import IntegrityError -from django.db.models import Q, Sum, UniqueConstraint, query +from django.db.models import Q, Sum, UniqueConstraint from django.db.models.functions import Coalesce from django.core.validators import MinValueValidator @@ -102,11 +102,11 @@ class PartCategory(InvenTreeTree): if cascade: """ Select any parts which exist in this category or any child categories """ - query = Part.objects.filter(category__in=self.getUniqueChildren(include_self=True)) + queryset = Part.objects.filter(category__in=self.getUniqueChildren(include_self=True)) else: - query = Part.objects.filter(category=self.pk) + queryset = Part.objects.filter(category=self.pk) - return query + return queryset @property def item_count(self): @@ -224,14 +224,14 @@ class PartCategory(InvenTreeTree): return [s for s in subscribers] - def is_subscribed_by(self, user, **kwargs): + def is_starred_by(self, user, **kwargs): """ Returns True if the specified user subscribes to this category """ return user in self.get_subscribers(**kwargs) - def set_subscription(self, user, status): + def set_starred(self, user, status): """ Set the "subscription" status of this PartCategory against the specified user """ @@ -239,7 +239,7 @@ class PartCategory(InvenTreeTree): if not user: return - if self.is_subscribed_by(user) == status: + if self.is_starred_by(user) == status: return if status: @@ -386,9 +386,16 @@ class Part(MPTTModel): context = {} - context['starred'] = self.is_subscribed_by(request.user) context['disabled'] = not self.active + # Subscription status + context['starred'] = self.is_starred_by(request.user) + context['starred_directly'] = context['starred'] and self.is_starred_by( + request.user, + include_variants=False, + include_categories=False + ) + # Pre-calculate complex queries so they only need to be performed once context['total_stock'] = self.total_stock @@ -1129,14 +1136,14 @@ class Part(MPTTModel): return [s for s in subscribers] - def is_subscribed_by(self, user, **kwargs): + def is_starred_by(self, user, **kwargs): """ Return True if the specified user subscribes to this part """ return user in self.get_subscribers(**kwargs) - def set_subscription(self, user, status): + def set_starred(self, user, status): """ Set the "subscription" status of this Part against the specified user """ @@ -1145,7 +1152,7 @@ class Part(MPTTModel): return # Already subscribed? - if self.is_subscribed_by(user) == status: + if self.is_starred_by(user) == status: return if status: diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index ff1fb2c8c6..981d143507 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -33,12 +33,27 @@ from .models import (BomItem, BomItemSubstitute, class CategorySerializer(InvenTreeModelSerializer): """ Serializer for PartCategory """ + def __init__(self, *args, **kwargs): + + self.starred_categories = kwargs.pop('starred_categories', []) + + super().__init__(*args, **kwargs) + + def get_starred(self, category): + """ + Return True if the category is directly "starred" by the current user + """ + + return category in self.starred_categories + url = serializers.CharField(source='get_absolute_url', read_only=True) parts = serializers.IntegerField(source='item_count', read_only=True) level = serializers.IntegerField(read_only=True) + starred = serializers.SerializerMethodField() + class Meta: model = PartCategory fields = [ @@ -51,6 +66,7 @@ class CategorySerializer(InvenTreeModelSerializer): 'parent', 'parts', 'pathstring', + 'starred', 'url', ] diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 1dcb509a59..21e26c64c6 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -23,10 +23,14 @@ {% include "admin_button.html" with url=url %} {% endif %} -{% if starred %} +{% if starred_directly %} <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to nofications for this part" %}'> <span id='part-star-icon' class='fas fa-bell icon-green'/> </button> +{% elif starred %} +<button type='button' class='btn btn-outline-secondary' title='{% trans "You are subscribed to notifications for this part" %}' disabled='true'> + <span class='fas fa-bell icon-green'></span> +</button> {% else %} <button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this part" %}'> <span id='part-star-icon' class='fa fa-bell-slash'/> diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 39bb6a39af..755bd45cea 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -384,19 +384,19 @@ class PartSubscriptionTests(TestCase): """ # First check that the user is *not* subscribed to the part - self.assertFalse(self.part.is_subscribed_by(self.user)) + self.assertFalse(self.part.is_starred_by(self.user)) # Now, subscribe directly to the part - self.part.set_subscription(self.user, True) + self.part.set_starred(self.user, True) self.assertEqual(PartStar.objects.count(), 1) - self.assertTrue(self.part.is_subscribed_by(self.user)) + self.assertTrue(self.part.is_starred_by(self.user)) # Now, unsubscribe - self.part.set_subscription(self.user, False) + self.part.set_starred(self.user, False) - self.assertFalse(self.part.is_subscribed_by(self.user)) + self.assertFalse(self.part.is_starred_by(self.user)) def test_variant_subscription(self): """ @@ -410,13 +410,13 @@ class PartSubscriptionTests(TestCase): variant_of=self.part, ) - self.assertFalse(sub_part.is_subscribed_by(self.user)) + self.assertFalse(sub_part.is_starred_by(self.user)) # Subscribe to the "parent" part - self.part.set_subscription(self.user, True) + self.part.set_starred(self.user, True) - self.assertTrue(self.part.is_subscribed_by(self.user)) - self.assertTrue(sub_part.is_subscribed_by(self.user)) + self.assertTrue(self.part.is_starred_by(self.user)) + self.assertTrue(sub_part.is_starred_by(self.user)) def test_category_subscription(self): """ @@ -425,26 +425,26 @@ class PartSubscriptionTests(TestCase): self.assertEqual(PartCategoryStar.objects.count(), 0) - self.assertFalse(self.part.is_subscribed_by(self.user)) - self.assertFalse(self.category.is_subscribed_by(self.user)) + self.assertFalse(self.part.is_starred_by(self.user)) + self.assertFalse(self.category.is_starred_by(self.user)) # Subscribe to the direct parent category - self.category.set_subscription(self.user, True) + self.category.set_starred(self.user, True) self.assertEqual(PartStar.objects.count(), 0) self.assertEqual(PartCategoryStar.objects.count(), 1) - self.assertTrue(self.category.is_subscribed_by(self.user)) - self.assertTrue(self.part.is_subscribed_by(self.user)) + self.assertTrue(self.category.is_starred_by(self.user)) + self.assertTrue(self.part.is_starred_by(self.user)) # Check that the "parent" category is not starred - self.assertFalse(self.category.parent.is_subscribed_by(self.user)) + self.assertFalse(self.category.parent.is_starred_by(self.user)) # Un-subscribe - self.category.set_subscription(self.user, False) + self.category.set_starred(self.user, False) - self.assertFalse(self.category.is_subscribed_by(self.user)) - self.assertFalse(self.part.is_subscribed_by(self.user)) + self.assertFalse(self.category.is_starred_by(self.user)) + self.assertFalse(self.part.is_starred_by(self.user)) def test_parent_category_subscription(self): """ @@ -454,13 +454,13 @@ class PartSubscriptionTests(TestCase): # Top-level "electronics" category cat = PartCategory.objects.get(pk=1) - cat.set_subscription(self.user, True) + cat.set_starred(self.user, True) # Check base category - self.assertTrue(cat.is_subscribed_by(self.user)) + self.assertTrue(cat.is_starred_by(self.user)) # Check lower level category - self.assertTrue(self.category.is_subscribed_by(self.user)) + self.assertTrue(self.category.is_starred_by(self.user)) # Check part - self.assertTrue(self.part.is_subscribed_by(self.user)) + self.assertTrue(self.part.is_starred_by(self.user)) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 5a4167ea05..de4bbf5443 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -412,6 +412,7 @@ class PartDetail(InvenTreeRoleMixin, DetailView): part = self.get_object() ctx = part.get_context_data(self.request) + context.update(**ctx) # Pricing information From 193d6b334c7f70e7222bf8e725b37d3717e63c1c Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Wed, 3 Nov 2021 23:29:36 +1100 Subject: [PATCH 08/20] Add option to display "starred categories" on the index page --- InvenTree/common/models.py | 6 ++++++ InvenTree/templates/InvenTree/index.html | 13 ++++++++++++- .../templates/InvenTree/settings/user_homepage.html | 1 + InvenTree/templates/js/dynamic/inventree.js | 7 ++++++- InvenTree/templates/js/translated/part.js | 6 +++--- 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 125941be14..bbc8a6721a 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -879,6 +879,12 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'default': True, 'validator': bool, }, + 'HOMEPAGE_CATEGORY_STARRED': { + 'name': _('Show subscribed categories'), + 'description': _('Show subscribed part categories on the homepage'), + 'default': True, + 'validator': bool, + }, 'HOMEPAGE_PART_LATEST': { 'name': _('Show latest parts'), 'description': _('Show latest parts on the homepage'), diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html index 2c407fdcd9..b87ec6d0dc 100644 --- a/InvenTree/templates/InvenTree/index.html +++ b/InvenTree/templates/InvenTree/index.html @@ -76,6 +76,7 @@ function addHeaderAction(label, title, icon, options) { } {% settings_value 'HOMEPAGE_PART_STARRED' user=request.user as setting_part_starred %} +{% settings_value 'HOMEPAGE_CATEGORY_STARRED' user=request.user as setting_category_starred %} {% settings_value 'HOMEPAGE_PART_LATEST' user=request.user as setting_part_latest %} {% settings_value 'HOMEPAGE_BOM_VALIDATION' user=request.user as setting_bom_validation %} {% to_list setting_part_starred setting_part_latest setting_bom_validation as settings_list_part %} @@ -87,12 +88,22 @@ addHeaderTitle('{% trans "Parts" %}'); addHeaderAction('starred-parts', '{% trans "Subscribed Parts" %}', 'fa-bell'); loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", { params: { - "starred": true, + starred: true, }, name: 'starred_parts', }); {% endif %} +{% if setting_category_starred %} +addHeaderAction('starred-categories', '{% trans "Subscribed Categories" %}', 'fa-bell'); +loadPartCategoryTable($('#table-starred-categories'), { + params: { + starred: true, + }, + name: 'starred_categories' +}); +{% endif %} + {% if setting_part_latest %} addHeaderAction('latest-parts', '{% trans "Latest Parts" %}', 'fa-newspaper'); loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", { diff --git a/InvenTree/templates/InvenTree/settings/user_homepage.html b/InvenTree/templates/InvenTree/settings/user_homepage.html index 8219187044..54e3bdcefd 100644 --- a/InvenTree/templates/InvenTree/settings/user_homepage.html +++ b/InvenTree/templates/InvenTree/settings/user_homepage.html @@ -15,6 +15,7 @@ <table class='table table-striped table-condensed'> <tbody> {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" icon='fa-bell' user_setting=True %} + {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_CATEGORY_STARRED" icon='fa-bell' user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_LATEST" icon='fa-history' user_setting=True %} {% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BOM_VALIDATION" user_setting=True %} diff --git a/InvenTree/templates/js/dynamic/inventree.js b/InvenTree/templates/js/dynamic/inventree.js index 0172e47706..1774ba6f3d 100644 --- a/InvenTree/templates/js/dynamic/inventree.js +++ b/InvenTree/templates/js/dynamic/inventree.js @@ -169,7 +169,12 @@ function inventreeDocReady() { html += '</span>'; if (user_settings.SEARCH_SHOW_STOCK_LEVELS) { - html += partStockLabel(item.data); + html += partStockLabel( + item.data, + { + classes: 'badge-right', + } + ); } html += '</a>'; diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index adc10566d7..b87e90dcc8 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -420,12 +420,12 @@ function toggleStar(options) { } -function partStockLabel(part) { +function partStockLabel(part, options={}) { if (part.in_stock) { - return `<span class='badge rounded-pill bg-success'>{% trans "Stock" %}: ${part.in_stock}</span>`; + return `<span class='badge rounded-pill bg-success ${options.classes}'>{% trans "Stock" %}: ${part.in_stock}</span>`; } else { - return `<span class='badge rounded-pill bg-danger'>{% trans "No Stock" %}</span>`; + return `<span class='badge rounded-pill bg-danger ${options.classes}'>{% trans "No Stock" %}</span>`; } } From 1c6eb41341cbc564d47d212a87c3381ea2099021 Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Thu, 4 Nov 2021 00:01:52 +1100 Subject: [PATCH 09/20] Ability to toggle part category "star" status via the API --- InvenTree/build/templates/build/detail.html | 4 +- InvenTree/part/api.py | 13 +++++- InvenTree/part/serializers.py | 4 +- InvenTree/part/templates/part/category.html | 44 ++++++++++++++++--- InvenTree/part/templates/part/part_base.html | 2 +- InvenTree/part/views.py | 11 +++++ InvenTree/templates/js/translated/part.js | 30 ++++++++----- .../templates/js/translated/table_filters.js | 6 ++- 8 files changed, 88 insertions(+), 26 deletions(-) diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index d53122cdd1..31e9f38080 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -247,7 +247,9 @@ <span class='fas fa-tools'></span> <span class='caret'></span> </button> <ul class='dropdown-menu'> - <li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'><span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}</a></li> + <li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'> + <span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %} + </a></li> </ul> </div> </div> diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 20447a4d26..dc521b42c6 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -177,6 +177,17 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView): return ctx + def update(self, request, *args, **kwargs): + + if 'starred' in request.data: + starred = str2bool(request.data.get('starred', False)) + + self.get_object().set_starred(request.user, starred) + + response = super().update(request, *args, **kwargs) + + return response + class CategoryParameterList(generics.ListAPIView): """ API endpoint for accessing a list of PartCategoryParameterTemplate objects. @@ -446,7 +457,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView): """ if 'starred' in request.data: - starred = str2bool(request.data.get('starred', None)) + starred = str2bool(request.data.get('starred', False)) self.get_object().set_starred(request.user, starred) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 981d143507..3b6d823ddc 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -35,8 +35,6 @@ class CategorySerializer(InvenTreeModelSerializer): def __init__(self, *args, **kwargs): - self.starred_categories = kwargs.pop('starred_categories', []) - super().__init__(*args, **kwargs) def get_starred(self, category): @@ -44,7 +42,7 @@ class CategorySerializer(InvenTreeModelSerializer): Return True if the category is directly "starred" by the current user """ - return category in self.starred_categories + return category in self.context.get('starred_categories', []) url = serializers.CharField(source='get_absolute_url', read_only=True) diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 03369b093d..48677ee71d 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -20,15 +20,37 @@ {% include "admin_button.html" with url=url %} {% endif %} {% if category %} -{% if roles.part_category.change %} -<button class='btn btn-outline-secondary' id='cat-edit' title='{% trans "Edit part category" %}'> - <span class='fas fa-edit'/> +{% if starred_directly %} +<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to notifications for this category" %}'> + <span id='category-star-icon' class='fas fa-bell icon-green'></span> +</button> +{% elif starred %} +<button type='button' class='btn btn-outline-secondary' title='{% trans "You are subscribed to notifications for this category" %}' disabled='true'> + <span class='fas fa-bell icon-green'></span> +</button> +{% else %} +<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "Subscribe to nofications for this category" %}'> + <span id='category-star-icon' class='fa fa-bell-slash'/> </button> {% endif %} -{% if roles.part_category.delete %} -<button class='btn btn-outline-secondary' id='cat-delete' title='{% trans "Delete part category" %}'> - <span class='fas fa-trash-alt icon-red'/> -</button> +{% if roles.part_category.change or roles.part_category.delete %} +<div class='btn-group' role='group'> + <button id='category-options' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Category Actions" %}'> + <span class='fas fa-tools'></span> <span class='caret'></span> + </button> + <ul class='dropdown-menu'> + {% if roles.part_category.change %} + <li><a class='dropdown-item' href='#' id='cat-edit' title='{% trans "Edit category" %}'> + <span class='fas fa-edit icon-green'></span> {% trans "Edit Category" %} + </a></li> + {% endif %} + {% if roles.part_category.delete %} + <li><a class='dropdown-item' href='#' id='cat-delete' title='{% trans "Delete category" %}'> + <span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Category" %} + </a></li> + {% endif %} + </ul> +</div> {% endif %} {% endif %} {% if roles.part_category.add %} @@ -198,6 +220,14 @@ data: {{ parameters|safe }}, } ); + + $("#toggle-starred").click(function() { + toggleStar({ + url: '{% url "api-part-category-detail" category.pk %}', + button: '#category-star-icon' + }); + }); + {% endif %} enableSidebar('category'); diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 21e26c64c6..a4087a3ece 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -320,7 +320,7 @@ $("#toggle-starred").click(function() { toggleStar({ - part: {{ part.id }}, + url: '{% url "api-part-detail" part.pk %}', button: '#part-star-icon', }); }); diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index de4bbf5443..56ab98004d 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -1470,18 +1470,29 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView): if category: cascade = kwargs.get('cascade', True) + # Prefetch parts parameters parts_parameters = category.prefetch_parts_parameters(cascade=cascade) + # Get table headers (unique parameters names) context['headers'] = category.get_unique_parameters(cascade=cascade, prefetch=parts_parameters) + # Insert part information context['headers'].insert(0, 'description') context['headers'].insert(0, 'part') + # Get parameters data context['parameters'] = category.get_parts_parameters(cascade=cascade, prefetch=parts_parameters) + # Insert "starred" information + context['starred'] = category.is_starred_by(self.request.user) + context['starred_directly'] = context['starred'] and category.is_starred_by( + self.request.user, + include_parents=False, + ) + return context diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index b87e90dcc8..e00f04aebd 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -378,19 +378,18 @@ function duplicatePart(pk, options={}) { * * options: * - button: ID of the button (default = '#part-star-icon') - * - part: pk of the part object + * - URL: API url of the object * - user: pk of the user */ function toggleStar(options) { - var url = `/api/part/${options.part}/`; - - inventreeGet(url, {}, { + inventreeGet(options.url, {}, { success: function(response) { + var starred = response.starred; inventreePut( - url, + options.url, { starred: !starred, }, @@ -399,16 +398,16 @@ function toggleStar(options) { success: function(response) { if (response.starred) { $(options.button).removeClass('fa fa-bell-slash').addClass('fas fa-bell icon-green'); - $(options.button).attr('title', '{% trans "You are subscribed to notifications for this part" %}'); + $(options.button).attr('title', '{% trans "You are subscribed to notifications for this item" %}'); - showMessage('{% trans "You have subscribed to notifications for this part" %}', { + showMessage('{% trans "You have subscribed to notifications for this item" %}', { style: 'success', }); } else { $(options.button).removeClass('fas fa-bell icon-green').addClass('fa fa-bell-slash'); - $(options.button).attr('title', '{% trans "Subscribe to notifications for this part" %}'); + $(options.button).attr('title', '{% trans "Subscribe to notifications for this item" %}'); - showMessage('{% trans "You have unsubscribed to notifications for this part" %}', { + showMessage('{% trans "You have unsubscribed to notifications for this item" %}', { style: 'warning', }); } @@ -453,7 +452,7 @@ function makePartIcons(part) { } if (part.starred) { - html += makeIconBadge('fa-star', '{% trans "Starred part" %}'); + html += makeIconBadge('fa-bell icon-green', '{% trans "Subscribed part" %}'); } if (part.salable) { @@ -461,7 +460,7 @@ function makePartIcons(part) { } if (!part.active) { - html += `<span class='badge badge-right rounded-pill bg-warning'>{% trans "Inactive" %}</span>`; + html += `<span class='badge badge-right rounded-pill bg-warning'>{% trans "Inactive" %}</span> `; } return html; @@ -1268,10 +1267,17 @@ function loadPartCategoryTable(table, options) { switchable: true, sortable: true, formatter: function(value, row) { - return renderLink( + + var html = renderLink( value, `/part/category/${row.pk}/` ); + + if (row.starred) { + html += makeIconBadge('fa-bell icon-green', '{% trans "Subscribed category" %}'); + } + + return html; } }, { diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index 4d12f69780..537adefee9 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -103,6 +103,10 @@ function getAvailableTableFilters(tableKey) { title: '{% trans "Include subcategories" %}', description: '{% trans "Include subcategories" %}', }, + starred: { + type: 'bool', + title: '{% trans "Subscribed" %}', + }, }; } @@ -368,7 +372,7 @@ function getAvailableTableFilters(tableKey) { }, starred: { type: 'bool', - title: '{% trans "Starred" %}', + title: '{% trans "Subscribed" %}', }, salable: { type: 'bool', From 476a1342c1f1536eb352228ea6a14f503b99e83b Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Thu, 4 Nov 2021 00:28:10 +1100 Subject: [PATCH 10/20] Improve notification of 'low stock' parts: - Traverse up the variant tree - Enable subscription by "category" --- InvenTree/part/models.py | 19 +++++++++++- InvenTree/part/tasks.py | 31 ++++++++++++------- InvenTree/stock/models.py | 11 ++++--- .../email/low_stock_notification.html | 4 ++- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index dada6f125b..a3c294ea17 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -20,7 +20,7 @@ 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 from django.dispatch import receiver from jinja2 import Template @@ -47,6 +47,7 @@ from InvenTree import validators from InvenTree.models import InvenTreeTree, InvenTreeAttachment from InvenTree.fields import InvenTreeURLField from InvenTree.helpers import decimal2string, normalize, decimal2money +import InvenTree.tasks from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus @@ -56,6 +57,7 @@ from company.models import SupplierPart from stock import models as StockModels import common.models + import part.settings as part_settings @@ -2085,9 +2087,24 @@ class Part(MPTTModel): return len(self.get_related_parts()) def is_part_low_on_stock(self): + """ + Returns True if the total stock for this part is less than the minimum stock level + """ + return self.total_stock <= self.minimum_stock + +@receiver(post_save, sender=Part, dispatch_uid='part_post_save_log') +def after_save_part(sender, instance: Part, **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) + + 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 index 72d996e772..779027a96d 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -13,23 +13,28 @@ from common.models import InvenTree import InvenTree.helpers import InvenTree.tasks -from part.models import Part +import part.models logger = logging.getLogger("inventree") -def notify_low_stock(part: Part): +def notify_low_stock(part: part.models.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) + # Get a list of users who are subcribed to this part + subscribers = part.get_subscribers() + + emails = EmailAddress.objects.filter( + user__in=subscribers, + ) # TODO: In the future, include the part image in the email template - if len(starred_users_email) > 0: + if len(emails) > 0: logger.info(f"Notify users regarding low stock of {part.name}") context = { # Pass the "Part" object through to the template context @@ -39,20 +44,24 @@ def notify_low_stock(part: Part): 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) + recipients = emails.values_list('email', flat=True) InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message) -def notify_low_stock_if_required(part: Part): +def notify_low_stock_if_required(part: part.models.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 - ) + # Run "up" the tree, to allow notification for "parent" parts + parts = part.get_ancestors(include_self=True, ascending=True) + + for p in parts: + if p.is_part_low_on_stock(): + InvenTree.tasks.offload_task( + 'part.tasks.notify_low_stock', + p + ) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 657469a744..eb0e6aa12f 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -27,7 +27,9 @@ from mptt.managers import TreeManager from decimal import Decimal, InvalidOperation from datetime import datetime, timedelta + from InvenTree import helpers +import InvenTree.tasks import common.models import report.models @@ -41,7 +43,6 @@ 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): @@ -1658,16 +1659,18 @@ 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) + # Run this check in the background + InvenTree.tasks.offload_task('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 + Hook function to be executed after StockItem object is saved/updated """ - part_tasks.notify_low_stock_if_required(instance.part) + # Run this check in the background + InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part) class StockItemAttachment(InvenTreeAttachment): diff --git a/InvenTree/templates/email/low_stock_notification.html b/InvenTree/templates/email/low_stock_notification.html index ecb350925a..4db9c2ddaa 100644 --- a/InvenTree/templates/email/low_stock_notification.html +++ b/InvenTree/templates/email/low_stock_notification.html @@ -17,13 +17,15 @@ {% block body %} <tr style="height: 3rem; border-bottom: 1px solid"> <th>{% trans "Part Name" %}</th> - <th>{% trans "Available Quantity" %}</th> + <th>{% trans "Total Stock" %}</th> + <th>{% trans "Available" %}</th> <th>{% trans "Minimum Quantity" %}</th> </tr> <tr style="height: 3rem"> <td style="text-align: center;">{{ part.full_name }}</td> <td style="text-align: center;">{{ part.total_stock }}</td> + <td style="text-align: center;">{{ part.available_stock }}</td> <td style="text-align: center;">{{ part.minimum_stock }}</td> </tr> {% endblock %} From ee7c3ae0664cb38673efccded74e195314fe5b5e Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Thu, 4 Nov 2021 00:38:34 +1100 Subject: [PATCH 11/20] Update index page --- InvenTree/part/serializers.py | 3 +++ InvenTree/part/templates/part/detail.html | 2 +- InvenTree/templates/InvenTree/index.html | 5 ++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 3b6d823ddc..47ce3f66c8 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -255,6 +255,9 @@ class PartSerializer(InvenTreeModelSerializer): to reduce database trips. """ + # TODO: Update the "in_stock" annotation to include stock for variants of the part + # Ref: https://github.com/inventree/InvenTree/issues/2240 + # Annotate with the total 'in stock' quantity queryset = queryset.annotate( in_stock=Coalesce( diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index fc9795b6e0..d3da4df514 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -62,7 +62,7 @@ {% endif %} {% if part.minimum_stock %} <tr> - <td><span class='fas fa-less-than-equal'></span></td> + <td><span class='fas fa-flag'></span></td> <td>{% trans "Minimum stock level" %}</td> <td>{{ part.minimum_stock }}</td> </tr> diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html index b87ec6d0dc..44bc70fc37 100644 --- a/InvenTree/templates/InvenTree/index.html +++ b/InvenTree/templates/InvenTree/index.html @@ -139,8 +139,7 @@ loadSimplePartTable("#table-bom-validation", "{% url 'api-part-list' %}", { {% to_list setting_stock_recent setting_stock_low setting_stock_depleted setting_stock_needed as settings_list_stock %} {% endif %} -{% if roles.stock.view and True in settings_list_stock %} -addHeaderTitle('{% trans "Stock" %}'); +{% if roles.stock.view %} {% if setting_stock_recent %} addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock'); @@ -156,7 +155,7 @@ loadStockTable($('#table-recently-updated-stock'), { {% endif %} {% if setting_stock_low %} -addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart'); +addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-flag'); loadSimplePartTable("#table-low-stock", "{% url 'api-part-list' %}", { params: { low_stock: true, From 55425322232835ead9ba419bb8e0023091c314ce Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Thu, 4 Nov 2021 00:44:16 +1100 Subject: [PATCH 12/20] Template tweaks --- InvenTree/part/models.py | 1 - InvenTree/part/templates/part/detail.html | 2 +- InvenTree/part/templates/part/part_base.html | 9 +++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index a3c294ea17..1c50bc321e 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2094,7 +2094,6 @@ class Part(MPTTModel): return self.total_stock <= self.minimum_stock - @receiver(post_save, sender=Part, dispatch_uid='part_post_save_log') def after_save_part(sender, instance: Part, **kwargs): """ diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index d3da4df514..706bf5e329 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -35,7 +35,7 @@ <td><span class='fas fa-sitemap'></span></td> <td>{% trans "Category" %}</td> <td> - <a href='{% url "category-detail" part.category.pk %}'>{{ part.category }}</a> + <a href='{% url "category-detail" part.category.pk %}'>{{ part.category.name }}</a> </td> </tr> {% endif %} diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index a4087a3ece..bb7aea3abb 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -147,8 +147,6 @@ </div> </h4> - - <!-- Part info messages --> <div class='info-messages'> {% if part.variant_of %} @@ -174,6 +172,13 @@ <td>{% trans "In Stock" %}</td> <td>{% include "part/stock_count.html" %}</td> </tr> + {% if part.minimum_stock %} + <tr> + <td><span class='fas fa-flag'></span></td> + <td>{% trans "Minimum Stock" %}</td> + <td>{{ part.minimum_stock }}</td> + </tr> + {% endif %} {% if on_order > 0 %} <tr> <td><span class='fas fa-shopping-cart'></span></td> From ef2307aeaa5e5700bdb8da01acc0ee10b55e0c8c Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Thu, 4 Nov 2021 00:46:23 +1100 Subject: [PATCH 13/20] Add new model to permissions table --- InvenTree/users/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index d31f2a9905..a7016cbf96 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -77,6 +77,7 @@ class RuleSet(models.Model): 'part_category': [ 'part_partcategory', 'part_partcategoryparametertemplate', + 'part_partcategorystar', ], 'part': [ 'part_part', @@ -90,6 +91,7 @@ class RuleSet(models.Model): 'part_partparameter', 'part_partrelated', 'part_partstar', + 'part_partcategorystar', 'company_supplierpart', 'company_manufacturerpart', 'company_manufacturerpartparameter', From e7b93a54d82e542be6bcdac218c7b9bf0a96ea98 Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Thu, 4 Nov 2021 00:55:43 +1100 Subject: [PATCH 14/20] Add new model "NotificationEntry" - Keep track of past notifications --- .../migrations/0012_notificationentry.py | 25 +++++++++++++++ InvenTree/common/models.py | 31 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 InvenTree/common/migrations/0012_notificationentry.py diff --git a/InvenTree/common/migrations/0012_notificationentry.py b/InvenTree/common/migrations/0012_notificationentry.py new file mode 100644 index 0000000000..77439c9f8c --- /dev/null +++ b/InvenTree/common/migrations/0012_notificationentry.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.5 on 2021-11-03 13:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0011_auto_20210722_2114'), + ] + + operations = [ + migrations.CreateModel( + name='NotificationEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=250)), + ('uid', models.IntegerField()), + ('updated', models.DateTimeField(auto_now=True)), + ], + options={ + 'unique_together': {('key', 'uid')}, + }, + ), + ] diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index bbc8a6721a..559a8dc003 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1226,3 +1226,34 @@ class ColorTheme(models.Model): return True return False + + +class NotificationEntry(models.Model): + """ + A NotificationEntry records the last time a particular notifaction was sent out. + + It is recorded to ensure that notifications are not sent out "too often" to users. + + Attributes: + - key: A text entry describing the notification e.g. 'part.notify_low_stock' + - uid: An (optional) numerical ID for a particular instance + - date: The last time this notification was sent + """ + + class Meta: + unique_together = [ + ('key', 'uid'), + ] + + key = models.CharField( + max_length=250, + blank=False, + ) + + uid = models.IntegerField( + ) + + updated = models.DateTimeField( + auto_now=True, + null=False, + ) From 1f7676ee6581b19731d439ef621e7675e31d638f Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Thu, 4 Nov 2021 01:06:57 +1100 Subject: [PATCH 15/20] Add admin entry for new model --- InvenTree/common/admin.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/InvenTree/common/admin.py b/InvenTree/common/admin.py index 1eda18e869..4df2499177 100644 --- a/InvenTree/common/admin.py +++ b/InvenTree/common/admin.py @@ -5,7 +5,7 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin -from .models import InvenTreeSetting, InvenTreeUserSetting +import common.models class SettingsAdmin(ImportExportModelAdmin): @@ -18,5 +18,11 @@ class UserSettingsAdmin(ImportExportModelAdmin): list_display = ('key', 'value', 'user', ) -admin.site.register(InvenTreeSetting, SettingsAdmin) -admin.site.register(InvenTreeUserSetting, UserSettingsAdmin) +class NotificationEntryAdmin(admin.ModelAdmin): + + list_display = ('key', 'uid', 'updated', ) + + +admin.site.register(common.models.InvenTreeSetting, SettingsAdmin) +admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin) +admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin) From bebf368d06f339dfb5fa285efd642fb0566507c7 Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Thu, 4 Nov 2021 01:11:42 +1100 Subject: [PATCH 16/20] Add functionality and unit testing for new model --- InvenTree/common/models.py | 30 ++++++++++++++++++++++++++++++ InvenTree/common/tests.py | 25 +++++++++++++++++++++++++ InvenTree/users/models.py | 1 + 3 files changed, 56 insertions(+) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 559a8dc003..bc1463ca00 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -9,6 +9,7 @@ from __future__ import unicode_literals import os import decimal import math +from datetime import datetime, timedelta from django.db import models, transaction from django.contrib.auth.models import User, Group @@ -1257,3 +1258,32 @@ class NotificationEntry(models.Model): auto_now=True, null=False, ) + + @classmethod + def check_recent(cls, key: str, uid: int, delta: timedelta): + """ + Test if a particular notification has been sent in the specified time period + """ + + since = datetime.now().date() - delta + + entries = cls.objects.filter( + key=key, + uid=uid, + updated__gte=since + ) + + return entries.exists() + + @classmethod + def notify(cls, key: str, uid: int): + """ + Notify the database that a particular notification has been sent out + """ + + entry, created = cls.objects.get_or_create( + key=key, + uid=uid + ) + + entry.save() diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index d20f76baa0..63023da5cb 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -1,10 +1,13 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from datetime import timedelta + from django.test import TestCase from django.contrib.auth import get_user_model from .models import InvenTreeSetting +from .models import NotificationEntry class SettingsTest(TestCase): @@ -85,3 +88,25 @@ class SettingsTest(TestCase): if setting.default_value not in [True, False]: raise ValueError(f'Non-boolean default value specified for {key}') + + +class NotificationTest(TestCase): + + def test_check_notification_entries(self): + + # Create some notification entries + + self.assertEqual(NotificationEntry.objects.count(), 0) + + NotificationEntry.notify('test.notification', 1) + + self.assertEqual(NotificationEntry.objects.count(), 1) + + delta = timedelta(days=1) + + self.assertFalse(NotificationEntry.check_recent('test.notification', 2, delta)) + self.assertFalse(NotificationEntry.check_recent('test.notification2', 1, delta)) + + self.assertTrue(NotificationEntry.check_recent('test.notification', 1, delta)) + + diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index a7016cbf96..4d1b46ae5d 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -151,6 +151,7 @@ class RuleSet(models.Model): 'common_colortheme', 'common_inventreesetting', 'common_inventreeusersetting', + 'common_notificationentry', 'company_contact', 'users_owner', From a447e22108d8934a6b9f0834ff54cbea8a6836c0 Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Thu, 4 Nov 2021 01:18:00 +1100 Subject: [PATCH 17/20] Prevent low-stock notifications from overwhelming users - Limit to once per day, per part --- InvenTree/part/tasks.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py index 779027a96d..f4f1459214 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -2,13 +2,14 @@ from __future__ import unicode_literals import logging +from datetime import timedelta 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 +from common.models import NotificationEntry import InvenTree.helpers import InvenTree.tasks @@ -23,6 +24,13 @@ def notify_low_stock(part: part.models.Part): Notify users who have starred a part when its stock quantity falls below the minimum threshold """ + # Check if we have notified recently... + delta = timedelta(days=1) + + if NotificationEntry.check_recent('part.notify_low_stock', part.pk, delta): + logger.info(f"Low stock notification has recently been sent for '{part.full_name}' - SKIPPING") + return + logger.info(f"Sending low stock notification email for {part.full_name}") # Get a list of users who are subcribed to this part @@ -48,6 +56,8 @@ def notify_low_stock(part: part.models.Part): InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message) + NotificationEntry.notify('part.notify_low_stock', part.pk) + def notify_low_stock_if_required(part: part.models.Part): """ From 6c724556f1de5020a9b98e86e690177557ee3684 Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Thu, 4 Nov 2021 01:21:08 +1100 Subject: [PATCH 18/20] PEP fixes --- InvenTree/common/tests.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index 63023da5cb..c20dc5d126 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -108,5 +108,3 @@ class NotificationTest(TestCase): self.assertFalse(NotificationEntry.check_recent('test.notification2', 1, delta)) self.assertTrue(NotificationEntry.check_recent('test.notification', 1, delta)) - - From 3a61d11f5a64166e2d8e1c8283ed4e7f301a48ce Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Thu, 4 Nov 2021 01:31:26 +1100 Subject: [PATCH 19/20] Adds a scheduled task to remove old notification entries from the database --- InvenTree/InvenTree/apps.py | 6 ++++ .../management/commands/rebuild_thumbnails.py | 2 +- InvenTree/common/tasks.py | 29 +++++++++++++++++++ tasks.py | 1 + 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 InvenTree/common/tasks.py diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 31a887d736..5f347dd1e5 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -76,6 +76,12 @@ class InvenTreeConfig(AppConfig): minutes=30, ) + # Delete old notification records + InvenTree.tasks.schedule_task( + 'common.tasks.delete_old_notifications', + schedule_type=Schedule.DAILY, + ) + def update_exchange_rates(self): """ Update exchange rates each time the server is started, *if*: diff --git a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py index 07e700a1cf..bf36a612d1 100644 --- a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py +++ b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py @@ -17,7 +17,7 @@ from company.models import Company from part.models import Part -logger = logging.getLogger("inventree-thumbnails") +logger = logging.getLogger('inventree') class Command(BaseCommand): diff --git a/InvenTree/common/tasks.py b/InvenTree/common/tasks.py new file mode 100644 index 0000000000..409acf5a13 --- /dev/null +++ b/InvenTree/common/tasks.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import logging +from datetime import timedelta, datetime + +from django.core.exceptions import AppRegistryNotReady + + +logger = logging.getLogger('inventree') + + +def delete_old_notifications(): + """ + Remove old notifications from the database. + + Anything older than ~3 months is removed + """ + + try: + from common.models import NotificationEntry + except AppRegistryNotReady: + logger.info("Could not perform 'delete_old_notifications' - App registry not ready") + return + + before = datetime.now() - timedelta(days=90) + + # Delete notification records before the specified date + NotificationEntry.objects.filter(updated__lte=before).delete() diff --git a/tasks.py b/tasks.py index 59fa83e56b..c960fb8657 100644 --- a/tasks.py +++ b/tasks.py @@ -286,6 +286,7 @@ def content_excludes(): "users.owner", "exchange.rate", "exchange.exchangebackend", + "common.notificationentry", ] output = "" From 52242e7a00f01e3d3bfad6e7628d1c98ef04d18a Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Thu, 4 Nov 2021 08:40:38 +1100 Subject: [PATCH 20/20] Catch error --- InvenTree/part/api.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index dc521b42c6..b08834445c 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -62,7 +62,11 @@ class CategoryList(generics.ListCreateAPIView): ctx = super().get_serializer_context() - ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()] + try: + ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()] + except AttributeError: + # Error is thrown if the view does not have an associated request + ctx['starred_categories'] = [] return ctx @@ -173,7 +177,11 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView): ctx = super().get_serializer_context() - ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()] + try: + ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()] + except AttributeError: + # Error is thrown if the view does not have an associated request + ctx['starred_categories'] = [] return ctx