From 4a0ed4b2a1c69dcc17238855a226f95f5a8bed43 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 19 Oct 2021 22:49:48 +1100 Subject: [PATCH 01/77] Start of API forms for stock item --- InvenTree/stock/serializers.py | 9 +- .../stock/templates/stock/item_base.html | 5 ++ InvenTree/templates/js/translated/forms.js | 12 +++ InvenTree/templates/js/translated/stock.js | 89 +++++++++++++++++++ 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index c44dffe94f..e2781af12f 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -134,7 +134,7 @@ class StockItemSerializer(InvenTreeModelSerializer): tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True, required=False) - quantity = serializers.FloatField() + # quantity = serializers.FloatField() allocated = serializers.FloatField(source='allocation_count', required=False) @@ -142,20 +142,22 @@ class StockItemSerializer(InvenTreeModelSerializer): stale = serializers.BooleanField(required=False, read_only=True) - serial = serializers.CharField(required=False) + # serial = serializers.CharField(required=False) required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False) purchase_price = InvenTreeMoneySerializer( label=_('Purchase Price'), max_digits=19, decimal_places=4, - allow_null=True + allow_null=True, + help_text=_('Purchase price of this stock item'), ) purchase_price_currency = serializers.ChoiceField( choices=currency_code_mappings(), default=currency_code_default, label=_('Currency'), + help_text=_('Purchase currency of this stock item'), ) purchase_price_string = serializers.SerializerMethodField() @@ -197,6 +199,7 @@ class StockItemSerializer(InvenTreeModelSerializer): 'belongs_to', 'build', 'customer', + 'delete_on_deplete', 'expired', 'expiry_date', 'in_stock', diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index b7019ff887..97b94bdd70 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -150,6 +150,7 @@
  • {% trans "Duplicate stock item" %}
  • {% endif %}
  • {% trans "Edit stock item" %}
  • +
  • {% trans "Edit stock item" %}
  • {% if user.is_staff or roles.stock.delete %} {% if item.can_delete %}
  • {% trans "Delete stock item" %}
  • @@ -520,6 +521,10 @@ $("#stock-edit").click(function () { ); }); +$('#stock-edit-2').click(function() { + editStockItem({{ item.pk }}); +}); + $('#stock-edit-status').click(function () { constructForm('{% url "api-stock-detail" item.pk %}', { diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 1bfe196286..07e38565cd 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -179,6 +179,7 @@ function constructChangeForm(fields, options) { // Request existing data from the API endpoint $.ajax({ url: options.url, + data: options.params || {}, type: 'GET', contentType: 'application/json', dataType: 'json', @@ -194,6 +195,17 @@ function constructChangeForm(fields, options) { fields[field].value = data[field]; } } + + // An optional function can be provided to process the returned results, + // before they are rendered to the form + if (options.processResults) { + var processed = options.processResults(data, fields, options); + + // If the processResults function returns data, it will be stored + if (processed) { + data = processed; + } + } // Store the entire data object options.instance = data; diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 67c50bffef..2f885477a5 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -68,6 +68,95 @@ function locationFields() { } +function stockItemFields(options={}) { + var fields = { + part: {}, + supplier_part: { + filters: { + part_detail: true, + supplier_detail: true, + }, + adjustFilters: function(query, opts) { + var part = getFormFieldValue('part', {}, opts); + + if (part) { + query.part = part; + } + + return query; + } + }, + serial: {}, + status: {}, + expiry_date: {}, + batch: {}, + purchase_price: {}, + purchase_price_currency: {}, + packaging: {}, + link: {}, + delete_on_deplete: {}, + // owner: {}, + }; + + // Remove stock expiry fields if feature is not enabled + if (!global_settings.STOCK_ENABLE_EXPIRY) { + delete fields['expiry_date']; + } + + return fields; +} + + +function stockItemGroups(options={}) { + return { + + }; +} + + +/* + * Launch a modal form to edit a given StockItem + */ +function editStockItem(pk, options={}) { + + var url = `/api/stock/${pk}/`; + + var fields = stockItemFields(options); + + // Prevent editing of the "part" + fields.part.hidden = true; + + var groups = stockItemGroups(options); + + constructForm(url, { + fields: fields, + groups: groups, + title: '{% trans "Edit Stock Item" %}', + params: { + part_detail: true, + supplier_part_detail: true, + }, + processResults: function(data, fields, options) { + // Callback when StockItem data is received from server + + if (data.part_detail.trackable) { + delete options.fields.delete_on_deplete; + } else { + // Remove serial number field if part is not trackable + delete options.fields.serial; + } + + // Remove pricing fields if part is not purchaseable + if (!data.part_detail.purchaseable) { + delete options.fields.supplier_part; + delete options.fields.purchase_price; + delete options.fields.purchase_price_currency; + } + } + }); +} + + /* Stock API functions * Requires api.js to be loaded first */ From d3b1ecd65e2a0611de9363a1cf8e6af03c6963c2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 20 Oct 2021 23:44:01 +1100 Subject: [PATCH 02/77] Add "owner" field --- InvenTree/stock/serializers.py | 7 ++++--- InvenTree/templates/js/translated/stock.js | 7 ++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index e2781af12f..0b99ed9c02 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -58,15 +58,15 @@ class StockItemSerializerBrief(InvenTreeModelSerializer): class Meta: model = StockItem fields = [ - 'pk', - 'uid', 'part', 'part_name', - 'supplier_part', + 'pk', 'location', 'location_name', 'quantity', 'serial', + 'supplier_part', + 'uid', ] @@ -208,6 +208,7 @@ class StockItemSerializer(InvenTreeModelSerializer): 'location', 'location_detail', 'notes', + 'owner', 'packaging', 'part', 'part_detail', diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 2f885477a5..2bc562cb5a 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -94,8 +94,8 @@ function stockItemFields(options={}) { purchase_price_currency: {}, packaging: {}, link: {}, + owner: {}, delete_on_deplete: {}, - // owner: {}, }; // Remove stock expiry fields if feature is not enabled @@ -103,6 +103,11 @@ function stockItemFields(options={}) { delete fields['expiry_date']; } + // Remove ownership field if feature is not enanbled + if (!global_settings.STOCK_OWNERSHIP_CONTROL) { + delete fields['owner']; + } + return fields; } From da9d2f7467fd81d9aa3c64e05e3316aa482b84c1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Oct 2021 22:49:06 +0200 Subject: [PATCH 03/77] Added missing fields Fixes #2181 --- InvenTree/part/views.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 5a4167ea05..330e7ae49c 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -245,6 +245,7 @@ class PartImport(FileManagementFormView): 'Category', 'default_location', 'default_supplier', + 'variant_of', ] OPTIONAL_HEADERS = [ @@ -256,6 +257,16 @@ class PartImport(FileManagementFormView): 'minimum_stock', 'Units', 'Notes', + 'Active', + 'base_cost', + 'Multiple', + 'assembly', + 'component', + 'is_template', + 'purchaseable', + 'salable', + 'trackable', + 'virtual', ] name = 'part' @@ -284,6 +295,17 @@ class PartImport(FileManagementFormView): 'category': 'category', 'default_location': 'default_location', 'default_supplier': 'default_supplier', + 'variant_of': 'variant_of', + 'active': 'active', + 'base_cost': 'base_cost', + 'multiple': 'multiple', + 'assembly': 'assembly', + 'component': 'component', + 'is_template': 'is_template', + 'purchaseable': 'purchaseable', + 'salable': 'salable', + 'trackable': 'trackable', + 'virtual': 'virtual', } file_manager_class = PartFileManager @@ -299,6 +321,8 @@ class PartImport(FileManagementFormView): self.matches['default_location'] = ['name__contains'] self.allowed_items['default_supplier'] = SupplierPart.objects.all() self.matches['default_supplier'] = ['SKU__contains'] + self.allowed_items['variant_of'] = Part.objects.all() + self.matches['variant_of'] = ['name__contains'] # setup self.file_manager.setup() From 71cc155dc99474f7e0c17066ee1eed46f5531436 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Oct 2021 22:50:01 +0200 Subject: [PATCH 04/77] Capitalize name --- InvenTree/part/views.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 330e7ae49c..66a101ba5a 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -260,13 +260,13 @@ class PartImport(FileManagementFormView): 'Active', 'base_cost', 'Multiple', - 'assembly', - 'component', + 'Assembly', + 'Component', 'is_template', - 'purchaseable', - 'salable', - 'trackable', - 'virtual', + 'Purchaseable', + 'Salable', + 'Trackable', + 'Virtual', ] name = 'part' From 15566632540cf0f1e521ddabd1f202af6aa30d07 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Oct 2021 23:40:29 +0200 Subject: [PATCH 05/77] added fields to save step --- InvenTree/part/views.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 66a101ba5a..55147c933b 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -388,6 +388,17 @@ class PartImport(FileManagementFormView): category=optional_matches['Category'], default_location=optional_matches['default_location'], default_supplier=optional_matches['default_supplier'], + variant_of=optional_matches['variant_of'], + active=optional_matches['active'], + base_cost=optional_matches['base_cost'], + multiple=optional_matches['multiple'], + assembly=optional_matches['assembly'], + component=optional_matches['component'], + is_template=optional_matches['is_template'], + purchaseable=optional_matches['purchaseable'], + salable=optional_matches['salable'], + trackable=optional_matches['trackable'], + virtual=optional_matches['virtual'], ) try: new_part.save() From 8e6aaa89f91f94ada7bce7f5660dbe783d63cae0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Oct 2021 23:40:57 +0200 Subject: [PATCH 06/77] calculate true / false for fields --- InvenTree/part/views.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 55147c933b..f9bd57e767 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -389,16 +389,16 @@ class PartImport(FileManagementFormView): default_location=optional_matches['default_location'], default_supplier=optional_matches['default_supplier'], variant_of=optional_matches['variant_of'], - active=optional_matches['active'], - base_cost=optional_matches['base_cost'], - multiple=optional_matches['multiple'], - assembly=optional_matches['assembly'], - component=optional_matches['component'], - is_template=optional_matches['is_template'], - purchaseable=optional_matches['purchaseable'], - salable=optional_matches['salable'], - trackable=optional_matches['trackable'], - virtual=optional_matches['virtual'], + active=str2bool(part_data.get('active', None)), + base_cost=part_data.get('base_cost', None), + multiple=part_data.get('multiple', None), + assembly=str2bool(part_data.get('assembly', None)), + component=str2bool(part_data.get('component', None)), + is_template=str2bool(part_data.get('is_template', None)), + purchaseable=str2bool(part_data.get('purchaseable', None)), + salable=str2bool(part_data.get('salable', None)), + trackable=str2bool(part_data.get('trackable', None)), + virtual=str2bool(part_data.get('virtual', None)), ) try: new_part.save() From 612832c3e771b3847abf948cd8cc1459962f10c2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Oct 2021 23:48:42 +0200 Subject: [PATCH 07/77] respect defaults --- InvenTree/part/views.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index f9bd57e767..8deec0750f 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -47,6 +47,7 @@ from stock.models import StockLocation import common.settings as inventree_settings from . import forms as part_forms +from . import settings as part_settings from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat from order.models import PurchaseOrderLineItem @@ -389,16 +390,16 @@ class PartImport(FileManagementFormView): default_location=optional_matches['default_location'], default_supplier=optional_matches['default_supplier'], variant_of=optional_matches['variant_of'], - active=str2bool(part_data.get('active', None)), + active=str2bool(part_data.get('active', True)), base_cost=part_data.get('base_cost', None), multiple=part_data.get('multiple', None), - assembly=str2bool(part_data.get('assembly', None)), - component=str2bool(part_data.get('component', None)), - is_template=str2bool(part_data.get('is_template', None)), - purchaseable=str2bool(part_data.get('purchaseable', None)), - salable=str2bool(part_data.get('salable', None)), - trackable=str2bool(part_data.get('trackable', None)), - virtual=str2bool(part_data.get('virtual', None)), + assembly=str2bool(part_data.get('assembly', part_settings.part_assembly_default())), + component=str2bool(part_data.get('component', part_settings.part_component_default())), + is_template=str2bool(part_data.get('is_template', part_settings.part_template_default())), + purchaseable=str2bool(part_data.get('purchaseable', part_settings.part_purchaseable_default())), + salable=str2bool(part_data.get('salable', part_settings.part_salable_default())), + trackable=str2bool(part_data.get('trackable', part_settings.part_trackable_default())), + virtual=str2bool(part_data.get('virtual', part_settings.part_virtual_default())), ) try: new_part.save() From bec845003d76bb93c50181c1f096cfc1fc939fac Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Oct 2021 23:57:10 +0200 Subject: [PATCH 08/77] fix defaults --- InvenTree/part/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 8deec0750f..918c015f94 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -391,8 +391,8 @@ class PartImport(FileManagementFormView): default_supplier=optional_matches['default_supplier'], variant_of=optional_matches['variant_of'], active=str2bool(part_data.get('active', True)), - base_cost=part_data.get('base_cost', None), - multiple=part_data.get('multiple', None), + base_cost=part_data.get('base_cost', 0), + multiple=part_data.get('multiple', 1), assembly=str2bool(part_data.get('assembly', part_settings.part_assembly_default())), component=str2bool(part_data.get('component', part_settings.part_component_default())), is_template=str2bool(part_data.get('is_template', part_settings.part_template_default())), From d97e3cd4e5d85e0ebf71ad0f653e43358f872d21 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 24 Oct 2021 00:19:17 +0200 Subject: [PATCH 09/77] create stock on import --- InvenTree/part/views.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 918c015f94..5456f4e049 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -42,7 +42,7 @@ from common.files import FileManager from common.views import FileManagementFormView, FileManagementAjaxView from common.forms import UploadFileForm, MatchFieldForm -from stock.models import StockLocation +from stock.models import StockItem, StockLocation import common.settings as inventree_settings @@ -268,6 +268,7 @@ class PartImport(FileManagementFormView): 'Salable', 'Trackable', 'Virtual', + 'Stock', ] name = 'part' @@ -307,6 +308,7 @@ class PartImport(FileManagementFormView): 'salable': 'salable', 'trackable': 'trackable', 'virtual': 'virtual', + 'stock': 'stock', } file_manager_class = PartFileManager @@ -403,6 +405,15 @@ class PartImport(FileManagementFormView): ) try: new_part.save() + + # add stock item if set + if part_data.get('stock', None): + stock = StockItem( + part=new_part, + location=new_part.default_location, + quantity=int(part_data.get('stock', 1)), + ) + stock.save() import_done += 1 except ValidationError as _e: import_error.append(', '.join(set(_e.messages))) From 4bfdf211072cb7f3b08fbe967e8054bab383a1be Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 2 Nov 2021 15:07:20 +1100 Subject: [PATCH 10/77] 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 %} - {% 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 @@
    - {% 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 c3c4aca829eb3a33934f74dafcdedaa8acdfa0c4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 2 Nov 2021 19:29:26 +1100 Subject: [PATCH 11/77] Fix action buttons for stock-item detail page --- .../stock/templates/stock/item_base.html | 59 ++++++++++--------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index d225e8734e..82d8625256 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -22,40 +22,39 @@ {% url 'admin:stock_stockitem_change' item.pk as url %} {% include "admin_button.html" with url=url %} {% endif %} -
    -
    +
    {% if barcodes %} - {% endif %} -
    - + @@ -63,37 +62,37 @@ {% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} {% if roles.stock.change and not item.is_building %} -
    - +
    + @@ -102,19 +101,19 @@ {% if roles.stock.change and not item.is_building %}
    - + @@ -123,6 +122,8 @@ {% endif %}
    +{% endblock %} + {% block thumbnail %} {% endblock %} From d357e982f5ed6cc4d3d795a8a552a424b60db183 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 2 Nov 2021 19:43:17 +1100 Subject: [PATCH 12/77] Revert stock-item detail template to mater --- .../stock/templates/stock/item_base.html | 164 ++++++++---------- 1 file changed, 75 insertions(+), 89 deletions(-) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 82d8625256..5a58e2e04f 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -22,106 +22,96 @@ {% url 'admin:stock_stockitem_change' item.pk as url %} {% include "admin_button.html" with url=url %} {% endif %} - +{% if barcodes %} +
    - + +
    - +{% endif %} +
    - - {% if barcodes %} - -
    - + + +
    + + +{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} + {% if roles.stock.change and not item.is_building %} +
    +
    {% endif %} - -
    - + + {% if roles.stock.change and not item.is_building %} +
    +
    - - - {% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} - {% if roles.stock.change and not item.is_building %} -
    - - -
    - {% endif %} - - {% if roles.stock.change and not item.is_building %} -
    - - -
    - {% endif %} {% endif %} -
    - +{% endif %} {% endblock %} {% block thumbnail %} @@ -491,10 +481,6 @@ $("#stock-edit").click(function () { ); }); -$('#stock-edit-2').click(function() { - editStockItem({{ item.pk }}); -}); - $('#stock-edit-status').click(function () { constructForm('{% url "api-stock-detail" item.pk %}', { From 4c8bc9580c6cf790c99c87300a80201bd3c04122 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 2 Nov 2021 19:51:46 +1100 Subject: [PATCH 13/77] stock-item-edit is looking OK now --- .../stock/templates/stock/item_base.html | 12 ++--- InvenTree/stock/test_views.py | 16 +++--- InvenTree/stock/urls.py | 1 - InvenTree/templates/js/translated/stock.js | 51 ++++++++++--------- 4 files changed, 37 insertions(+), 43 deletions(-) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 5a58e2e04f..525b6767d6 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -471,14 +471,10 @@ $("#stock-duplicate").click(function() { }); }); -$("#stock-edit").click(function () { - launchModalForm( - "{% url 'stock-item-edit' item.id %}", - { - reload: true, - submit_text: '{% trans "Save" %}', - } - ); +$('#stock-edit').click(function() { + editStockItem({{ item.pk }}, { + reload: true, + }); }); $('#stock-edit-status').click(function () { diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index 9494598430..6d22c7e4bb 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -105,15 +105,6 @@ class StockItemTest(StockViewTestCase): response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') self.assertEqual(response.status_code, 200) - def test_edit_item(self): - # Test edit view for StockItem - response = self.client.get(reverse('stock-item-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Test with a non-purchaseable part - response = self.client.get(reverse('stock-item-edit', args=(100,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - def test_create_item(self): """ Test creation of StockItem @@ -273,11 +264,15 @@ class StockOwnershipTest(StockViewTestCase): HTTP_X_REQUESTED_WITH='XMLHttpRequest') self.assertContains(response, '"form_valid": true', status_code=200) + """ + TODO: Refactor this following test to use the new API form # Set ownership on existing item (and change location) response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)), {'part': 1, 'status': StockStatus.OK, 'owner': user_as_owner.pk}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertContains(response, '"form_valid": true', status_code=200) + """ # Logout self.client.logout() @@ -294,6 +289,8 @@ class StockOwnershipTest(StockViewTestCase): location = StockLocation.objects.get(pk=test_location_id) self.assertEqual(location.owner, user_group_owner) + """ + TODO: Refactor this following test to use the new API form # Test item edit response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)), {'part': 1, 'status': StockStatus.OK, 'owner': new_user_as_owner.pk}, @@ -302,6 +299,7 @@ class StockOwnershipTest(StockViewTestCase): # Make sure the item's owner is unchanged item = StockItem.objects.get(pk=test_item_id) self.assertEqual(item.owner, user_as_owner) + """ # Create new parent location parent_location = { diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 434acde84e..d3e7090652 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -22,7 +22,6 @@ location_urls = [ ] stock_item_detail_urls = [ - url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'), url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'), url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'), url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 9268dcefc9..174e675426 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -131,34 +131,35 @@ function editStockItem(pk, options={}) { // Prevent editing of the "part" fields.part.hidden = true; - var groups = stockItemGroups(options); + options.groups = stockItemGroups(options); - constructForm(url, { - fields: fields, - groups: groups, - title: '{% trans "Edit Stock Item" %}', - params: { - part_detail: true, - supplier_part_detail: true, - }, - processResults: function(data, fields, options) { - // Callback when StockItem data is received from server + options.fields = fields; + options.title = '{% trans "Edit Stock Item" %}'; + + // Query parameters for retrieving stock item data + options.params = { + part_detail: true, + supplier_part_detail: true, + }; - if (data.part_detail.trackable) { - delete options.fields.delete_on_deplete; - } else { - // Remove serial number field if part is not trackable - delete options.fields.serial; - } - - // Remove pricing fields if part is not purchaseable - if (!data.part_detail.purchaseable) { - delete options.fields.supplier_part; - delete options.fields.purchase_price; - delete options.fields.purchase_price_currency; - } + // Augment the rendered form when we receive information about the StockItem + options.processResults = function(data, fields, options) { + if (data.part_detail.trackable) { + delete options.fields.delete_on_deplete; + } else { + // Remove serial number field if part is not trackable + delete options.fields.serial; } - }); + + // Remove pricing fields if part is not purchaseable + if (!data.part_detail.purchaseable) { + delete options.fields.supplier_part; + delete options.fields.purchase_price; + delete options.fields.purchase_price_currency; + } + }; + + constructForm(url, options); } From 0d9c08b49c56ee05cb794f9e0a933ff726c7bf5a Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 2 Nov 2021 19:58:25 +1100 Subject: [PATCH 14/77] StockLocationEdit --- InvenTree/stock/templates/stock/location.html | 9 +++--- InvenTree/stock/test_views.py | 32 ------------------- InvenTree/stock/urls.py | 1 - InvenTree/stock/views.py | 4 +++ InvenTree/templates/js/translated/stock.js | 18 +++++++++-- 5 files changed, 24 insertions(+), 40 deletions(-) diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 7490d262bd..d3cc407298 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -243,12 +243,11 @@ }); {% if location %} + $('#location-edit').click(function() { - launchModalForm("{% url 'stock-location-edit' location.id %}", - { - reload: true - }); - return false; + editStockLocation({{ location.id }}, { + reload: true, + }); }); $('#location-delete').click(function() { diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index 6d22c7e4bb..64d0ac42d5 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -66,10 +66,6 @@ class StockListTest(StockViewTestCase): class StockLocationTest(StockViewTestCase): """ Tests for StockLocation views """ - def test_location_edit(self): - response = self.client.get(reverse('stock-location-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - def test_qr_code(self): # Request the StockLocation QR view response = self.client.get(reverse('stock-location-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') @@ -258,12 +254,6 @@ class StockOwnershipTest(StockViewTestCase): # Enable ownership control self.enable_ownership() - # Set ownership on existing location - response = self.client.post(reverse('stock-location-edit', args=(test_location_id,)), - {'name': 'Office', 'owner': user_group_owner.pk}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": true', status_code=200) - """ TODO: Refactor this following test to use the new API form # Set ownership on existing item (and change location) @@ -280,15 +270,6 @@ class StockOwnershipTest(StockViewTestCase): # Login with new user self.client.login(username='john', password='custom123') - # Test location edit - response = self.client.post(reverse('stock-location-edit', args=(test_location_id,)), - {'name': 'Office', 'owner': new_user_group_owner.pk}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - # Make sure the location's owner is unchanged - location = StockLocation.objects.get(pk=test_location_id) - self.assertEqual(location.owner, user_group_owner) - """ TODO: Refactor this following test to use the new API form # Test item edit @@ -370,16 +351,3 @@ class StockOwnershipTest(StockViewTestCase): # Logout self.client.logout() - - # Login with admin - self.client.login(username='username', password='password') - - # Switch owner of location - response = self.client.post(reverse('stock-location-edit', args=(location_created.pk,)), - {'name': new_location['name'], 'owner': user_group_owner.pk}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": true', status_code=200) - - # Check that owner was updated for item in this location - stock_item = StockItem.objects.all().last() - self.assertEqual(stock_item.owner, user_group_owner) diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index d3e7090652..58b76b9bf7 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -11,7 +11,6 @@ location_urls = [ url(r'^new/', views.StockLocationCreate.as_view(), name='stock-location-create'), url(r'^(?P\d+)/', include([ - url(r'^edit/?', views.StockLocationEdit.as_view(), name='stock-location-edit'), url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'), url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index eb5fabcc25..583ad705db 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -149,6 +149,10 @@ class StockLocationEdit(AjaxUpdateView): """ View for editing details of a StockLocation. This view is used with the EditStockLocationForm to deliver a modal form to the web view + + TODO: Remove this code as location editing has been migrated to the API forms + - Have to still validate that all form functionality (as below) as been ported + """ model = StockLocation diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 174e675426..8d0637238c 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -51,13 +51,14 @@ loadStockTestResultsTable, loadStockTrackingTable, loadTableFilters, - locationFields, removeStockRow, + stockItemFields, + stockLocationFields, stockStatusCodes, */ -function locationFields() { +function stockLocationFields(options={}) { return { parent: { help_text: '{% trans "Parent stock location" %}', @@ -68,6 +69,19 @@ function locationFields() { } +/* + * Launch an API form to edit a stock location + */ +function editStockLocation(pk, options={}) { + + var url = `/api/stock/location/${pk}/`; + + options.fields = stockLocationFields(options); + + constructForm(url, options); +} + + function stockItemFields(options={}) { var fields = { part: {}, From ef305032c962b6b4f97e036a1d7eb587a3c24e16 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 2 Nov 2021 20:04:25 +1100 Subject: [PATCH 15/77] Create new stock location via API forms --- InvenTree/stock/templates/stock/location.html | 25 +++----- InvenTree/stock/test_views.py | 58 ------------------- InvenTree/stock/urls.py | 2 - InvenTree/stock/views.py | 4 ++ InvenTree/templates/js/translated/stock.js | 22 ++++++- 5 files changed, 32 insertions(+), 79 deletions(-) diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index d3cc407298..78b82b5093 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -222,24 +222,13 @@ }); $('#location-create').click(function () { - launchModalForm("{% url 'stock-location-create' %}", - { - data: { - {% if location %} - location: {{ location.id }} - {% endif %} - }, - follow: true, - secondary: [ - { - field: 'parent', - label: '{% trans "New Location" %}', - title: '{% trans "Create new location" %}', - url: "{% url 'stock-location-create' %}", - }, - ] - }); - return false; + + createStockLocation({ + {% if location %} + parent: {{ location.pk }}, + {% endif %} + follow: true, + }); }); {% if location %} diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index 64d0ac42d5..3aacf8a139 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -63,32 +63,6 @@ class StockListTest(StockViewTestCase): self.assertEqual(response.status_code, 200) -class StockLocationTest(StockViewTestCase): - """ Tests for StockLocation views """ - - def test_qr_code(self): - # Request the StockLocation QR view - response = self.client.get(reverse('stock-location-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Test for an invalid StockLocation - response = self.client.get(reverse('stock-location-qr', args=(999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_create(self): - # Test StockLocation creation view - response = self.client.get(reverse('stock-location-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Create with a parent - response = self.client.get(reverse('stock-location-create'), {'location': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Create with an invalid parent - response = self.client.get(reverse('stock-location-create'), {'location': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - class StockItemTest(StockViewTestCase): """" Tests for StockItem views """ @@ -289,38 +263,6 @@ class StockOwnershipTest(StockViewTestCase): 'owner': new_user_group_owner.pk, } - # Create new parent location - response = self.client.post(reverse('stock-location-create'), - parent_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": true', status_code=200) - - # Retrieve created location - parent_location = StockLocation.objects.get(name=parent_location['name']) - - # Create new child location - new_location = { - 'name': 'Upper Left Drawer', - 'description': 'John\'s desk - Upper left drawer', - } - - # Try to create new location with neither parent or owner - response = self.client.post(reverse('stock-location-create'), - new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": false', status_code=200) - - # Try to create new location with invalid owner - new_location['parent'] = parent_location.id - new_location['owner'] = user_group_owner.pk - response = self.client.post(reverse('stock-location-create'), - new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": false', status_code=200) - - # Try to create new location with valid owner - new_location['owner'] = new_user_group_owner.pk - response = self.client.post(reverse('stock-location-create'), - new_location, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertContains(response, '"form_valid": true', status_code=200) - # Retrieve created location location_created = StockLocation.objects.get(name=new_location['name']) diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 58b76b9bf7..5441101aa1 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -8,8 +8,6 @@ from stock import views location_urls = [ - url(r'^new/', views.StockLocationCreate.as_view(), name='stock-location-create'), - url(r'^(?P\d+)/', include([ url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'), url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 583ad705db..44c1a824bd 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -931,6 +931,10 @@ class StockLocationCreate(AjaxCreateView): """ View for creating a new StockLocation A parent location (another StockLocation object) can be passed as a query parameter + + TODO: Remove this class entirely, as it has been migrated to the API forms + - Still need to check that all the functionality (as below) has been implemented + """ model = StockLocation diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 8d0637238c..a987348d8b 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -59,13 +59,19 @@ function stockLocationFields(options={}) { - return { + var fields = { parent: { help_text: '{% trans "Parent stock location" %}', }, name: {}, description: {}, }; + + if (options.parent) { + fields.parent.value = options.parent; + } + + return fields; } @@ -82,6 +88,20 @@ function editStockLocation(pk, options={}) { } +/* + * Launch an API form to create a new stock location + */ +function createStockLocation(options={}) { + + var url = '{% url "api-location-list" %}'; + + options.method = 'POST'; + options.fields = stockLocationFields(options); + + constructForm(url, options); +} + + function stockItemFields(options={}) { var fields = { part: {}, From d8e3c40f78d4536a46be16bfca2006f0306c2288 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 2 Nov 2021 23:04:10 +1100 Subject: [PATCH 16/77] Implementing more complex behaviour for StockItem creation form --- InvenTree/templates/js/translated/forms.js | 16 ++- InvenTree/templates/js/translated/stock.js | 160 ++++++++++----------- 2 files changed, 94 insertions(+), 82 deletions(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index dd6e2cb291..59df238f51 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -25,7 +25,9 @@ */ /* exported - setFormGroupVisibility + hideFormInput, + setFormGroupVisibility, + showFormInput, */ /** @@ -1248,6 +1250,18 @@ function initializeGroups(fields, options) { } } +// Hide a form input +function hideFormInput(name, options) { + $(options.modal).find(`#div_id_${name}`).hide(); +} + + +// Show a form input +function showFormInput(name, options) { + $(options.modal).find(`#div_id_${name}`).show(); +} + + // Hide a form group function hideFormGroup(group, options) { $(options.modal).find(`#form-panel-${group}`).hide(); diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index a987348d8b..7cf839496b 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -97,6 +97,7 @@ function createStockLocation(options={}) { options.method = 'POST'; options.fields = stockLocationFields(options); + options.title = '{% trans "New Stock Location" %}'; constructForm(url, options); } @@ -104,8 +105,28 @@ function createStockLocation(options={}) { function stockItemFields(options={}) { var fields = { - part: {}, + part: { + onSelect: function(data, field, opts) { + // Callback when a new "part" is selected + + // If we are "creating" a new stock item + if (options.create) { + // If a "trackable" part is selected, enable serial number field + if (data.trackable) { + showFormInput('serial_numbers', opts); + } else { + updateFieldValue('serial_numbers', '', {}, opts); + hideFormInput('serial_numbers', opts); + } + } + + // TODO: Hide "purchase price" fields for non purchaseable parts! + + // TODO: Update "location" based on "default_location" returned + } + }, supplier_part: { + icon: 'fa-building', filters: { part_detail: true, supplier_detail: true, @@ -120,18 +141,49 @@ function stockItemFields(options={}) { return query; } }, - serial: {}, + location: { + icon: 'fa-sitemap', + }, + quantity: { + help_text: '{% trans "Enter initial quantity for this stock item" %}', + }, + serial_numbers: { + icon: 'fa-hashtag', + type: 'string', + label: '{% trans "Serial Numbers" %}', + help_text: '{% trans "Enter serial numbers for new stock (or leave blank)" %}', + required: false, + }, + serial: { + icon: 'fa-hashtag', + }, status: {}, expiry_date: {}, batch: {}, - purchase_price: {}, + purchase_price: { + icon: 'fa-dollar-sign', + }, purchase_price_currency: {}, - packaging: {}, - link: {}, + packaging: { + icon: 'fa-box', + }, + link: { + icon: 'fa-link', + }, owner: {}, delete_on_deplete: {}, }; + if (options.create) { + // Use "serial numbers" field when creating a new stock item + delete fields['serial']; + } else { + // These fields cannot be edited once the stock item has been created + delete fields['serial_numbers']; + delete fields['quantity']; + delete fields['location']; + } + // Remove stock expiry fields if feature is not enabled if (!global_settings.STOCK_ENABLE_EXPIRY) { delete fields['expiry_date']; @@ -160,14 +212,14 @@ function editStockItem(pk, options={}) { var url = `/api/stock/${pk}/`; - var fields = stockItemFields(options); - // Prevent editing of the "part" fields.part.hidden = true; + options.create = false; + + options.fields = stockItemFields(options); options.groups = stockItemGroups(options); - options.fields = fields; options.title = '{% trans "Edit Stock Item" %}'; // Query parameters for retrieving stock item data @@ -197,6 +249,25 @@ function editStockItem(pk, options={}) { } +/* + * Launch an API form to contsruct a new stock item + */ +function createNewStockItem(options={}) { + + var url = '{% url "api-stock-list" %}'; + + options.title = '{% trans "New Stock Item" %}'; + options.method = 'POST'; + + options.create = true; + + options.fields = stockItemFields(options); + options.groups = stockItemGroups(options); + + constructForm(url, options); +} + + /* Stock API functions * Requires api.js to be loaded first */ @@ -1929,79 +2000,6 @@ function loadStockTrackingTable(table, options) { } -function createNewStockItem(options) { - /* Launch a modal form to create a new stock item. - * - * This is really just a helper function which calls launchModalForm, - * but it does get called a lot, so here we are ... - */ - - // Add in some funky options - - options.callback = [ - { - field: 'part', - action: function(value) { - - if (!value) { - // No part chosen - - clearFieldOptions('supplier_part'); - enableField('serial_numbers', false); - enableField('purchase_price_0', false); - enableField('purchase_price_1', false); - - return; - } - - // Reload options for supplier part - reloadFieldOptions( - 'supplier_part', - { - url: '{% url "api-supplier-part-list" %}', - params: { - part: value, - pretty: true, - }, - text: function(item) { - return item.pretty_name; - } - } - ); - - // Request part information from the server - inventreeGet( - `/api/part/${value}/`, {}, - { - success: function(response) { - - // Disable serial number field if the part is not trackable - enableField('serial_numbers', response.trackable); - clearField('serial_numbers'); - - enableField('purchase_price_0', response.purchaseable); - enableField('purchase_price_1', response.purchaseable); - - // Populate the expiry date - if (response.default_expiry <= 0) { - // No expiry date - clearField('expiry_date'); - } else { - var expiry = moment().add(response.default_expiry, 'days'); - - setFieldValue('expiry_date', expiry.format('YYYY-MM-DD')); - } - } - } - ); - } - }, - ]; - - launchModalForm('{% url "stock-item-create" %}', options); -} - - function loadInstalledInTable(table, options) { /* * Display a table showing the stock items which are installed in this stock item. From aaf27d409818c2b9a65ac48556a063bfdbf18adf Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 07:27:58 +1100 Subject: [PATCH 17/77] Adds new buttons to create a new stock item --- .../company/templates/company/supplier_part.html | 10 +++++++++- .../templates/order/purchase_order_detail.html | 2 +- InvenTree/part/templates/part/detail.html | 15 +++++++++++++-- InvenTree/stock/templates/stock/location.html | 10 +++++++++- InvenTree/templates/stock_table.html | 12 ++---------- 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 52742cf488..d3bee4e797 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -134,7 +134,15 @@ src="{% static 'img/blank_image.png' %}"
    -

    {% trans "Supplier Part Stock" %}

    + +

    {% trans "Supplier Part Stock" %}

    + {% include "spacer.html" %} +
    + +
    +
    {% include "stock_table.html" %} diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index b0d9d7d301..257707347a 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -50,7 +50,7 @@

    {% trans "Received Items" %}

    - {% include "stock_table.html" with prevent_new_stock=True %} + {% include "stock_table.html" %}
    diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 145b5bfb35..3c75b128da 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -95,7 +95,15 @@
    -

    {% trans "Part Stock" %}

    +
    +

    {% trans "Part Stock" %}

    + {% include "spacer.html" %} +
    + +
    +
    {% if part.is_template %} @@ -851,11 +859,14 @@ }); onPanelLoad("part-stock", function() { - $('#add-stock-item').click(function () { + $('#new-stock-item').click(function () { createNewStockItem({ reload: true, data: { part: {{ part.id }}, + {% if part.default_location %} + location: {{ part.default_location.pk }}, + {% endif %} } }); }); diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 78b82b5093..bf3c5d1648 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -140,7 +140,15 @@
    -

    {% trans "Stock Items" %}

    +
    +

    {% trans "Stock Items" %}

    + {% include "spacer.html" %} +
    + +
    +
    {% include "stock_table.html" %} diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html index 912d2e16a5..71c6734818 100644 --- a/InvenTree/templates/stock_table.html +++ b/InvenTree/templates/stock_table.html @@ -10,17 +10,10 @@
    -
    +
    - - {% if owner_control.value == "True" and user in owners or user.is_superuser or owner_control.value == "False" %} - {% if not read_only and not prevent_new_stock and roles.stock.add %} - - {% endif %} {% if barcodes %}
    @@ -46,7 +39,7 @@
    {% if not read_only %} {% if roles.stock.change or roles.stock.delete %} -
    +
    @@ -66,7 +59,6 @@
    {% endif %} {% endif %} - {% endif %} {% include "filter_list.html" with id="stock" %}
    From ad4c4f2a6d3c030f26e069b76c85ef5bb7179370 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 07:28:21 +1100 Subject: [PATCH 18/77] Stock item duplication now works with the API forms --- .../stock/templates/stock/item_base.html | 6 ++-- InvenTree/templates/js/translated/stock.js | 28 +++++++++++++++++-- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 525b6767d6..c2220776d7 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -463,11 +463,9 @@ $("#print-label").click(function() { {% if roles.stock.change %} $("#stock-duplicate").click(function() { - createNewStockItem({ + // Duplicate a stock item + duplicateStockItem({{ item.pk }}, { follow: true, - data: { - copy: {{ item.id }}, - } }); }); diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 7cf839496b..e968da3769 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -106,6 +106,8 @@ function createStockLocation(options={}) { function stockItemFields(options={}) { var fields = { part: { + // Hide the part field unless we are "creating" a new stock item + hidden: !options.create, onSelect: function(data, field, opts) { // Callback when a new "part" is selected @@ -205,6 +207,29 @@ function stockItemGroups(options={}) { } +/* + * Launch a modal form to duplicate a given StockItem + */ +function duplicateStockItem(pk, options) { + + // First, we need the StockItem informatino + inventreeGet(`/api/stock/${pk}/`, {}, { + success: function(data) { + + options.create = true; + + options.data = data; + options.method = 'POST'; + options.fields = stockItemFields(options); + options.groups = stockItemGroups(options); + options.title = '{% trans "Duplicate Stock Item" %}'; + + constructForm('{% url "api-stock-list" %}', options); + } + }); +} + + /* * Launch a modal form to edit a given StockItem */ @@ -212,9 +237,6 @@ function editStockItem(pk, options={}) { var url = `/api/stock/${pk}/`; - // Prevent editing of the "part" - fields.part.hidden = true; - options.create = false; options.fields = stockItemFields(options); From 2b69d9c2afe085c7a7014fec3e8f9dcfed9a8df6 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 07:41:47 +1100 Subject: [PATCH 19/77] Correctly serialize stock when creating via the API --- InvenTree/stock/api.py | 71 +++++++++++++++++----- InvenTree/templates/js/translated/stock.js | 8 ++- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 1207312688..143481acd5 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -7,9 +7,11 @@ from __future__ import unicode_literals from datetime import datetime, timedelta +from django.core.exceptions import ValidationError as DjangoValidationError from django.conf.urls import url, include from django.http import JsonResponse from django.db.models import Q +from django.db import transaction from rest_framework import status from rest_framework.serializers import ValidationError @@ -39,7 +41,7 @@ import common.models import stock.serializers as StockSerializers -from InvenTree.helpers import str2bool, isNull +from InvenTree.helpers import str2bool, isNull, extract_serial_numbers from InvenTree.api import AttachmentMixin from InvenTree.filters import InvenTreeOrderingFilter @@ -380,28 +382,67 @@ class StockList(generics.ListCreateAPIView): """ user = request.user + data = request.data - serializer = self.get_serializer(data=request.data) + serializer = self.get_serializer(data=data) serializer.is_valid(raise_exception=True) - item = serializer.save() + # Check if a set of serial numbers was provided + serial_numbers = data.get('serial_numbers', '') - # A location was *not* specified - try to infer it - if 'location' not in request.data: - item.location = item.part.get_default_location() + quantity = data['quantity'] - # An expiry date was *not* specified - try to infer it! - if 'expiry_date' not in request.data: + notes = data.get('notes', '') - if item.part.default_expiry > 0: - item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry) + serials = None - # Finally, save the item - item.save(user=user) + if serial_numbers: + # If serial numbers are specified, check that they match! + try: + serials = extract_serial_numbers(serial_numbers, data['quantity']) + except DjangoValidationError as e: + raise ValidationError({ + 'quantity': e.messages, + 'serial_numbers': e.messages, + }) - # Return a response - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + with transaction.atomic(): + + # Create an initial stock item + item = serializer.save() + + # A location was *not* specified - try to infer it + if 'location' not in data: + item.location = item.part.get_default_location() + + # An expiry date was *not* specified - try to infer it! + if 'expiry_date' not in data: + + if item.part.default_expiry > 0: + item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry) + + # Finally, save the item (with user information) + item.save(user=user) + + # Serialize the stock, if required + if serials: + try: + item.serializeStock( + quantity, + serials, + user, + notes=notes, + location=item.location, + ) + except DjangoValidationError as e: + raise ValidationError({ + 'quantity': e.messages, + 'serial_numbers': e.messages, + }) + + # Return a response + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) def list(self, request, *args, **kwargs): """ diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index e968da3769..d4a250c332 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -216,12 +216,16 @@ function duplicateStockItem(pk, options) { inventreeGet(`/api/stock/${pk}/`, {}, { success: function(data) { - options.create = true; + // Do not duplicate the serial number + delete data['serial']; options.data = data; - options.method = 'POST'; + + options.create = true; options.fields = stockItemFields(options); options.groups = stockItemGroups(options); + + options.method = 'POST'; options.title = '{% trans "Duplicate Stock Item" %}'; constructForm('{% url "api-stock-list" %}', options); From be7b224f14d95deb8f917bf3990f3fa24b160fb8 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 10:12:42 +1100 Subject: [PATCH 20/77] Adds API endpoint for serialization of stock items --- InvenTree/stock/api.py | 28 +++- InvenTree/stock/serializers.py | 153 +++++++++++++++--- .../stock/templates/stock/item_base.html | 16 +- InvenTree/stock/test_views.py | 40 ----- InvenTree/stock/urls.py | 1 - InvenTree/stock/views.py | 83 ---------- InvenTree/templates/js/translated/stock.js | 27 ++++ 7 files changed, 198 insertions(+), 150 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 143481acd5..5ca552dd8e 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -101,6 +101,27 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView): instance.mark_for_deletion() +class StockItemSerialize(generics.CreateAPIView): + """ + API endpoint for serializing a stock item + """ + + queryset = StockItem.objects.none() + serializer_class = StockSerializers.SerializeStockItemSerializer + + def get_serializer_context(self): + + context = super().get_serializer_context() + context['request'] = self.request + + try: + context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + return context + + class StockAdjustView(generics.CreateAPIView): """ A generic class for handling stocktake actions. @@ -1126,8 +1147,11 @@ stock_api_urls = [ url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'), ])), - # Detail for a single stock item - url(r'^(?P\d+)/', StockDetail.as_view(), name='api-stock-detail'), + # Detail views for a single stock item + url(r'^(?P\d+)/', include([ + url(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'), + url(r'^.*$', StockDetail.as_view(), name='api-stock-detail'), + ])), # Anything else url(r'^.*$', StockList.as_view(), name='api-stock-list'), diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 0b99ed9c02..8513fa8740 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -9,6 +9,7 @@ from decimal import Decimal from datetime import datetime, timedelta from django.db import transaction +from django.core.exceptions import ValidationError as DjangoValidationError from django.utils.translation import ugettext_lazy as _ from django.db.models.functions import Coalesce from django.db.models import Case, When, Value @@ -27,14 +28,15 @@ from .models import StockItemTestResult import common.models from common.settings import currency_code_default, currency_code_mappings - from company.serializers import SupplierPartSerializer + +import InvenTree.helpers +import InvenTree.serializers + from part.serializers import PartBriefSerializer -from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer, InvenTreeMoneySerializer -from InvenTree.serializers import InvenTreeAttachmentSerializer, InvenTreeAttachmentSerializerField -class LocationBriefSerializer(InvenTreeModelSerializer): +class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Provides a brief serializer for a StockLocation object """ @@ -48,7 +50,7 @@ class LocationBriefSerializer(InvenTreeModelSerializer): ] -class StockItemSerializerBrief(InvenTreeModelSerializer): +class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer): """ Brief serializers for a StockItem """ location_name = serializers.CharField(source='location', read_only=True) @@ -70,7 +72,7 @@ class StockItemSerializerBrief(InvenTreeModelSerializer): ] -class StockItemSerializer(InvenTreeModelSerializer): +class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Serializer for a StockItem: - Includes serialization for the linked part @@ -146,7 +148,7 @@ class StockItemSerializer(InvenTreeModelSerializer): required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False) - purchase_price = InvenTreeMoneySerializer( + purchase_price = InvenTree.serializers.InvenTreeMoneySerializer( label=_('Purchase Price'), max_digits=19, decimal_places=4, allow_null=True, @@ -247,14 +249,127 @@ class StockItemSerializer(InvenTreeModelSerializer): ] -class StockQuantitySerializer(InvenTreeModelSerializer): +class SerializeStockItemSerializer(serializers.Serializer): + """ + A DRF serializer for "serializing" a StockItem. + + (Sorry for the confusing naming...) + + Here, "serializing" means splitting out a single StockItem, + into multiple single-quantity items with an assigned serial number + + Note: The base StockItem object is provided to the serializer context + """ class Meta: - model = StockItem - fields = ('quantity',) + fields = [ + 'quantity', + 'serial_numbers', + 'destination', + 'notes', + ] + + quantity = serializers.IntegerField( + min_value=0, + required=True, + label=_('Quantity'), + help_text=_('Enter number of stock items to serialize'), + ) + + def validate_quantity(self, quantity): + + item = self.context['item'] + + if quantity < 0: + raise ValidationError(_("Quantity must be greater than zero")) + + if quantity > item.quantity: + q = item.quantity + raise ValidationError(_(f"Quantity must not exceed available stock quantity ({q})")) + + return quantity + + serial_numbers = serializers.CharField( + label=_('Serial Numbers'), + help_text=_('Enter serial numbers for new items'), + allow_blank=False, + required=True, + ) + + destination = serializers.PrimaryKeyRelatedField( + queryset=StockLocation.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Location'), + help_text=_('Destination stock location'), + ) + + notes = serializers.CharField( + required=False, + allow_blank=True, + label=_("Notes"), + help_text=_("Optional note field") + ) + + def validate(self, data): + """ + Check that the supplied serial numbers are valid + """ + + data = super().validate(data) + + item = self.context['item'] + + if not item.part.trackable: + raise ValidationError(_("Serial numbers cannot be assigned to this part")) + + # Ensure the serial numbers are valid! + quantity = data['quantity'] + serial_numbers = data['serial_numbers'] + + try: + serials = InvenTree.helpers.extract_serial_numbers(serial_numbers, quantity) + except DjangoValidationError as e: + raise ValidationError({ + 'serial_numbers': e.messages, + }) + + existing = item.part.find_conflicting_serial_numbers(serials) + + if len(existing) > 0: + exists = ','.join([str(x) for x in existing]) + error = _('Serial numbers already exist') + ": " + exists + + raise ValidationError({ + 'serial_numbers': error, + }) + + return data + + def save(self): + + item = self.context['item'] + request = self.context['request'] + user = request.user + + data = self.validated_data + + serials = InvenTree.helpers.extract_serial_numbers( + data['serial_numbers'], + data['quantity'], + ) + + item.serializeStock( + data['quantity'], + serials, + user, + notes=data.get('notes', ''), + location=data['destination'], + ) -class LocationSerializer(InvenTreeModelSerializer): +class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Detailed information about a stock location """ @@ -278,7 +393,7 @@ class LocationSerializer(InvenTreeModelSerializer): ] -class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer): +class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer): """ Serializer for StockItemAttachment model """ def __init__(self, *args, **kwargs): @@ -289,9 +404,9 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer): if user_detail is not True: self.fields.pop('user_detail') - user_detail = UserSerializerBrief(source='user', read_only=True) + user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True) - attachment = InvenTreeAttachmentSerializerField(required=True) + attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=True) # TODO: Record the uploading user when creating or updating an attachment! @@ -316,14 +431,14 @@ class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer): ] -class StockItemTestResultSerializer(InvenTreeModelSerializer): +class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Serializer for the StockItemTestResult model """ - user_detail = UserSerializerBrief(source='user', read_only=True) + user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True) key = serializers.CharField(read_only=True) - attachment = InvenTreeAttachmentSerializerField(required=False) + attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False) def __init__(self, *args, **kwargs): user_detail = kwargs.pop('user_detail', False) @@ -357,7 +472,7 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): ] -class StockTrackingSerializer(InvenTreeModelSerializer): +class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Serializer for StockItemTracking model """ def __init__(self, *args, **kwargs): @@ -377,7 +492,7 @@ class StockTrackingSerializer(InvenTreeModelSerializer): item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True) - user_detail = UserSerializerBrief(source='user', many=False, read_only=True) + user_detail = InvenTree.serializers.UserSerializerBrief(source='user', many=False, read_only=True) deltas = serializers.JSONField(read_only=True) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index c2220776d7..2bd878549c 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -418,12 +418,18 @@ {{ block.super }} $("#stock-serialize").click(function() { - launchModalForm( - "{% url 'stock-item-serialize' item.id %}", - { - reload: true, + + serializeStockItem({{ item.pk }}, { + reload: true, + data: { + quantity: {{ item.quantity }}, + {% if item.location %} + destination: {{ item.location.pk }}, + {% elif item.part.default_location %} + destination: {{ item.part.default_location.pk }}, + {% endif %} } - ); + }); }); $('#stock-install-in').click(function() { diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index 3aacf8a139..ce1ebb0afe 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -126,46 +126,6 @@ class StockItemTest(StockViewTestCase): self.assertIn(expected, str(response.content)) - def test_serialize_item(self): - # Test the serialization view - - url = reverse('stock-item-serialize', args=(100,)) - - # GET the form - response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - data_valid = { - 'quantity': 5, - 'serial_numbers': '1-5', - 'destination': 4, - 'notes': 'Serializing stock test' - } - - data_invalid = { - 'quantity': 4, - 'serial_numbers': 'dd-23-adf', - 'destination': 'blorg' - } - - # POST - response = self.client.post(url, data_valid, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - data = json.loads(response.content) - self.assertTrue(data['form_valid']) - - # Try again to serialize with the same numbers - response = self.client.post(url, data_valid, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - data = json.loads(response.content) - self.assertFalse(data['form_valid']) - - # POST with invalid data - response = self.client.post(url, data_invalid, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - data = json.loads(response.content) - self.assertFalse(data['form_valid']) - class StockOwnershipTest(StockViewTestCase): """ Tests for stock ownership views """ diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 5441101aa1..7c35aebcaf 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -20,7 +20,6 @@ location_urls = [ stock_item_detail_urls = [ url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'), - url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'), url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 44c1a824bd..ba45314dcb 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -1027,89 +1027,6 @@ class StockLocationCreate(AjaxCreateView): pass -class StockItemSerialize(AjaxUpdateView): - """ View for manually serializing a StockItem """ - - model = StockItem - ajax_template_name = 'stock/item_serialize.html' - ajax_form_title = _('Serialize Stock') - form_class = StockForms.SerializeStockForm - - def get_form(self): - - context = self.get_form_kwargs() - - # Pass the StockItem object through to the form - context['item'] = self.get_object() - - form = StockForms.SerializeStockForm(**context) - - return form - - def get_initial(self): - - initials = super().get_initial().copy() - - item = self.get_object() - - initials['quantity'] = item.quantity - initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity) - if item.location is not None: - initials['destination'] = item.location.pk - - return initials - - def get(self, request, *args, **kwargs): - - return super().get(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - - form = self.get_form() - - item = self.get_object() - - quantity = request.POST.get('quantity', 0) - serials = request.POST.get('serial_numbers', '') - dest_id = request.POST.get('destination', None) - notes = request.POST.get('note', '') - user = request.user - - valid = True - - try: - destination = StockLocation.objects.get(pk=dest_id) - except (ValueError, StockLocation.DoesNotExist): - destination = None - - try: - numbers = extract_serial_numbers(serials, quantity) - except ValidationError as e: - form.add_error('serial_numbers', e.messages) - valid = False - numbers = [] - - if valid: - try: - item.serializeStock(quantity, numbers, user, notes=notes, location=destination) - except ValidationError as e: - messages = e.message_dict - - for k in messages.keys(): - if k in ['quantity', 'destination', 'serial_numbers']: - form.add_error(k, messages[k]) - else: - form.add_error(None, messages[k]) - - valid = False - - data = { - 'form_valid': valid, - } - - return self.renderJsonResponse(request, form, data=data) - - class StockItemCreate(AjaxCreateView): """ View for creating a new StockItem diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index d4a250c332..75f5e133d0 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -52,12 +52,39 @@ loadStockTrackingTable, loadTableFilters, removeStockRow, + serializeStockItem, stockItemFields, stockLocationFields, stockStatusCodes, */ +/* + * Launches a modal form to serialize a particular StockItem + */ + +function serializeStockItem(pk, options={}) { + + var url = `/api/stock/${pk}/serialize/`; + + options.method = 'POST'; + options.title = '{% trans "Serialize Stock Item" %}'; + + options.fields = { + quantity: {}, + serial_numbers: { + icon: 'fa-hashtag', + }, + destination: { + icon: 'fa-sitemap', + }, + notes: {}, + } + + constructForm(url, options); +} + + function stockLocationFields(options={}) { var fields = { parent: { From 1d42d33c8e4cbfffd20be485bd821af74f6cc987 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 11:33:44 +1100 Subject: [PATCH 21/77] style fixes --- InvenTree/stock/serializers.py | 5 ++++- InvenTree/stock/test_views.py | 23 ++++++++--------------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 8513fa8740..850ebcea3b 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -277,8 +277,11 @@ class SerializeStockItemSerializer(serializers.Serializer): ) def validate_quantity(self, quantity): + """ + Validate that the quantity value is correct + """ - item = self.context['item'] + item = self.context['item'] if quantity < 0: raise ValidationError(_("Quantity must be greater than zero")) diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index ce1ebb0afe..e210a6ac95 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -7,11 +7,8 @@ from django.contrib.auth.models import Group from common.models import InvenTreeSetting -import json from datetime import datetime, timedelta -from InvenTree.status_codes import StockStatus - class StockViewTestCase(TestCase): @@ -169,34 +166,31 @@ class StockOwnershipTest(StockViewTestCase): InvenTreeSetting.set_setting('STOCK_OWNERSHIP_CONTROL', True, self.user) self.assertEqual(True, InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')) + """ + TODO: Refactor this following test to use the new API form def test_owner_control(self): # Test stock location and item ownership - from .models import StockLocation, StockItem + from .models import StockLocation from users.models import Owner - user_group = self.user.groups.all()[0] - user_group_owner = Owner.get_owner(user_group) new_user_group = self.new_user.groups.all()[0] new_user_group_owner = Owner.get_owner(new_user_group) user_as_owner = Owner.get_owner(self.user) new_user_as_owner = Owner.get_owner(self.new_user) - test_location_id = 4 - test_item_id = 11 - # Enable ownership control self.enable_ownership() - """ - TODO: Refactor this following test to use the new API form + test_location_id = 4 + test_item_id = 11 # Set ownership on existing item (and change location) response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)), {'part': 1, 'status': StockStatus.OK, 'owner': user_as_owner.pk}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') self.assertContains(response, '"form_valid": true', status_code=200) - """ + # Logout self.client.logout() @@ -204,8 +198,7 @@ class StockOwnershipTest(StockViewTestCase): # Login with new user self.client.login(username='john', password='custom123') - """ - TODO: Refactor this following test to use the new API form + # TODO: Refactor this following test to use the new API form # Test item edit response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)), {'part': 1, 'status': StockStatus.OK, 'owner': new_user_as_owner.pk}, @@ -214,7 +207,6 @@ class StockOwnershipTest(StockViewTestCase): # Make sure the item's owner is unchanged item = StockItem.objects.get(pk=test_item_id) self.assertEqual(item.owner, user_as_owner) - """ # Create new parent location parent_location = { @@ -253,3 +245,4 @@ class StockOwnershipTest(StockViewTestCase): # Logout self.client.logout() + """ From c636f13ba8dd6d5dcfd768c560eeb164720b398a Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 11:44:42 +1100 Subject: [PATCH 22/77] Template fix for BOM upload --- .../part/bom_upload/upload_file.html | 91 +++++++++---------- 1 file changed, 44 insertions(+), 47 deletions(-) diff --git a/InvenTree/part/templates/part/bom_upload/upload_file.html b/InvenTree/part/templates/part/bom_upload/upload_file.html index c8add61f49..ab3b245010 100644 --- a/InvenTree/part/templates/part/bom_upload/upload_file.html +++ b/InvenTree/part/templates/part/bom_upload/upload_file.html @@ -8,58 +8,55 @@ {% include "sidebar_link.html" with url=url text="Return to BOM" icon="fa-undo" %} {% endblock %} -{% block page_content %} +{% block heading %} +{% trans "Upload Bill of Materials" %} +{% endblock %} -
    -
    - {% block heading %} -

    {% trans "Upload Bill of Materials" %}

    - {{ wizard.form.media }} - {% endblock %} +{% block actions %} +{% endblock %} + +{% block page_info %} +
    +

    {% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} + {% if description %}- {{ description }}{% endif %}

    + +
    + {% csrf_token %} + {% load crispy_forms_tags %} + + {% block form_buttons_top %} + {% endblock form_buttons_top %} + + {% block form_alert %} +
    + {% trans "Requirements for BOM upload" %}: +
      +
    • {% trans "The BOM file must contain the required named columns as provided in the " %} {% trans "BOM Upload Template" %}
    • +
    • {% trans "Each part must already exist in the database" %}
    • +
    -
    - {% block details %} + {% endblock %} -

    {% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} - {% if description %}- {{ description }}{% endif %}

    +
    + {{ wizard.management_form }} + {% block form_content %} + {% crispy wizard.form %} + {% endblock form_content %} +
    - - {% csrf_token %} - {% load crispy_forms_tags %} - - {% block form_buttons_top %} - {% endblock form_buttons_top %} - - {% block form_alert %} -
    - {% trans "Requirements for BOM upload" %}: -
      -
    • {% trans "The BOM file must contain the required named columns as provided in the " %} {% trans "BOM Upload Template" %}
    • -
    • {% trans "Each part must already exist in the database" %}
    • -
    -
    - {% endblock %} - - - {{ wizard.management_form }} - {% block form_content %} - {% crispy wizard.form %} - {% endblock form_content %} -
    - - {% block form_buttons_bottom %} - {% if wizard.steps.prev %} - - {% endif %} - - - {% endblock form_buttons_bottom %} - - {% endblock details %} -
    - -{% endblock page_content %} + {% block form_buttons_bottom %} + {% if wizard.steps.prev %} + + {% endif %} + + + {% endblock form_buttons_bottom %} + +{% endblock page_info %} {% block js_ready %} {{ block.super }} + +enableSidebar('bom-upload'); + {% endblock js_ready %} From b1c23e30f5fc83af8edbf63b4973ddd11679be4c Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 11:59:20 +1100 Subject: [PATCH 23/77] Fix CSS for user badges --- InvenTree/build/templates/build/detail.html | 2 +- InvenTree/order/templates/order/order_base.html | 4 ++-- InvenTree/order/templates/order/sales_order_base.html | 6 +++--- InvenTree/part/templates/part/detail.html | 2 +- InvenTree/stock/templates/stock/item_base.html | 2 +- InvenTree/stock/templates/stock/location_delete.html | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 9400eb6473..d53122cdd1 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -142,7 +142,7 @@ {% trans "Completed" %} {% if build.completion_date %} - {{ build.completion_date }}{% if build.completed_by %}{{ build.completed_by }}{% endif %} + {{ build.completion_date }}{% if build.completed_by %}{{ build.completed_by }}{% endif %} {% else %} {% trans "Build not complete" %} {% endif %} diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 9e94b379f8..83a17a705a 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -123,7 +123,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Created" %} - {{ order.creation_date }}{{ order.created_by }} + {{ order.creation_date }}{{ order.created_by }} {% if order.issue_date %} @@ -143,7 +143,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Received" %} - {{ order.complete_date }}{{ order.received_by }} + {{ order.complete_date }}{{ order.received_by }} {% endif %} {% if order.responsible %} diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 42a09e8ede..952319da10 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -128,7 +128,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Created" %} - {{ order.creation_date }}{{ order.created_by }} + {{ order.creation_date }}{{ order.created_by }} {% if order.target_date %} @@ -141,14 +141,14 @@ src="{% static 'img/blank_image.png' %}" {% trans "Shipped" %} - {{ order.shipment_date }}{{ order.shipped_by }} + {{ order.shipment_date }}{{ order.shipped_by }} {% endif %} {% if order.status == PurchaseOrderStatus.COMPLETE %} {% trans "Received" %} - {{ order.complete_date }}{{ order.received_by }} + {{ order.complete_date }}{{ order.received_by }} {% endif %} {% if order.responsible %} diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 145b5bfb35..e45e7e14b8 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -64,7 +64,7 @@ {{ part.creation_date }} {% if part.creation_user %} - {{ part.creation_user }} + {{ part.creation_user }} {% endif %} diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 5a58e2e04f..8da0db0296 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -393,7 +393,7 @@ {% trans "Last Stocktake" %} {% if item.stocktake_date %} - {{ item.stocktake_date }} {{ item.stocktake_user }} + {{ item.stocktake_date }} {{ item.stocktake_user }} {% else %} {% trans "No stocktake performed" %} {% endif %} diff --git a/InvenTree/stock/templates/stock/location_delete.html b/InvenTree/stock/templates/stock/location_delete.html index 22b4168173..9c560e58c5 100644 --- a/InvenTree/stock/templates/stock/location_delete.html +++ b/InvenTree/stock/templates/stock/location_delete.html @@ -36,7 +36,7 @@ If this location is deleted, these items will be moved to the top level 'Stock'
      {% for item in location.stock_items.all %} -
    • {{ item.part.full_name }} - {{ item.part.description }}{% decimal item.quantity %}
    • +
    • {{ item.part.full_name }} - {{ item.part.description }}{% decimal item.quantity %}
    • {% endfor %}
    {% endif %} From c4ea3ecf6fc3eb4e2db69c16218ac44b2d219cf0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 13:27:58 +1100 Subject: [PATCH 24/77] Bug fix for stock location table (cherry picked from commit 44794d7b78520023e3b70da61dc13938bfd4bd14) --- InvenTree/stock/templates/stock/location.html | 3 +- InvenTree/templates/js/translated/stock.js | 52 +++++++++++-------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 7490d262bd..521a7bdca8 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -183,7 +183,8 @@ {% else %} parent: 'null', {% endif %} - } + }, + allowTreeView: true, }); linkButtonsToSelection( diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 62e765b25b..04a27f6682 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1416,8 +1416,11 @@ function loadStockTable(table, options) { }); } + +/* + * Display a table of stock locations + */ function loadStockLocationTable(table, options) { - /* Display a table of stock locations */ var params = options.params || {}; @@ -1443,15 +1446,15 @@ function loadStockLocationTable(table, options) { filters[key] = params[key]; } - var tree_view = inventreeLoad('location-tree-view') == 1; + var tree_view = options.allowTreeView && inventreeLoad('location-tree-view') == 1; table.inventreeTable({ - treeEnable: tree_view, + treeEnable: options.allowTreeView && tree_view, rootParentId: options.params.parent, uniqueId: 'pk', idField: 'pk', treeShowField: 'name', - parentIdField: 'parent', + parentIdField: tree_view ? 'parent' : null, disablePagination: tree_view, sidePagination: tree_view ? 'client' : 'server', serverSort: !tree_view, @@ -1465,28 +1468,31 @@ function loadStockLocationTable(table, options) { showColumns: true, onPostBody: function() { - tree_view = inventreeLoad('location-tree-view') == 1; + if (options.allowTreeView) { - if (tree_view) { + tree_view = inventreeLoad('location-tree-view') == 1; - $('#view-location-list').removeClass('btn-secondary').addClass('btn-outline-secondary'); - $('#view-location-tree').removeClass('btn-outline-secondary').addClass('btn-secondary'); - - table.treegrid({ - treeColumn: 1, - onChange: function() { - table.bootstrapTable('resetView'); - }, - onExpand: function() { - - } - }); - } else { - $('#view-location-tree').removeClass('btn-secondary').addClass('btn-outline-secondary'); - $('#view-location-list').removeClass('btn-outline-secondary').addClass('btn-secondary'); + if (tree_view) { + + $('#view-location-list').removeClass('btn-secondary').addClass('btn-outline-secondary'); + $('#view-location-tree').removeClass('btn-outline-secondary').addClass('btn-secondary'); + + table.treegrid({ + treeColumn: 1, + onChange: function() { + table.bootstrapTable('resetView'); + }, + onExpand: function() { + + } + }); + } else { + $('#view-location-tree').removeClass('btn-secondary').addClass('btn-outline-secondary'); + $('#view-location-list').removeClass('btn-outline-secondary').addClass('btn-secondary'); + } } }, - buttons: [ + buttons: options.allowTreeView ? [ { icon: 'fas fa-bars', attributes: { @@ -1525,7 +1531,7 @@ function loadStockLocationTable(table, options) { ); } } - ], + ] : [], columns: [ { checkbox: true, From 08ffa102c627b5ad6b4cf83c5183060dc3f2dd33 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 13:33:38 +1100 Subject: [PATCH 25/77] Fixes for part category table (cherry picked from commit f7ef309995f8a52c213748f4a9226724e288c936) --- InvenTree/part/templates/part/category.html | 3 +- InvenTree/templates/js/translated/part.js | 51 +++++++++++---------- InvenTree/templates/js/translated/stock.js | 4 +- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 6672adf210..03369b093d 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -210,7 +210,8 @@ {% else %} parent: null, {% endif %} - } + }, + allowTreeView: true, } ); diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index ec72d2682c..44295e67ea 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -1133,8 +1133,10 @@ function loadPartTable(table, url, options={}) { } +/* + * Display a table of part categories + */ function loadPartCategoryTable(table, options) { - /* Display a table of part categories */ var params = options.params || {}; @@ -1157,15 +1159,15 @@ function loadPartCategoryTable(table, options) { setupFilterList(filterKey, table, filterListElement); - var tree_view = inventreeLoad('category-tree-view') == 1; + var tree_view = options.allowTreeView && inventreeLoad('category-tree-view') == 1; table.inventreeTable({ treeEnable: tree_view, - rootParentId: options.params.parent, + rootParentId: tree_view ? options.params.parent : null, uniqueId: 'pk', idField: 'pk', treeShowField: 'name', - parentIdField: 'parent', + parentIdField: tree_view ? 'parent' : null, method: 'get', url: options.url || '{% url "api-part-category-list" %}', queryParams: filters, @@ -1176,7 +1178,7 @@ function loadPartCategoryTable(table, options) { name: 'category', original: original, showColumns: true, - buttons: [ + buttons: options.allowTreeView ? [ { icon: 'fas fa-bars', attributes: { @@ -1215,28 +1217,31 @@ function loadPartCategoryTable(table, options) { ); } } - ], + ] : [], onPostBody: function() { - tree_view = inventreeLoad('category-tree-view') == 1; + if (options.allowTreeView) { - if (tree_view) { + tree_view = inventreeLoad('category-tree-view') == 1; - $('#view-category-list').removeClass('btn-secondary').addClass('btn-outline-secondary'); - $('#view-category-tree').removeClass('btn-outline-secondary').addClass('btn-secondary'); - - table.treegrid({ - treeColumn: 0, - onChange: function() { - table.bootstrapTable('resetView'); - }, - onExpand: function() { - - } - }); - } else { - $('#view-category-tree').removeClass('btn-secondary').addClass('btn-outline-secondary'); - $('#view-category-list').removeClass('btn-outline-secondary').addClass('btn-secondary'); + if (tree_view) { + + $('#view-category-list').removeClass('btn-secondary').addClass('btn-outline-secondary'); + $('#view-category-tree').removeClass('btn-outline-secondary').addClass('btn-secondary'); + + table.treegrid({ + treeColumn: 0, + onChange: function() { + table.bootstrapTable('resetView'); + }, + onExpand: function() { + + } + }); + } else { + $('#view-category-tree').removeClass('btn-secondary').addClass('btn-outline-secondary'); + $('#view-category-list').removeClass('btn-outline-secondary').addClass('btn-secondary'); + } } }, columns: [ diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 04a27f6682..261cb4d1d6 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1449,8 +1449,8 @@ function loadStockLocationTable(table, options) { var tree_view = options.allowTreeView && inventreeLoad('location-tree-view') == 1; table.inventreeTable({ - treeEnable: options.allowTreeView && tree_view, - rootParentId: options.params.parent, + treeEnable: tree_view, + rootParentId: tree_view ? options.params.parent : null, uniqueId: 'pk', idField: 'pk', treeShowField: 'name', From d435689562f996d4e393403d0c260a08d933862f Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 13:57:50 +1100 Subject: [PATCH 26/77] Add more information to the "part details" tab --- InvenTree/part/templates/part/detail.html | 32 +++++++++++++++++------ 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index e45e7e14b8..1bc4b9b967 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -20,13 +20,6 @@ - {% if part.IPN %} - - - - - - {% endif %} @@ -37,6 +30,13 @@ + {% if part.IPN %} + + + + + + {% endif %} {% if part.revision %} @@ -44,6 +44,20 @@ {% endif %} + {% if part.units %} + + + + + + {% endif %} + {% if part.minimum_stock %} + + + + + + {% endif %} {% if part.keywords %} @@ -79,7 +93,9 @@ - + {% endif %} {% if part.default_supplier %} From 31ea71d3913f6be45922f7cc65e6ba0593838107 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 14:00:13 +1100 Subject: [PATCH 27/77] Display part category --- InvenTree/part/templates/part/detail.html | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 1bc4b9b967..fc9795b6e0 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -30,6 +30,15 @@ + {% if part.category %} + + + + + + {% endif %} {% if part.IPN %} From 85adf842f69cd953bbf87c713a653f1f983b16ad Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 16:59:59 +1100 Subject: [PATCH 28/77] 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 %} {% 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 @@
    {% trans "IPN" %}{{ part.IPN }}{% include "clip.html"%}
    {% trans "Name" %}{% trans "Description" %} {{ part.description }}{% include "clip.html"%}
    {% trans "IPN" %}{{ part.IPN }}{% include "clip.html"%}
    {{ part.revision }}{% include "clip.html"%}
    {% trans "Units" %}{{ part.units }}
    {% trans "Minimum stock level" %}{{ part.minimum_stock }}
    {% trans "Default Location" %}{{ part.default_location }} + {{ part.default_location }} +
    {% trans "Description" %} {{ part.description }}{% include "clip.html"%}
    {% trans "Category" %} + {{ part.category }} +
    - {% 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 Date: Wed, 3 Nov 2021 17:55:30 +1100 Subject: [PATCH 29/77] 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 = ` + ${icon} + ${message} + + + `; - // 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 %} - +{% else %} + +{% endif %} {% if barcodes %} 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 @@
    + + {% block alerts %} +
    + +
    + {% endblock %} + {% block breadcrumb_list %} {% include 'modals.html' %} {% include 'about.html' %} - {% include 'notification.html' %} 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 @@ -
    -
    - × -
    Success alert
    -
    -
    - × -
    Info alert
    -
    -
    - × -
    Warning alert
    -
    -
    - × -
    Danger alert
    -
    -
    \ No newline at end of file From 4cf6b9bd319fa2d8d49a97db23f0068b6aefc399 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 3 Nov 2021 17:59:08 +1100 Subject: [PATCH 30/77] 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 Date: Wed, 3 Nov 2021 18:10:34 +1100 Subject: [PATCH 31/77] 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 Date: Wed, 3 Nov 2021 22:57:49 +1100 Subject: [PATCH 32/77] 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 Date: Wed, 3 Nov 2021 23:22:31 +1100 Subject: [PATCH 33/77] 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 %} +{% elif starred %} + {% else %}
    {% 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 += ''; if (user_settings.SEARCH_SHOW_STOCK_LEVELS) { - html += partStockLabel(item.data); + html += partStockLabel( + item.data, + { + classes: 'badge-right', + } + ); } html += ''; 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 `{% trans "Stock" %}: ${part.in_stock}`; + return `{% trans "Stock" %}: ${part.in_stock}`; } else { - return `{% trans "No Stock" %}`; + return `{% trans "No Stock" %}`; } } From 1c6eb41341cbc564d47d212a87c3381ea2099021 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 00:01:52 +1100 Subject: [PATCH 35/77] 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 @@ 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 %} - +{% elif starred %} + +{% else %} + {% endif %} -{% if roles.part_category.delete %} - +{% if roles.part_category.change or roles.part_category.delete %} +
    + + +
    {% 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 += `{% trans "Inactive" %}`; + html += `{% trans "Inactive" %} `; } 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 Date: Thu, 4 Nov 2021 00:28:10 +1100 Subject: [PATCH 36/77] 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 %}
    - + + + {% endblock %} From ee7c3ae0664cb38673efccded74e195314fe5b5e Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 00:38:34 +1100 Subject: [PATCH 37/77] 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 %} - + 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 Date: Thu, 4 Nov 2021 00:44:16 +1100 Subject: [PATCH 38/77] 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 @@ {% 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 @@ - -
    {% if part.variant_of %} @@ -174,6 +172,13 @@
    + {% if part.minimum_stock %} + + + + + + {% endif %} {% if on_order > 0 %} From ef2307aeaa5e5700bdb8da01acc0ee10b55e0c8c Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 00:46:23 +1100 Subject: [PATCH 39/77] 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 Date: Thu, 4 Nov 2021 00:55:43 +1100 Subject: [PATCH 40/77] 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 Date: Thu, 4 Nov 2021 01:06:57 +1100 Subject: [PATCH 41/77] 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 Date: Thu, 4 Nov 2021 01:11:42 +1100 Subject: [PATCH 42/77] 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 Date: Thu, 4 Nov 2021 01:18:00 +1100 Subject: [PATCH 43/77] 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 Date: Thu, 4 Nov 2021 01:21:08 +1100 Subject: [PATCH 44/77] 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 Date: Thu, 4 Nov 2021 01:31:26 +1100 Subject: [PATCH 45/77] 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 Date: Thu, 4 Nov 2021 08:40:38 +1100 Subject: [PATCH 46/77] 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 From 19afcc52e88fc4e86210928b64531e84236f0053 Mon Sep 17 00:00:00 2001 From: Matthias Mair <66015116+matmair@users.noreply.github.com> Date: Wed, 3 Nov 2021 23:00:52 +0100 Subject: [PATCH 47/77] Update bug_report.md fix spelling error --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1a75b97af0..17a3adeb00 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -28,4 +28,4 @@ Docker Bare Metal **Version Information** -You can get this by going to the "About InvenTree" section in the upper right corner and cicking on to the "copy version information" +You can get this by going to the "About InvenTree" section in the upper right corner and clicking on to the "copy version information" From 717bfde73d0a7aa0038ffc87f89f5a4e6c45afbb Mon Sep 17 00:00:00 2001 From: Matthias Mair <66015116+matmair@users.noreply.github.com> Date: Wed, 3 Nov 2021 23:07:25 +0100 Subject: [PATCH 48/77] bug report simplification Hide descriptions so the reports from inexperienced users get less cluttered --- .github/ISSUE_TEMPLATE/bug_report.md | 30 +++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 17a3adeb00..55585c7670 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,31 +1,47 @@ --- -name: Bug report -about: Create a bug report to help us improve InvenTree +name: Bug +about: Create a bug report to help us improve InvenTree! title: "[BUG] Enter bug description" labels: bug, question assignees: '' --- -**Describe the bug** -A clear and concise description of what the bug is. + + + +**Describe the bug** + + +**Steps to Reproduce** -**To Reproduce** Steps to reproduce the behavior: + **Expected behavior** + + **Deployment Method** -Docker -Bare Metal +- [ ] Docker +- [ ] Bare Metal **Version Information** + From d4a8f5823c602cb144047a8c4c954ffa0b3222b0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 09:13:13 +1100 Subject: [PATCH 49/77] Fix for missing template file --- InvenTree/templates/account/base.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/InvenTree/templates/account/base.html b/InvenTree/templates/account/base.html index 048496c4a5..eeaa0c449d 100644 --- a/InvenTree/templates/account/base.html +++ b/InvenTree/templates/account/base.html @@ -53,7 +53,9 @@ {% block extra_body %} {% endblock %} - {% include 'notification.html' %} +
    + +
    From 4e7825df135b99700fc87ffdbe7231094a84335d Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 09:26:32 +1100 Subject: [PATCH 50/77] Fix javascript issues on login screens --- .../static/script/inventree}/inventree.js | 112 +++++++++--------- .../static/script/inventree/notification.js | 6 +- InvenTree/InvenTree/urls.py | 1 - InvenTree/templates/account/base.html | 27 +++-- InvenTree/templates/base.html | 2 +- 5 files changed, 81 insertions(+), 67 deletions(-) rename InvenTree/{templates/js/dynamic => InvenTree/static/script/inventree}/inventree.js (74%) diff --git a/InvenTree/templates/js/dynamic/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js similarity index 74% rename from InvenTree/templates/js/dynamic/inventree.js rename to InvenTree/InvenTree/static/script/inventree/inventree.js index 1774ba6f3d..b4f7114448 100644 --- a/InvenTree/templates/js/dynamic/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -1,5 +1,3 @@ -{% load inventree_extras %} - /* globals ClipboardJS, inventreeFormDataUpload, @@ -130,66 +128,68 @@ function inventreeDocReady() { attachClipboard('.clip-btn-version', 'modal-about', 'about-copy-text'); // Add autocomplete to the search-bar - $('#search-bar').autocomplete({ - source: function(request, response) { - $.ajax({ - url: '/api/part/', - data: { - search: request.term, - limit: user_settings.SEARCH_PREVIEW_RESULTS, - offset: 0 - }, - success: function(data) { + if ($('#search-bar').exists()) { + $('#search-bar').autocomplete({ + source: function(request, response) { + $.ajax({ + url: '/api/part/', + data: { + search: request.term, + limit: user_settings.SEARCH_PREVIEW_RESULTS, + offset: 0 + }, + success: function(data) { - var transformed = $.map(data.results, function(el) { - return { - label: el.full_name, - id: el.pk, - thumbnail: el.thumbnail, - data: el, - }; - }); - response(transformed); - }, - error: function() { - response([]); - } - }); - }, - create: function() { - $(this).data('ui-autocomplete')._renderItem = function(ul, item) { + var transformed = $.map(data.results, function(el) { + return { + label: el.full_name, + id: el.pk, + thumbnail: el.thumbnail, + data: el, + }; + }); + response(transformed); + }, + error: function() { + response([]); + } + }); + }, + create: function() { + $(this).data('ui-autocomplete')._renderItem = function(ul, item) { - var html = ``; + var html = ``; - html += ` `; - html += item.label; + html += ` `; + html += item.label; - html += ''; - - if (user_settings.SEARCH_SHOW_STOCK_LEVELS) { - html += partStockLabel( - item.data, - { - classes: 'badge-right', - } - ); - } + html += ''; + + if (user_settings.SEARCH_SHOW_STOCK_LEVELS) { + html += partStockLabel( + item.data, + { + classes: 'badge-right', + } + ); + } - html += ''; + html += ''; - return $('
  • ').append(html).appendTo(ul); - }; - }, - select: function( event, ui ) { - window.location = '/part/' + ui.item.id + '/'; - }, - minLength: 2, - classes: { - 'ui-autocomplete': 'dropdown-menu search-menu', - }, - }); + return $('
  • ').append(html).appendTo(ul); + }; + }, + select: function( event, ui ) { + window.location = '/part/' + ui.item.id + '/'; + }, + minLength: 2, + classes: { + 'ui-autocomplete': 'dropdown-menu search-menu', + }, + }); + } // Generate brand-icons $('.brand-icon').each(function(i, obj) { diff --git a/InvenTree/InvenTree/static/script/inventree/notification.js b/InvenTree/InvenTree/static/script/inventree/notification.js index 4ed1333ac6..f62226985e 100644 --- a/InvenTree/InvenTree/static/script/inventree/notification.js +++ b/InvenTree/InvenTree/static/script/inventree/notification.js @@ -1,10 +1,12 @@ function showAlertOrCache(alertType, message, cache, timeout=5000) { if (cache) { - sessionStorage.setItem("inventree-" + alertType, message); + sessionStorage.setItem(`inventree-${alertType}`, message); } else { showMessage('#' + alertType, message, timeout); + + sessionStorage.removeItem(`inventree-${alertType}`); } } @@ -82,4 +84,4 @@ function showMessage(message, options={}) { $(`#alert-${id}`).delay(timeout).slideUp(200, function() { $(this).alert(close); }); -} \ No newline at end of file +} diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 77a0e06a0c..053ba05264 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -94,7 +94,6 @@ settings_urls = [ # These javascript files are served "dynamically" - i.e. rendered on demand dynamic_javascript_urls = [ - url(r'^inventree.js', DynamicJsView.as_view(template_name='js/dynamic/inventree.js'), name='inventree.js'), url(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'), url(r'^nav.js', DynamicJsView.as_view(template_name='js/dynamic/nav.js'), name='nav.js'), url(r'^settings.js', DynamicJsView.as_view(template_name='js/dynamic/settings.js'), name='settings.js'), diff --git a/InvenTree/templates/account/base.html b/InvenTree/templates/account/base.html index eeaa0c449d..4dded57eba 100644 --- a/InvenTree/templates/account/base.html +++ b/InvenTree/templates/account/base.html @@ -33,9 +33,15 @@ +
    +
    + +
    +
  • {% trans "The following parts are low on required stock" %} + + + + + + + +{% for line in lines %} + + + + + + +{% endfor %} + +{% endblock body %} + +{% block footer_prefix %} +

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

    +{% endblock footer_prefix %} From 99b324d1ef4ab063364576837e5deff4ee2ae0fc Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 14:30:23 +1100 Subject: [PATCH 57/77] Add a post-save hook the "Build" model to check stock --- InvenTree/build/models.py | 28 +++++++++++++++---- InvenTree/build/tasks.py | 3 +- .../email/build_order_required_stock.html | 4 ++- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 403b3a9430..e8263285b1 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -8,17 +8,19 @@ import decimal import os from datetime import datetime +from django import dispatch -from django.utils.translation import ugettext_lazy as _ from django.contrib.auth.models import User from django.core.exceptions import ValidationError - -from django.urls import reverse +from django.core.validators import MinValueValidator from django.db import models, transaction from django.db.models import Sum, Q from django.db.models.functions import Coalesce -from django.core.validators import MinValueValidator +from django.db.models.signals import post_save +from django.dispatch.dispatcher import receiver +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ from markdownx.models import MarkdownxField @@ -27,16 +29,17 @@ from mptt.exceptions import InvalidMove from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode -from InvenTree.validators import validate_build_order_reference from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin +from InvenTree.validators import validate_build_order_reference import common.models import InvenTree.fields import InvenTree.helpers +import InvenTree.tasks -from stock import models as StockModels from part import models as PartModels +from stock import models as StockModels from users import models as UserModels @@ -1014,6 +1017,19 @@ class Build(MPTTModel, ReferenceIndexingMixin): return self.status == BuildStatus.COMPLETE +@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log') +def after_save_build(sender, instance: Build, created: bool, **kwargs): + """ + Callback function to be executed after a Build instance is saved + """ + + if created: + # A new Build has just been created + + # Run checks on required parts + InvenTree.tasks.offload_task('build.tasks.check_build_stock', instance) + + class BuildOrderAttachment(InvenTreeAttachment): """ Model for storing file attachments against a BuildOrder object diff --git a/InvenTree/build/tasks.py b/InvenTree/build/tasks.py index a087b66129..8b5a0a1831 100644 --- a/InvenTree/build/tasks.py +++ b/InvenTree/build/tasks.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from decimal import Decimal import logging from django.utils.translation import ugettext_lazy as _ @@ -39,7 +40,7 @@ def check_build_stock(build: build.models.Build): available = max(0, in_stock - allocated) - required = bom_item.quantity * build.quantity + required = Decimal(bom_item.quantity) * Decimal(build.quantity) if available < required: # There is not sufficient stock for this part diff --git a/InvenTree/templates/email/build_order_required_stock.html b/InvenTree/templates/email/build_order_required_stock.html index 6b28d39f8e..5f4015da27 100644 --- a/InvenTree/templates/email/build_order_required_stock.html +++ b/InvenTree/templates/email/build_order_required_stock.html @@ -21,7 +21,9 @@ {% for line in lines %} - + From 01191d84c56ba7094d7e26a18c79377c8db72f79 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 14:32:42 +1100 Subject: [PATCH 58/77] Only run check stock function when updating an existing part --- InvenTree/build/models.py | 2 -- InvenTree/build/tasks.py | 2 -- InvenTree/part/models.py | 9 ++++++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index e8263285b1..0dd6a404e5 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -8,8 +8,6 @@ import decimal import os from datetime import datetime -from django import dispatch - from django.contrib.auth.models import User from django.core.exceptions import ValidationError diff --git a/InvenTree/build/tasks.py b/InvenTree/build/tasks.py index 8b5a0a1831..7455d6eac2 100644 --- a/InvenTree/build/tasks.py +++ b/InvenTree/build/tasks.py @@ -9,8 +9,6 @@ from django.template.loader import render_to_string from allauth.account.models import EmailAddress -from common.models import NotificationEntry - import build.models import InvenTree.helpers import InvenTree.tasks diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 7a15657a90..f37b61864d 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2102,13 +2102,16 @@ class Part(MPTTModel): @receiver(post_save, sender=Part, dispatch_uid='part_post_save_log') -def after_save_part(sender, instance: Part, **kwargs): +def after_save_part(sender, instance: Part, created, **kwargs): """ Function to be executed after a Part is saved """ - # Run this check in the background - InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance) + if not created: + # Check part stock only if we are *updating* the part (not creating it) + + # Run this check in the background + InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance) def attach_file(instance, filename): From 42a794e8e47213294e2c3e190c56b93e94ee08f6 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 15:05:54 +1100 Subject: [PATCH 59/77] Fix CI errors --- InvenTree/build/tasks.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/InvenTree/build/tasks.py b/InvenTree/build/tasks.py index 7455d6eac2..6fe4be5119 100644 --- a/InvenTree/build/tasks.py +++ b/InvenTree/build/tasks.py @@ -12,6 +12,7 @@ from allauth.account.models import EmailAddress import build.models import InvenTree.helpers import InvenTree.tasks +import part.models as part_models logger = logging.getLogger('inventree') @@ -27,7 +28,18 @@ def check_build_stock(build: build.models.Build): lines = [] - for bom_item in build.part.get_bom_items(): + if not build: + logger.error("Invalid build passed to 'build.tasks.check_build_stock'") + return + + try: + part = build.part + except part_models.Part.DoesNotExist: + # Note: This error may be thrown during unit testing... + logger.error("Invalid build.part passed to 'build.tasks.check_build_stock'") + return + + for bom_item in part.get_bom_items(): sub_part = bom_item.sub_part From 47f6a8266dd0ead24e218df6517a25c1aafe964d Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 15:08:22 +1100 Subject: [PATCH 60/77] Fix for tree-view - Force "cascade" to be set --- InvenTree/templates/js/translated/part.js | 9 +++++++-- InvenTree/templates/js/translated/stock.js | 8 ++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index e00f04aebd..742083bbe4 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -1159,6 +1159,13 @@ function loadPartCategoryTable(table, options) { filters = loadTableFilters(filterKey); } + + var tree_view = options.allowTreeView && inventreeLoad('category-tree-view') == 1; + + if (tree_view) { + params.cascade = true; + } + var original = {}; for (var key in params) { @@ -1168,8 +1175,6 @@ function loadPartCategoryTable(table, options) { setupFilterList(filterKey, table, filterListElement); - var tree_view = options.allowTreeView && inventreeLoad('category-tree-view') == 1; - table.inventreeTable({ treeEnable: tree_view, rootParentId: tree_view ? options.params.parent : null, diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 07689b7638..a62e049651 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1426,6 +1426,12 @@ function loadStockLocationTable(table, options) { var filterListElement = options.filterList || '#filter-list-location'; + var tree_view = options.allowTreeView && inventreeLoad('location-tree-view') == 1; + + if (tree_view) { + params.cascade = true; + } + var filters = {}; var filterKey = options.filterKey || options.name || 'location'; @@ -1446,8 +1452,6 @@ function loadStockLocationTable(table, options) { filters[key] = params[key]; } - var tree_view = options.allowTreeView && inventreeLoad('location-tree-view') == 1; - table.inventreeTable({ treeEnable: tree_view, rootParentId: tree_view ? options.params.parent : null, From 5ae62410834228df8338bd150d5aeb8bf9cecd07 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 16:15:11 +1100 Subject: [PATCH 61/77] Fixes for low-stock emails - Include variant stock in test - Improve email template --- InvenTree/part/models.py | 2 +- InvenTree/templates/email/low_stock_notification.html | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index f37b61864d..9dd4031886 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2098,7 +2098,7 @@ class Part(MPTTModel): Returns True if the total stock for this part is less than the minimum stock level """ - return self.total_stock <= self.minimum_stock + return self.get_stock_count() <= self.minimum_stock @receiver(post_save, sender=Part, dispatch_uid='part_post_save_log') diff --git a/InvenTree/templates/email/low_stock_notification.html b/InvenTree/templates/email/low_stock_notification.html index 4db9c2ddaa..7b52ebc0cd 100644 --- a/InvenTree/templates/email/low_stock_notification.html +++ b/InvenTree/templates/email/low_stock_notification.html @@ -24,8 +24,8 @@ - - - + + + {% endblock %} From 75d7530e30dfc731771f739417db204f9d0bb5fa Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 16:17:05 +1100 Subject: [PATCH 62/77] Fix missing tag in template --- InvenTree/templates/email/low_stock_notification.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/templates/email/low_stock_notification.html b/InvenTree/templates/email/low_stock_notification.html index 7b52ebc0cd..bc0a82fd23 100644 --- a/InvenTree/templates/email/low_stock_notification.html +++ b/InvenTree/templates/email/low_stock_notification.html @@ -25,7 +25,7 @@ - - + + {% endblock %} From 3a7f8c91966152378b984ff621360260eb110cf0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 16:18:49 +1100 Subject: [PATCH 63/77] Fix comparison operator --- InvenTree/part/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 9dd4031886..23aea29dbd 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2098,7 +2098,7 @@ class Part(MPTTModel): Returns True if the total stock for this part is less than the minimum stock level """ - return self.get_stock_count() <= self.minimum_stock + return self.get_stock_count() < self.minimum_stock @receiver(post_save, sender=Part, dispatch_uid='part_post_save_log') From 39d3a127e1abc1ff47bd6f7074383bd97bef469c Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 16:19:57 +1100 Subject: [PATCH 64/77] Template improvements --- .../templates/email/low_stock_notification.html | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/InvenTree/templates/email/low_stock_notification.html b/InvenTree/templates/email/low_stock_notification.html index bc0a82fd23..f922187f33 100644 --- a/InvenTree/templates/email/low_stock_notification.html +++ b/InvenTree/templates/email/low_stock_notification.html @@ -8,15 +8,12 @@ {% if link %}

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

    {% endif %} -{% endblock %} +{% endblock title %} -{% 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 %}
    - + @@ -28,4 +25,8 @@ -{% endblock %} +{% endblock body %} + +{% block footer_prefix %} +

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

    +{% endblock footer_prefix %} From 9cfcb6579fe573223512986368e06f1a47b9a5ff Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 16:48:24 +1100 Subject: [PATCH 65/77] Fixes for search auto-complete dropdown menu - Fix rendering of badges - Menu fills out right-to-left --- .../static/script/inventree/inventree.js | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 5d393a085a..5974e12fc5 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -158,14 +158,13 @@ function inventreeDocReady() { create: function() { $(this).data('ui-autocomplete')._renderItem = function(ul, item) { - var html = ``; - - html += ` `; - html += item.label; - - html += ''; + var html = ` +
    + + ${item.label} + + + `; if (user_settings.SEARCH_SHOW_STOCK_LEVELS) { html += partStockLabel( @@ -176,7 +175,7 @@ function inventreeDocReady() { ); } - html += ''; + html += '
    '; return $('
  • ').append(html).appendTo(ul); }; @@ -188,6 +187,10 @@ function inventreeDocReady() { classes: { 'ui-autocomplete': 'dropdown-menu search-menu', }, + position: { + my : "right top", + at: "right bottom" + } }); } From b1598cfd10d2162e53cee4a709b7a3dff26b31a9 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 17:02:52 +1100 Subject: [PATCH 66/77] - style fixes - add part description as mouse-over text --- InvenTree/InvenTree/static/css/inventree.css | 5 +++++ InvenTree/InvenTree/static/script/inventree/inventree.js | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index a686b5c512..478734383f 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -560,6 +560,11 @@ transition: 0.1s; } +.search-autocomplete-item { + border-top: 1px solid #EEE; + margin-bottom: 2px; +} + .modal { overflow: hidden; z-index: 9999; diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 5974e12fc5..df48cf7d60 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -159,9 +159,9 @@ function inventreeDocReady() { $(this).data('ui-autocomplete')._renderItem = function(ul, item) { var html = ` -
    +
    - ${item.label} + ${item.label} `; From 3ceb6f6ba8912f337659b2b370341b603a7581f4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 17:04:47 +1100 Subject: [PATCH 67/77] Fix barcode input --- InvenTree/templates/js/translated/barcode.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/barcode.js b/InvenTree/templates/js/translated/barcode.js index fcced46f53..ac512df27d 100644 --- a/InvenTree/templates/js/translated/barcode.js +++ b/InvenTree/templates/js/translated/barcode.js @@ -36,7 +36,7 @@ function makeBarcodeInput(placeholderText='', hintText='') {
    - + From 76b5bc067ad11afdd55b58bae4b90d647ce9e035 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 17:06:52 +1100 Subject: [PATCH 68/77] Fix notes field --- InvenTree/templates/js/translated/barcode.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/barcode.js b/InvenTree/templates/js/translated/barcode.js index ac512df27d..4b61249d0b 100644 --- a/InvenTree/templates/js/translated/barcode.js +++ b/InvenTree/templates/js/translated/barcode.js @@ -59,7 +59,7 @@ function makeNotesField(options={}) {
    - + From 71bb6fff5005b09628adcd14a842e20f0093fa29 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 22:25:35 +1100 Subject: [PATCH 69/77] Add option to hide inactive parts in search preview window --- InvenTree/InvenTree/static/css/inventree.css | 1 + .../static/script/inventree/inventree.js | 18 +++++++++++++----- InvenTree/common/models.py | 7 +++++++ .../InvenTree/settings/user_search.html | 1 + 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 478734383f..b2e3b36354 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -563,6 +563,7 @@ .search-autocomplete-item { border-top: 1px solid #EEE; margin-bottom: 2px; + overflow-x: hidden; } .modal { diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index df48cf7d60..85ae042728 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -131,13 +131,21 @@ function inventreeDocReady() { if ($('#search-bar').exists()) { $('#search-bar').autocomplete({ source: function(request, response) { + + var params = { + search: request.term, + limit: user_settings.SEARCH_PREVIEW_RESULTS, + offset: 0, + }; + + if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) { + // Limit to active parts + params.active = true; + } + $.ajax({ url: '/api/part/', - data: { - search: request.term, - limit: user_settings.SEARCH_PREVIEW_RESULTS, - offset: 0 - }, + data: params, success: function(data) { var transformed = $.map(data.results, function(el) { diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index bc1463ca00..53924f11fa 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1012,6 +1012,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'validator': bool, }, + 'SEARCH_HIDE_INACTIVE_PARTS': { + 'name': _("Hide Inactive Parts"), + 'description': _('Hide inactive parts in search preview window'), + 'default': False, + 'validator': bool, + }, + 'PART_SHOW_QUANTITY_IN_FORMS': { 'name': _('Show Quantity in Forms'), 'description': _('Display available part quantity in some forms'), diff --git a/InvenTree/templates/InvenTree/settings/user_search.html b/InvenTree/templates/InvenTree/settings/user_search.html index 43eab057c3..51df53ee6b 100644 --- a/InvenTree/templates/InvenTree/settings/user_search.html +++ b/InvenTree/templates/InvenTree/settings/user_search.html @@ -16,6 +16,7 @@
  • {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %} {% include "InvenTree/settings/setting.html" with key="SEARCH_SHOW_STOCK_LEVELS" user_setting=True icon='fa-boxes' %} + {% include "InvenTree/settings/setting.html" with key="SEARCH_HIDE_INACTIVE_PARTS" user_setting=True icon='fa-eye-slash' %}
    {% trans "Part Name" %}{% trans "Available Quantity" %}{% trans "Total Stock" %}{% trans "Available" %} {% trans "Minimum Quantity" %}
    {{ part.full_name }} {{ part.total_stock }}{{ part.available_stock }} {{ part.minimum_stock }}
    {% trans "Minimum stock level" %} {{ part.minimum_stock }}
    {% trans "Category" %} - {{ part.category }} + {{ part.category.name }}
    {% trans "In Stock" %} {% include "part/stock_count.html" %}
    {% trans "Minimum Stock" %}{{ part.minimum_stock }}
    {% trans "Part" %}{% trans "Required Quantity" %}{% trans "Available" %}
    {{ line.part.full_name }} + {% decimal line.required %} {% if line.part.units %}{{ line.part.units }}{% endif %} + {% decimal line.available %} {% if line.part.units %}{{ line.part.units }}{% endif %}
    {{ line.part.full_name }} + {{ line.part.full_name }}{% if part.description %} - {{ part.description }}{% endif %} + {% decimal line.required %} {% if line.part.units %}{{ line.part.units }}{% endif %}
    {{ part.full_name }}{{ part.total_stock }}{{ part.available_stock }}{{ part.minimum_stock }}{% decimal part.total_stock %}{% part.available_stock %}{% part.minimum_stock %}
    {{ part.full_name }} {% decimal part.total_stock %}{% part.available_stock %}{% part.minimum_stock %}{% decimal part.available_stock %}{% decimal part.minimum_stock %}
    {% trans "Part Name" %}{% trans "Part" %} {% trans "Total Stock" %} {% trans "Available" %} {% trans "Minimum Quantity" %}{% decimal part.available_stock %} {% decimal part.minimum_stock %}
    From 97326d9fb2448e6258068546226f25288676acba Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 22:45:11 +1100 Subject: [PATCH 70/77] Display stock item owner (if applicable) --- InvenTree/stock/templates/stock/item_base.html | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index ea5484a73a..f64c9b0704 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -410,8 +410,15 @@ {{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }} {% endif %} + {% if item.owner %} + + + {% trans "Owner" %} + {{ item.owner }} + + {% endif %} -{% endblock %} +{% endblock details_right %} {% block js_ready %} From 3be4acf3ef9d8290ab8472ab50a743b17968ab29 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 23:09:49 +1100 Subject: [PATCH 71/77] More refactoring for notifications - Adds default behaviour for successful stock item creation --- .../static/script/inventree/notification.js | 19 ++++++++----- InvenTree/stock/templates/stock/location.html | 4 +-- InvenTree/templates/account/base.html | 8 +++++- InvenTree/templates/js/translated/barcode.js | 13 ++++++--- InvenTree/templates/js/translated/forms.js | 8 +++--- InvenTree/templates/js/translated/modals.js | 8 +++--- InvenTree/templates/js/translated/stock.js | 27 +++++++++++++++++++ 7 files changed, 66 insertions(+), 21 deletions(-) diff --git a/InvenTree/InvenTree/static/script/inventree/notification.js b/InvenTree/InvenTree/static/script/inventree/notification.js index 399ba1d359..f6bdf3bc57 100644 --- a/InvenTree/InvenTree/static/script/inventree/notification.js +++ b/InvenTree/InvenTree/static/script/inventree/notification.js @@ -1,7 +1,7 @@ /* * Add a cached alert message to sesion storage */ -function addCachedAlert(message, style) { +function addCachedAlert(message, options={}) { var alerts = sessionStorage.getItem('inventree-alerts'); @@ -13,7 +13,8 @@ function addCachedAlert(message, style) { alerts.push({ message: message, - style: style + style: options.style || 'success', + icon: options.icon, }); sessionStorage.setItem('inventree-alerts', JSON.stringify(alerts)); @@ -31,13 +32,13 @@ function clearCachedAlerts() { /* * Display an alert, or cache to display on reload */ -function showAlertOrCache(message, style, cache=false) { +function showAlertOrCache(message, cache, options={}) { if (cache) { - addCachedAlert(message, style); + addCachedAlert(message, options); } else { - showMessage(message, {style: style}); + showMessage(message, options); } } @@ -50,7 +51,13 @@ function showCachedAlerts() { var alerts = JSON.parse(sessionStorage.getItem('inventree-alerts')) || []; alerts.forEach(function(alert) { - showMessage(alert.message, {style: alert.style}); + showMessage( + alert.message, + { + style: alert.style || 'success', + icon: alert.icon, + } + ); }); clearCachedAlerts(); diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 8270db22f5..24a6da69a9 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -308,12 +308,12 @@ $('#item-create').click(function () { createNewStockItem({ - follow: true, + table: '#stock-table', data: { {% if location %} location: {{ location.id }} {% endif %} - } + }, }); }); diff --git a/InvenTree/templates/account/base.html b/InvenTree/templates/account/base.html index fa2a34f79d..7f2486bfcc 100644 --- a/InvenTree/templates/account/base.html +++ b/InvenTree/templates/account/base.html @@ -111,7 +111,13 @@ $(document).ready(function () { // notifications {% if messages %} {% for message in messages %} - showAlertOrCache('{{ message }}', 'info', true); + showAlertOrCache( + '{{ message }}', + true, + { + style: 'info', + } + ); {% endfor %} {% endif %} diff --git a/InvenTree/templates/js/translated/barcode.js b/InvenTree/templates/js/translated/barcode.js index 4b61249d0b..fcc4df5f50 100644 --- a/InvenTree/templates/js/translated/barcode.js +++ b/InvenTree/templates/js/translated/barcode.js @@ -480,10 +480,13 @@ function barcodeCheckIn(location_id) { $(modal).modal('hide'); if (status == 'success' && 'success' in response) { - showAlertOrCache(response.success, 'success', true); + addCachedAlert(response.success); location.reload(); } else { - showAlertOrCache('{% trans "Error transferring stock" %}', 'danger', false); + showMessage('{% trans "Error transferring stock" %}', { + style: 'danger', + icon: 'fas fa-times-circle', + }); } } } @@ -604,10 +607,12 @@ function scanItemsIntoLocation(item_id_list, options={}) { $(modal).modal('hide'); if (status == 'success' && 'success' in response) { - showAlertOrCache(response.success, 'success', true); + addCachedAlert(response.success); location.reload(); } else { - showAlertOrCache('{% trans "Error transferring stock" %}', 'danger', false); + showMessage('{% trans "Error transferring stock" %}', { + style: 'danger', + }); } } } diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 2308c1e247..0da32c58ff 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -904,19 +904,19 @@ function handleFormSuccess(response, options) { // Display any messages if (response && response.success) { - showAlertOrCache(response.success, 'success', cache); + showAlertOrCache(response.success, cache, {style: 'success'}); } if (response && response.info) { - showAlertOrCache(response.info, 'info', cache); + showAlertOrCache(response.info, cache, {style: 'info'}); } if (response && response.warning) { - showAlertOrCache(response.warning, 'warning', cache); + showAlertOrCache(response.warning, cache, {style: 'warning'}); } if (response && response.danger) { - showAlertOrCache(response.danger, 'dagner', cache); + showAlertOrCache(response.danger, cache, {style: 'danger'}); } if (options.onSuccess) { diff --git a/InvenTree/templates/js/translated/modals.js b/InvenTree/templates/js/translated/modals.js index aefdba604f..4cd0be8cec 100644 --- a/InvenTree/templates/js/translated/modals.js +++ b/InvenTree/templates/js/translated/modals.js @@ -399,19 +399,19 @@ function afterForm(response, options) { // Display any messages if (response.success) { - showAlertOrCache(response.success, 'success', cache); + showAlertOrCache(response.success, cache, {style: 'success'}); } if (response.info) { - showAlertOrCache(response.info, 'info', cache); + showAlertOrCache(response.info, cache, {style: 'info'}); } if (response.warning) { - showAlertOrCache(response.warning, 'warning', cache); + showAlertOrCache(response.warning, cache, {style: 'warning'}); } if (response.danger) { - showAlertOrCache(response.danger, 'danger', cache); + showAlertOrCache(response.danger, cache, {style: 'danger'}); } // Was a callback provided? diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 7ba5a52b97..3e6b35ca83 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -317,6 +317,33 @@ function createNewStockItem(options={}) { options.fields = stockItemFields(options); options.groups = stockItemGroups(options); + if (!options.onSuccess) { + options.onSuccess = function(response) { + // If a single stock item has been created, follow it! + if (response.pk) { + var url = `/stock/item/${pk}/`; + + addCachedAlert('{% trans "Created stock item" %}', { + icon: 'fas fa-boxes', + }); + + location.href = url; + } else { + + var q = response.quantity; + + showMessage('{% trans "Created stock items" %}', { + icon: 'fas fa-boxes', + }); + + if (options.table) { + // Reload the table + $(options.table).bootstrapTable('refresh'); + } + } + } + } + constructForm(url, options); } From b41dbba2b0205239b7c2e6d7bf2193615f4f2cd3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 23:18:59 +1100 Subject: [PATCH 72/77] Correctly handle serialization of newly created stock --- InvenTree/stock/api.py | 21 ++++++++++++++++++++- InvenTree/templates/js/translated/forms.js | 6 ++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 5ca552dd8e..9d2860e41d 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -445,8 +445,14 @@ class StockList(generics.ListCreateAPIView): # Finally, save the item (with user information) item.save(user=user) - # Serialize the stock, if required if serials: + """ + Serialize the stock, if required + + - Note that the "original" stock item needs to be created first, so it can be serialized + - It is then immediately deleted + """ + try: item.serializeStock( quantity, @@ -455,6 +461,19 @@ class StockList(generics.ListCreateAPIView): notes=notes, location=item.location, ) + + headers = self.get_success_headers(serializer.data) + + # Delete the original item + item.delete() + + response_data = { + 'quantity': quantity, + 'serial_numbers': serials, + } + + return Response(response_data, status=status.HTTP_201_CREATED, headers=headers) + except DjangoValidationError as e: raise ValidationError({ 'quantity': e.messages, diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 0da32c58ff..42e3aac289 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -115,6 +115,10 @@ function canDelete(OPTIONS) { */ function getApiEndpointOptions(url, callback) { + if (!url) { + return; + } + // Return the ajax request object $.ajax({ url: url, @@ -727,6 +731,8 @@ function submitFormData(fields, options) { break; default: $(options.modal).modal('hide'); + + console.log(`upload error at ${options.url}`); showApiError(xhr, options.url); break; } From f27acde9346dfb16f4013ded8bfcd92449c1030c Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 5 Nov 2021 00:02:55 +1100 Subject: [PATCH 73/77] More fixes - Allow stock item creation for inactive parts - Better handling of successful stock item creation - Disable fields rather than hiding them --- .../templates/company/supplier_part.html | 1 - InvenTree/part/templates/part/detail.html | 2 - .../migrations/0067_alter_stockitem_part.py | 20 ++++++++ InvenTree/stock/models.py | 1 - InvenTree/stock/templates/stock/location.html | 1 - InvenTree/templates/js/translated/forms.js | 20 ++++++++ InvenTree/templates/js/translated/stock.js | 50 ++++++++++++------- 7 files changed, 73 insertions(+), 22 deletions(-) create mode 100644 InvenTree/stock/migrations/0067_alter_stockitem_part.py diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index d3bee4e797..276a9f7ebc 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -322,7 +322,6 @@ $("#item-create").click(function() { part: {{ part.part.id }}, supplier_part: {{ part.id }}, }, - reload: true, }); }); diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index e9b7b4252a..f03127e996 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -886,7 +886,6 @@ onPanelLoad("part-stock", function() { $('#new-stock-item').click(function () { createNewStockItem({ - reload: true, data: { part: {{ part.id }}, {% if part.default_location %} @@ -919,7 +918,6 @@ $('#item-create').click(function () { createNewStockItem({ - reload: true, data: { part: {{ part.id }}, } diff --git a/InvenTree/stock/migrations/0067_alter_stockitem_part.py b/InvenTree/stock/migrations/0067_alter_stockitem_part.py new file mode 100644 index 0000000000..7f00b8f7b1 --- /dev/null +++ b/InvenTree/stock/migrations/0067_alter_stockitem_part.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.5 on 2021-11-04 12:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0074_partcategorystar'), + ('stock', '0066_stockitem_scheduled_for_deletion'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='part', + field=models.ForeignKey(help_text='Base part', limit_choices_to={'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.part', verbose_name='Base Part'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index eb0e6aa12f..320807e0c1 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -456,7 +456,6 @@ class StockItem(MPTTModel): verbose_name=_('Base Part'), related_name='stock_items', help_text=_('Base part'), limit_choices_to={ - 'active': True, 'virtual': False }) diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 24a6da69a9..18b78b2290 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -308,7 +308,6 @@ $('#item-create').click(function () { createNewStockItem({ - table: '#stock-table', data: { {% if location %} location: {{ location.id }} diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 42e3aac289..a86b64d0e2 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -25,6 +25,9 @@ */ /* exported + clearFormInput, + disableFormInput, + enableFormInput, hideFormInput, setFormGroupVisibility, showFormInput, @@ -1261,6 +1264,23 @@ function initializeGroups(fields, options) { } } +// Clear a form input +function clearFormInput(name, options) { + updateFieldValue(name, null, {}, options); +} + +// Disable a form input +function disableFormInput(name, options) { + $(options.modal).find(`#id_${name}`).prop('disabled', true); +} + + +// Enable a form input +function enableFormInput(name, options) { + $(options.modal).find(`#id_${name}`).prop('disabled', false); +} + + // Hide a form input function hideFormInput(name, options) { $(options.modal).find(`#div_id_${name}`).hide(); diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 3e6b35ca83..a7a60230f4 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -138,20 +138,33 @@ function stockItemFields(options={}) { onSelect: function(data, field, opts) { // Callback when a new "part" is selected - // If we are "creating" a new stock item + // If we are "creating" a new stock item, + // change the available fields based on the part properties if (options.create) { + // If a "trackable" part is selected, enable serial number field if (data.trackable) { - showFormInput('serial_numbers', opts); + enableFormInput('serial_numbers', opts); + // showFormInput('serial_numbers', opts); } else { - updateFieldValue('serial_numbers', '', {}, opts); - hideFormInput('serial_numbers', opts); + clearFormInput('serial_numbers', opts); + disableFormInput('serial_numbers', opts); + } + + // Enable / disable fields based on purchaseable status + if (data.purchaseable) { + enableFormInput('supplier_part', opts); + enableFormInput('purchase_price', opts); + enableFormInput('purchase_price_currency', opts); + } else { + clearFormInput('supplier_part', opts); + clearFormInput('purchase_price', opts); + + disableFormInput('supplier_part', opts); + disableFormInput('purchase_price', opts); + disableFormInput('purchase_price_currency', opts); } } - - // TODO: Hide "purchase price" fields for non purchaseable parts! - - // TODO: Update "location" based on "default_location" returned } }, supplier_part: { @@ -204,7 +217,7 @@ function stockItemFields(options={}) { }; if (options.create) { - // Use "serial numbers" field when creating a new stock item + // Use special "serial numbers" field when creating a new stock item delete fields['serial']; } else { // These fields cannot be edited once the stock item has been created @@ -321,25 +334,28 @@ function createNewStockItem(options={}) { options.onSuccess = function(response) { // If a single stock item has been created, follow it! if (response.pk) { - var url = `/stock/item/${pk}/`; + var url = `/stock/item/${response.pk}/`; - addCachedAlert('{% trans "Created stock item" %}', { + addCachedAlert('{% trans "Created new stock item" %}', { icon: 'fas fa-boxes', }); - location.href = url; + window.location.href = url; } else { + // Multiple stock items have been created (i.e. serialized stock) + var q = response.quantity; - showMessage('{% trans "Created stock items" %}', { + showMessage('{% trans "Created multiple stock items" %}', { icon: 'fas fa-boxes', + details: `{% trans "Serial numbers" %}: ${response.serial_numbers}` }); - if (options.table) { - // Reload the table - $(options.table).bootstrapTable('refresh'); - } + var table = options.table || '#stock-table'; + + // Reload the table + $(table).bootstrapTable('refresh'); } } } From 78ac40083a8193221749108f6d401386b49a1c17 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 5 Nov 2021 00:27:26 +1100 Subject: [PATCH 74/77] Fixes for stock api unit tests - Remove old unit tests - Require quantity when creating a new stock item --- InvenTree/stock/api.py | 39 +++++++++++---------- InvenTree/stock/test_api.py | 12 +++---- InvenTree/stock/test_views.py | 64 ----------------------------------- InvenTree/stock/urls.py | 2 -- 4 files changed, 27 insertions(+), 90 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 9d2860e41d..e287441382 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -12,39 +12,39 @@ from django.conf.urls import url, include from django.http import JsonResponse from django.db.models import Q from django.db import transaction +from django.utils.translation import ugettext_lazy as _ + +from django_filters.rest_framework import DjangoFilterBackend +from django_filters import rest_framework as rest_filters from rest_framework import status from rest_framework.serializers import ValidationError from rest_framework.response import Response from rest_framework import generics, filters -from django_filters.rest_framework import DjangoFilterBackend -from django_filters import rest_framework as rest_filters - -from .models import StockLocation, StockItem -from .models import StockItemTracking -from .models import StockItemAttachment -from .models import StockItemTestResult - -from part.models import BomItem, Part, PartCategory -from part.serializers import PartBriefSerializer +import common.settings +import common.models from company.models import Company, SupplierPart from company.serializers import CompanySerializer, SupplierPartSerializer +from InvenTree.helpers import str2bool, isNull, extract_serial_numbers +from InvenTree.api import AttachmentMixin +from InvenTree.filters import InvenTreeOrderingFilter + from order.models import PurchaseOrder from order.models import SalesOrder, SalesOrderAllocation from order.serializers import POSerializer -import common.settings -import common.models +from part.models import BomItem, Part, PartCategory +from part.serializers import PartBriefSerializer +from stock.models import StockLocation, StockItem +from stock.models import StockItemTracking +from stock.models import StockItemAttachment +from stock.models import StockItemTestResult import stock.serializers as StockSerializers -from InvenTree.helpers import str2bool, isNull, extract_serial_numbers -from InvenTree.api import AttachmentMixin -from InvenTree.filters import InvenTreeOrderingFilter - class StockDetail(generics.RetrieveUpdateDestroyAPIView): """ API detail endpoint for Stock object @@ -411,7 +411,12 @@ class StockList(generics.ListCreateAPIView): # Check if a set of serial numbers was provided serial_numbers = data.get('serial_numbers', '') - quantity = data['quantity'] + quantity = data.get('quantity', None) + + if quantity is None: + raise ValidationError({ + 'quantity': _('Quantity is required'), + }) notes = data.get('notes', '') diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index d07c35aaf7..422f9f11ab 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -364,24 +364,22 @@ class StockItemTest(StockAPITestCase): 'part': 1, 'location': 1, }, - expected_code=201, + expected_code=400 ) - # Item should have been created with default quantity - self.assertEqual(response.data['quantity'], 1) + self.assertIn('Quantity is required', str(response.data)) # POST with quantity and part and location - response = self.client.post( + response = self.post( self.list_url, data={ 'part': 1, 'location': 1, 'quantity': 10, - } + }, + expected_code=201 ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - def test_default_expiry(self): """ Test that the "default_expiry" functionality works via the API. diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index e210a6ac95..d3019ee541 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -60,70 +60,6 @@ class StockListTest(StockViewTestCase): self.assertEqual(response.status_code, 200) -class StockItemTest(StockViewTestCase): - """" Tests for StockItem views """ - - def test_qr_code(self): - # QR code for a valid item - response = self.client.get(reverse('stock-item-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # QR code for an invalid item - response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_create_item(self): - """ - Test creation of StockItem - """ - - url = reverse('stock-item-create') - - response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - response = self.client.get(url, {'part': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Copy from a valid item, valid location - response = self.client.get(url, {'location': 1, 'copy': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Copy from an invalid item, invalid location - response = self.client.get(url, {'location': 999, 'copy': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_create_stock_with_expiry(self): - """ - Test creation of stock item of a part with an expiry date. - The initial value for the "expiry_date" field should be pre-filled, - and should be in the future! - """ - - # First, ensure that the expiry date feature is enabled! - InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user) - - url = reverse('stock-item-create') - - response = self.client.get(url, {'part': 25}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - self.assertEqual(response.status_code, 200) - - # We are expecting 10 days in the future - expiry = datetime.now().date() + timedelta(10) - - expected = f'name=\\\\"expiry_date\\\\" value=\\\\"{expiry.isoformat()}\\\\"' - - self.assertIn(expected, str(response.content)) - - # Now check with a part which does *not* have a default expiry period - response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - expected = 'name=\\\\"expiry_date\\\\" placeholder=\\\\"\\\\"' - - self.assertIn(expected, str(response.content)) - - class StockOwnershipTest(StockViewTestCase): """ Tests for stock ownership views """ diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 7c35aebcaf..b28104f388 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -45,8 +45,6 @@ stock_urls = [ # Stock location url(r'^location/', include(location_urls)), - url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'), - url(r'^item/uninstall/', views.StockItemUninstall.as_view(), name='stock-item-uninstall'), url(r'^track/', include(stock_tracking_urls)), From 5a0ff4c0778dcb455caed7b744efd9bfb35fb34f Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 5 Nov 2021 00:32:11 +1100 Subject: [PATCH 75/77] JS linting --- InvenTree/templates/js/translated/barcode.js | 1 - InvenTree/templates/js/translated/stock.js | 21 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/InvenTree/templates/js/translated/barcode.js b/InvenTree/templates/js/translated/barcode.js index fcc4df5f50..2778983341 100644 --- a/InvenTree/templates/js/translated/barcode.js +++ b/InvenTree/templates/js/translated/barcode.js @@ -10,7 +10,6 @@ modalSetSubmitText, modalShowSubmitButton, modalSubmit, - showAlertOrCache, showQuestionDialog, */ diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index a7a60230f4..1e6c473841 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -4,12 +4,11 @@ /* globals attachSelect, - enableField, - clearField, - clearFieldOptions, closeModal, constructField, constructFormBody, + disableField, + enableField, getFormFieldValue, global_settings, handleFormErrors, @@ -33,10 +32,8 @@ printStockItemLabels, printTestReports, renderLink, - reloadFieldOptions, scanItemsIntoLocation, showAlertDialog, - setFieldValue, setupFilterList, showApiError, stockStatusDisplay, @@ -44,6 +41,10 @@ /* exported createNewStockItem, + createStockLocation, + duplicateStockItem, + editStockItem, + editStockLocation, exportStock, loadInstalledInTable, loadStockLocationTable, @@ -344,12 +345,14 @@ function createNewStockItem(options={}) { } else { // Multiple stock items have been created (i.e. serialized stock) - - var q = response.quantity; + var details = ` +
    {% trans "Quantity" %}: ${response.quantity} +
    {% trans "Serial Numbers" %}: ${response.serial_numbers} + `; showMessage('{% trans "Created multiple stock items" %}', { icon: 'fas fa-boxes', - details: `{% trans "Serial numbers" %}: ${response.serial_numbers}` + details: details, }); var table = options.table || '#stock-table'; @@ -357,7 +360,7 @@ function createNewStockItem(options={}) { // Reload the table $(table).bootstrapTable('refresh'); } - } + }; } constructForm(url, options); From 185924e1f847d0444a3ebadb42f65e2fd68654c8 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 5 Nov 2021 00:35:47 +1100 Subject: [PATCH 76/77] More linting --- InvenTree/stock/test_views.py | 2 -- InvenTree/templates/js/translated/stock.js | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index d3019ee541..36042b9bc2 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -7,8 +7,6 @@ from django.contrib.auth.models import Group from common.models import InvenTreeSetting -from datetime import datetime, timedelta - class StockViewTestCase(TestCase): diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 1e6c473841..ec785969cd 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -7,8 +7,6 @@ closeModal, constructField, constructFormBody, - disableField, - enableField, getFormFieldValue, global_settings, handleFormErrors, @@ -80,7 +78,7 @@ function serializeStockItem(pk, options={}) { icon: 'fa-sitemap', }, notes: {}, - } + }; constructForm(url, options); } From ab3acc4601f955e85af2ae2493017fb02bf93a3c Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 5 Nov 2021 07:52:15 +1100 Subject: [PATCH 77/77] Target production docker-compose script to inventree:stable --- docker/docker-compose.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 3f8443065a..cef136bddb 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -13,6 +13,21 @@ version: "3.8" # specified in the "volumes" section at the end of this file. # This path determines where the InvenTree data will be stored! # +# +# InvenTree Image Versions +# ------------------------ +# By default, this docker-compose script targets the STABLE version of InvenTree, +# image: inventree/inventree:stable +# +# To run the LATEST (development) version of InvenTree, change the target image to: +# image: inventree/inventree:latest +# +# Alternatively, you could target a specific tagged release version with (for example): +# image: inventree/inventree:0.5.3 +# +# NOTE: If you change the target image, ensure it is the same for the following containers: +# - inventree-server +# - inventree-worker services: # Database service @@ -40,8 +55,7 @@ services: inventree-server: container_name: inventree-server # If you wish to specify a particular InvenTree version, do so here - # e.g. image: inventree/inventree:0.5.2 - image: inventree/inventree:latest + image: inventree/inventree:stable expose: - 8000 depends_on: @@ -58,8 +72,7 @@ services: inventree-worker: container_name: inventree-worker # If you wish to specify a particular InvenTree version, do so here - # e.g. image: inventree/inventree:0.5.2 - image: inventree/inventree:latest + image: inventree/inventree:stable command: invoke worker depends_on: - inventree-db