From db3d9b551acfa5d6ea7b9b3fb21ec926bedcc0c3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 22 Sep 2022 09:30:04 +1000 Subject: [PATCH 1/4] Cleanup javascript console log messages (#3706) --- InvenTree/InvenTree/static/script/inventree/inventree.js | 5 ++--- InvenTree/templates/js/dynamic/nav.js | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index d66ad7013d..f0d65b9131 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -207,13 +207,12 @@ function enableDragAndDrop(element, url, options) { formData, { success: function(data, status, xhr) { - console.log('Uploaded file via drag-and-drop'); if (options.success) { options.success(data, status, xhr); } }, error: function(xhr, status, error) { - console.log('File upload failed'); + console.error('File upload failed'); if (options.error) { options.error(xhr, status, error); } @@ -222,7 +221,7 @@ function enableDragAndDrop(element, url, options) { } ); } else { - console.log('Ignoring drag-and-drop event (not a file)'); + console.warn('Ignoring drag-and-drop event (not a file)'); } }); } diff --git a/InvenTree/templates/js/dynamic/nav.js b/InvenTree/templates/js/dynamic/nav.js index 05fc50abd8..37d094bddd 100644 --- a/InvenTree/templates/js/dynamic/nav.js +++ b/InvenTree/templates/js/dynamic/nav.js @@ -154,7 +154,7 @@ function enableBreadcrumbTree(options) { var label = options.label; if (!label) { - console.log('ERROR: enableBreadcrumbTree called without supplying label'); + console.error('enableBreadcrumbTree called without supplying label'); return; } From 5a08ef908dd5344b4433436a4679d122f7f99e41 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 23 Sep 2022 08:56:29 +0200 Subject: [PATCH 2/4] Add sanitation for SVG attachments (#3701) * add svg parser * move svg sanitation out into own file * move allowed elements out * add test for svg sanitation * make allowed elements configureable --- InvenTree/InvenTree/models.py | 10 +++++ InvenTree/InvenTree/sanitizer.py | 67 ++++++++++++++++++++++++++++++++ InvenTree/InvenTree/tests.py | 18 +++++++++ 3 files changed, 95 insertions(+) create mode 100644 InvenTree/InvenTree/sanitizer.py diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 5a7491b610..04b9c14bed 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -4,6 +4,7 @@ import logging import os import re from datetime import datetime +from io import BytesIO from django.conf import settings from django.contrib.auth import get_user_model @@ -24,6 +25,7 @@ import InvenTree.format import InvenTree.helpers from common.models import InvenTreeSetting from InvenTree.fields import InvenTreeURLField +from InvenTree.sanitizer import sanitize_svg logger = logging.getLogger('inventree') @@ -383,8 +385,16 @@ class InvenTreeAttachment(models.Model): 'link': _('Missing external link'), }) + if self.attachment.name.lower().endswith('.svg'): + self.attachment.file.file = self.clean_svg(self.attachment) + super().save(*args, **kwargs) + def clean_svg(self, field): + """Sanitize SVG file before saving.""" + cleaned = sanitize_svg(field.file.read()) + return BytesIO(bytes(cleaned, 'utf8')) + def __str__(self): """Human name for attachment.""" if self.attachment is not None: diff --git a/InvenTree/InvenTree/sanitizer.py b/InvenTree/InvenTree/sanitizer.py new file mode 100644 index 0000000000..a9f1fd2f3d --- /dev/null +++ b/InvenTree/InvenTree/sanitizer.py @@ -0,0 +1,67 @@ +"""Functions to sanitize user input files.""" +from bleach import clean +from bleach.css_sanitizer import CSSSanitizer + +ALLOWED_ELEMENTS_SVG = [ + 'a', 'animate', 'animateColor', 'animateMotion', + 'animateTransform', 'circle', 'defs', 'desc', 'ellipse', 'font-face', + 'font-face-name', 'font-face-src', 'g', 'glyph', 'hkern', + 'linearGradient', 'line', 'marker', 'metadata', 'missing-glyph', + 'mpath', 'path', 'polygon', 'polyline', 'radialGradient', 'rect', + 'set', 'stop', 'svg', 'switch', 'text', 'title', 'tspan', 'use' +] + +ALLOWED_ATTRIBUTES_SVG = [ + 'accent-height', 'accumulate', 'additive', 'alphabetic', + 'arabic-form', 'ascent', 'attributeName', 'attributeType', + 'baseProfile', 'bbox', 'begin', 'by', 'calcMode', 'cap-height', + 'class', 'color', 'color-rendering', 'content', 'cx', 'cy', 'd', 'dx', + 'dy', 'descent', 'display', 'dur', 'end', 'fill', 'fill-opacity', + 'fill-rule', 'font-family', 'font-size', 'font-stretch', 'font-style', + 'font-variant', 'font-weight', 'from', 'fx', 'fy', 'g1', 'g2', + 'glyph-name', 'gradientUnits', 'hanging', 'height', 'horiz-adv-x', + 'horiz-origin-x', 'id', 'ideographic', 'k', 'keyPoints', + 'keySplines', 'keyTimes', 'lang', 'marker-end', 'marker-mid', + 'marker-start', 'markerHeight', 'markerUnits', 'markerWidth', + 'mathematical', 'max', 'min', 'name', 'offset', 'opacity', 'orient', + 'origin', 'overline-position', 'overline-thickness', 'panose-1', + 'path', 'pathLength', 'points', 'preserveAspectRatio', 'r', 'refX', + 'refY', 'repeatCount', 'repeatDur', 'requiredExtensions', + 'requiredFeatures', 'restart', 'rotate', 'rx', 'ry', 'slope', + 'stemh', 'stemv', 'stop-color', 'stop-opacity', + 'strikethrough-position', 'strikethrough-thickness', 'stroke', + 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', + 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', + 'stroke-width', 'systemLanguage', 'target', 'text-anchor', 'to', + 'transform', 'type', 'u1', 'u2', 'underline-position', + 'underline-thickness', 'unicode', 'unicode-range', 'units-per-em', + 'values', 'version', 'viewBox', 'visibility', 'width', 'widths', 'x', + 'x-height', 'x1', 'x2', 'xlink:actuate', 'xlink:arcrole', + 'xlink:href', 'xlink:role', 'xlink:show', 'xlink:title', + 'xlink:type', 'xml:base', 'xml:lang', 'xml:space', 'xmlns', + 'xmlns:xlink', 'y', 'y1', 'y2', 'zoomAndPan', 'style' +] + + +def sanitize_svg(file_data: str, strip: bool = True, elements: str = ALLOWED_ELEMENTS_SVG, attributes: str = ALLOWED_ATTRIBUTES_SVG) -> str: + """Sanatize a SVG file. + + Args: + file_data (str): SVG as string. + strip (bool, optional): Should invalid elements get removed. Defaults to True. + elements (str, optional): Allowed elements. Defaults to ALLOWED_ELEMENTS_SVG. + attributes (str, optional): Allowed attributes. Defaults to ALLOWED_ATTRIBUTES_SVG. + + Returns: + str: Sanitzied SVG file. + """ + + cleaned = clean( + file_data, + tags=elements, + attributes=attributes, + strip=strip, + strip_comments=strip, + css_sanitizer=CSSSanitizer() + ) + return cleaned diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 1929ba1325..5cf07df637 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -23,6 +23,7 @@ import InvenTree.helpers import InvenTree.tasks from common.models import InvenTreeSetting from common.settings import currency_codes +from InvenTree.sanitizer import sanitize_svg from part.models import Part, PartCategory from stock.models import StockItem, StockLocation @@ -878,3 +879,20 @@ class BarcodeMixinTest(helpers.InvenTreeTestCase): for barcode, hash in hashing_tests.items(): self.assertEqual(InvenTree.helpers.hash_barcode(barcode), hash) + + +class SanitizerTest(TestCase): + """Simple tests for sanitizer functions.""" + + def test_svg_sanitizer(self): + """Test that SVGs are sanitized acordingly.""" + valid_string = """{0} + + """ + dangerous_string = valid_string.format('') + + # Test that valid string + self.assertEqual(valid_string, sanitize_svg(valid_string)) + + # Test that invalid string is cleanded + self.assertNotEqual(dangerous_string, sanitize_svg(dangerous_string)) From a7e4d27d6d81ce13572bd9a95a12ccaa247a3fca Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 24 Sep 2022 13:00:12 +1000 Subject: [PATCH 3/4] Add user setting for configuring table string length (#3715) --- InvenTree/common/models.py | 10 ++++++++++ .../templates/InvenTree/settings/user_display.html | 1 + InvenTree/templates/js/translated/helpers.js | 7 ++++--- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index e09a08a0bb..b9dc4559f3 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1642,6 +1642,16 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'default': True, 'validator': bool, }, + + 'TABLE_STRING_MAX_LENGTH': { + 'name': _('Table String Length'), + 'description': _('Maximimum length limit for strings displayed in table views'), + 'validator': [ + int, + MinValueValidator(0), + ], + 'default': 100, + } } typ = 'user' diff --git a/InvenTree/templates/InvenTree/settings/user_display.html b/InvenTree/templates/InvenTree/settings/user_display.html index 1e0b4a1ca3..136725b951 100644 --- a/InvenTree/templates/InvenTree/settings/user_display.html +++ b/InvenTree/templates/InvenTree/settings/user_display.html @@ -19,6 +19,7 @@ {% include "InvenTree/settings/setting.html" with key="FORMS_CLOSE_USING_ESCAPE" icon="fa-window-close" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="DISPLAY_SCHEDULE_TAB" icon="fa-calendar-alt" user_setting=True %} + {% include "InvenTree/settings/setting.html" with key="TABLE_STRING_MAX_LENGTH" icon="fa-table" user_setting=True %} diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index edd12a4ac5..2a62bca027 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -45,10 +45,11 @@ function deleteButton(url, text='{% trans "Delete" %}') { */ function shortenString(input_string, options={}) { - var max_length = options.max_length || 100; + // Maximum length can be provided via options argument, or via a user-configurable setting + var max_length = options.max_length || user_settings.TABLE_STRING_MAX_LENGTH; - if (input_string == null) { - return null; + if (!max_length || !input_string) { + return input_string; } input_string = input_string.toString(); From 1b421fb59a5b92acbb546ff0e51bc56a487b3f47 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 24 Sep 2022 23:45:56 +1000 Subject: [PATCH 4/4] Adds 'consumable' field to BomItem model (#2890) * Adds 'consumable' field to BomItem model * Add consumable field to API * Consumable items always count as "allocated" for a build * Add new BOM fields to BOM item checksum calculation * Display 'consumable' status in BOM table * Fix order of database migrations * Update unit tests * Fix for BOM table * Remove "infinite" field from StockItem model - Not used anywhere for functionality - Hidden from the user - Now replaced by the "consumable" concept in the BuildOrder model * Update build order allocation table display * Prevent auto-allocation of stock to consumable BOM items * Ignore consumable BOM items when allocating stock to a build order * Reimplmement "footer" row for BOM table * Fix "can_build" calculation - Ignore "consumable" BOM items * Unrelated typo fix * Tweak BOM table * More visual tweaks to BOM table * Add unit test for consumable field --- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- InvenTree/build/models.py | 11 +- InvenTree/build/serializers.py | 4 + InvenTree/part/api.py | 5 +- .../migrations/0087_bomitem_consumable.py | 18 +++ InvenTree/part/models.py | 29 +++- InvenTree/part/serializers.py | 1 + InvenTree/part/test_bom_export.py | 1 + InvenTree/part/test_bom_item.py | 48 ++++++ .../0088_remove_stockitem_infinite.py | 17 +++ InvenTree/stock/models.py | 12 +- InvenTree/templates/js/translated/bom.js | 143 ++++++++++-------- InvenTree/templates/js/translated/build.js | 56 ++++--- .../templates/js/translated/table_filters.js | 8 + 14 files changed, 256 insertions(+), 99 deletions(-) create mode 100644 InvenTree/part/migrations/0087_bomitem_consumable.py create mode 100644 InvenTree/stock/migrations/0088_remove_stockitem_infinite.py diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 72fd4938a6..2f240cd9ac 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -18,7 +18,7 @@ body: attributes: label: "Problem statement" description: "A clear and concise description of what the solved problem or feature request is." - placeholder: "I am always struggeling with ..." + placeholder: "I am always struggling with ..." - type: textarea id: solution validations: diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 5852fe4965..19bf8b85aa 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -839,6 +839,10 @@ class Build(MPTTModel, ReferenceIndexingMixin): # Get a list of all 'untracked' BOM items for bom_item in self.untracked_bom_items: + if bom_item.consumable: + # Do not auto-allocate stock to consumable BOM items + continue + variant_parts = bom_item.sub_part.get_descendants(include_self=False) unallocated_quantity = self.unallocated_quantity(bom_item) @@ -972,7 +976,12 @@ class Build(MPTTModel, ReferenceIndexingMixin): return max(required - allocated, 0) def is_bom_item_allocated(self, bom_item, output=None): - """Test if the supplied BomItem has been fully allocated!""" + """Test if the supplied BomItem has been fully allocated""" + + if bom_item.consumable: + # Consumable BOM items do not need to be allocated + return True + return self.unallocated_quantity(bom_item, output) == 0 def is_fully_allocated(self, output): diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index d961e024a8..fd880c7a38 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -781,6 +781,10 @@ class BuildAllocationSerializer(serializers.Serializer): quantity = item['quantity'] output = item.get('output', None) + # Ignore allocation for consumable BOM items + if bom_item.consumable: + continue + try: # Create a new BuildItem to allocate stock BuildItem.objects.create( diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index a0177380f8..59fba9b2c8 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1640,8 +1640,9 @@ class BomFilter(rest_filters.FilterSet): """Custom filters for the BOM list.""" # Boolean filters for BOM item - optional = rest_filters.BooleanFilter(label='BOM line is optional') - inherited = rest_filters.BooleanFilter(label='BOM line is inherited') + optional = rest_filters.BooleanFilter(label='BOM item is optional') + consumable = rest_filters.BooleanFilter(label='BOM item is consumable') + inherited = rest_filters.BooleanFilter(label='BOM item is inherited') allow_variants = rest_filters.BooleanFilter(label='Variants are allowed') # Filters for linked 'part' diff --git a/InvenTree/part/migrations/0087_bomitem_consumable.py b/InvenTree/part/migrations/0087_bomitem_consumable.py new file mode 100644 index 0000000000..79c793c99a --- /dev/null +++ b/InvenTree/part/migrations/0087_bomitem_consumable.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-04-28 00:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0086_auto_20220912_0007'), + ] + + operations = [ + migrations.AddField( + model_name='bomitem', + name='consumable', + field=models.BooleanField(default=False, help_text='This BOM item is consumable (it is not tracked in build orders)', verbose_name='Consumable'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 6fe06e49db..5ab60edbd5 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1134,7 +1134,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): total = None # Prefetch related tables, to reduce query expense - queryset = self.get_bom_items().prefetch_related( + queryset = self.get_bom_items() + + # Ignore 'consumable' BOM items for this calculation + queryset = queryset.filter(consumable=False) + + queryset = queryset.prefetch_related( 'sub_part__stock_items', 'sub_part__stock_items__allocations', 'sub_part__stock_items__sales_order_allocations', @@ -2526,6 +2531,7 @@ class BomItem(DataImportMixin, models.Model): sub_part: Link to the child part (the part that will be consumed) quantity: Number of 'sub_parts' consumed to produce one 'part' optional: Boolean field describing if this BomItem is optional + consumable: Boolean field describing if this BomItem is considered a 'consumable' reference: BOM reference field (e.g. part designators) overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%') note: Note field for this BOM item @@ -2544,6 +2550,7 @@ class BomItem(DataImportMixin, models.Model): 'allow_variants': {}, 'inherited': {}, 'optional': {}, + 'consumable': {}, 'note': {}, 'part': { 'label': _('Part'), @@ -2649,7 +2656,17 @@ class BomItem(DataImportMixin, models.Model): # Quantity required quantity = models.DecimalField(default=1.0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], verbose_name=_('Quantity'), help_text=_('BOM quantity for this BOM item')) - optional = models.BooleanField(default=False, verbose_name=_('Optional'), help_text=_("This BOM item is optional")) + optional = models.BooleanField( + default=False, + verbose_name=_('Optional'), + help_text=_("This BOM item is optional") + ) + + consumable = models.BooleanField( + default=False, + verbose_name=_('Consumable'), + help_text=_("This BOM item is consumable (it is not tracked in build orders)") + ) overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage], verbose_name=_('Overage'), @@ -2698,6 +2715,14 @@ class BomItem(DataImportMixin, models.Model): result_hash.update(str(self.optional).encode()) result_hash.update(str(self.inherited).encode()) + # Optionally encoded for backwards compatibility + if self.consumable: + result_hash.update(str(self.consumable).encode()) + + # Optionally encoded for backwards compatibility + if self.allow_variants: + result_hash.update(str(self.allow_variants).encode()) + return str(result_hash.digest()) def validate_hash(self, valid=True): diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 14140b69d7..5a8aafb0b2 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -760,6 +760,7 @@ class BomItemSerializer(InvenTreeModelSerializer): 'inherited', 'note', 'optional', + 'consumable', 'overage', 'pk', 'part', diff --git a/InvenTree/part/test_bom_export.py b/InvenTree/part/test_bom_export.py index d7e92d1da4..ed93c0f54d 100644 --- a/InvenTree/part/test_bom_export.py +++ b/InvenTree/part/test_bom_export.py @@ -118,6 +118,7 @@ class BomExportTest(InvenTreeTestCase): 'sub_assembly', 'quantity', 'optional', + 'consumable', 'overage', 'reference', 'note', diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index 58c5dfb032..266ae94aec 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -7,6 +7,8 @@ import django.core.exceptions as django_exceptions from django.db import transaction from django.test import TestCase +import stock.models + from .models import BomItem, BomItemSubstitute, Part @@ -197,3 +199,49 @@ class BomItemTest(TestCase): # The substitution links should have been automatically removed self.assertEqual(bom_item.substitutes.count(), 0) + + def test_consumable(self): + """Tests for the 'consumable' BomItem field""" + + # Create an assembly part + assembly = Part.objects.create(name="An assembly", description="Made with parts", assembly=True) + + # No BOM information initially + self.assertEqual(assembly.can_build, 0) + + # Create some component items + c1 = Part.objects.create(name="C1", description="C1") + c2 = Part.objects.create(name="C2", description="C2") + c3 = Part.objects.create(name="C3", description="C3") + c4 = Part.objects.create(name="C4", description="C4") + + for p in [c1, c2, c3, c4]: + # Ensure we have stock + stock.models.StockItem.objects.create(part=p, quantity=1000) + + # Create some BOM items + BomItem.objects.create( + part=assembly, + sub_part=c1, + quantity=10 + ) + + self.assertEqual(assembly.can_build, 100) + + BomItem.objects.create( + part=assembly, + sub_part=c2, + quantity=50, + consumable=True + ) + + # A 'consumable' BomItem does not alter the can_build calculation + self.assertEqual(assembly.can_build, 100) + + BomItem.objects.create( + part=assembly, + sub_part=c3, + quantity=50, + ) + + self.assertEqual(assembly.can_build, 20) diff --git a/InvenTree/stock/migrations/0088_remove_stockitem_infinite.py b/InvenTree/stock/migrations/0088_remove_stockitem_infinite.py new file mode 100644 index 0000000000..65df560bf6 --- /dev/null +++ b/InvenTree/stock/migrations/0088_remove_stockitem_infinite.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.15 on 2022-09-22 02:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0087_auto_20220912_2341'), + ] + + operations = [ + migrations.RemoveField( + model_name='stockitem', + name='infinite', + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 3724653eab..60d994def1 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -224,7 +224,6 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): build: Link to a Build (if this stock item was created from a build) is_building: Boolean field indicating if this stock item is currently being built (or is "in production") purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder) - infinite: If True this StockItem can never be exhausted sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder) purchase_price: The unit purchase price for this StockItem - this is the unit price at time of purchase (if this item was purchased from an external supplier) packaging: Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc) @@ -882,11 +881,6 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): self.save() - # If stock item is incoming, an (optional) ETA field - # expected_arrival = models.DateField(null=True, blank=True) - - infinite = models.BooleanField(default=False) - def is_allocated(self): """Return True if this StockItem is allocated to a SalesOrder or a Build.""" # TODO - For now this only checks if the StockItem is allocated to a SalesOrder @@ -1565,7 +1559,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): except InvalidOperation: return False - if count < 0 or self.infinite: + if count < 0: return False self.stocktake_date = datetime.now().date() @@ -1601,7 +1595,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): return False # Ignore amounts that do not make sense - if quantity <= 0 or self.infinite: + if quantity <= 0: return False if self.updateQuantity(self.quantity + quantity): @@ -1630,7 +1624,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): except InvalidOperation: return False - if quantity <= 0 or self.infinite: + if quantity <= 0: return False if self.updateQuantity(self.quantity - quantity): diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index e41b96cd09..8a49454027 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -382,6 +382,7 @@ function bomItemFields() { note: {}, allow_variants: {}, inherited: {}, + consumable: {}, optional: {}, }; @@ -761,7 +762,22 @@ function loadBomTable(table, options={}) { } return available; + } + function canBuildQuantity(row) { + // Calculate how many of each row we can make, given current stock + + if (row.consumable) { + // If the row is "consumable" we do not 'track' the quantity + return Infinity; + } + + // Prevent div-by-zero or negative errors + if ((row.quantity || 0) <= 0) { + return 0; + } + + return availableQuantity(row) / row.quantity; } // Construct the table columns @@ -844,6 +860,9 @@ function loadBomTable(table, options={}) { { field: 'sub_part_detail.description', title: '{% trans "Description" %}', + formatter: function(value) { + return withTitle(shortenString(value), value); + } } ); @@ -872,8 +891,12 @@ function loadBomTable(table, options={}) { text += ` ${row.sub_part_detail.units}`; } + if (row.consumable) { + text += ` ({% trans "Consumable" %})`; + } + if (row.optional) { - text += ' ({% trans "Optional" %})'; + text += ' ({% trans "Optional" %})'; } if (row.overage) { @@ -966,40 +989,11 @@ function loadBomTable(table, options={}) { if (row.substitutes && row.substitutes.length > 0) { return row.substitutes.length; } else { - return `-`; + return yesNoLabel(false); } } }); - if (show_pricing) { - cols.push({ - field: 'purchase_price_range', - title: '{% trans "Purchase Price Range" %}', - searchable: false, - sortable: true, - }); - - cols.push({ - field: 'purchase_price_avg', - title: '{% trans "Purchase Price Average" %}', - searchable: false, - sortable: true, - }); - - cols.push({ - field: 'price_range', - title: '{% trans "Supplier Cost" %}', - sortable: true, - formatter: function(value) { - if (value) { - return value; - } else { - return `{% trans 'No supplier pricing available' %}`; - } - } - }); - } - cols.push({ field: 'optional', title: '{% trans "Optional" %}', @@ -1009,6 +1003,15 @@ function loadBomTable(table, options={}) { } }); + cols.push({ + field: 'consumable', + title: '{% trans "Consumable" %}', + searchable: false, + formatter: function(value) { + return yesNoLabel(value); + } + }); + cols.push({ field: 'allow_variants', title: '{% trans "Allow Variants" %}', @@ -1037,36 +1040,63 @@ function loadBomTable(table, options={}) { } }); + if (show_pricing) { + cols.push({ + field: 'purchase_price_range', + title: '{% trans "Purchase Price Range" %}', + searchable: false, + sortable: true, + }); + + cols.push({ + field: 'purchase_price_avg', + title: '{% trans "Purchase Price Average" %}', + searchable: false, + sortable: true, + }); + + cols.push({ + field: 'price_range', + title: '{% trans "Supplier Cost" %}', + sortable: true, + formatter: function(value) { + if (value) { + return value; + } else { + return `{% trans 'No supplier pricing available' %}`; + } + } + }); + } + cols.push( { field: 'can_build', title: '{% trans "Can Build" %}', + sortable: true, formatter: function(value, row) { - var can_build = 0; - var available = availableQuantity(row); - - if (row.quantity > 0) { - can_build = available / row.quantity; + // "Consumable" parts are not tracked in the build + if (row.consumable) { + return `{% trans "Consumable item" %}`; } - var text = formatDecimal(can_build, 2); + var can_build = canBuildQuantity(row); - // Take "on order" quantity into account - if (row.on_order && row.on_order > 0 && row.quantity > 0) { - available += row.on_order; - can_build = available / row.quantity; + return +can_build.toFixed(2); + }, + sorter: function(valA, valB, rowA, rowB) { + // Function to sort the "can build" quantity + var cb_a = canBuildQuantity(rowA); + var cb_b = canBuildQuantity(rowB); - text += ``; - } - - return text; + return (cb_a > cb_b) ? 1 : -1; }, footerFormatter: function(data) { var can_build = null; data.forEach(function(row) { - if (row.part == options.parent_id && row.quantity > 0) { + if (row.quantity > 0 && !row.consumable) { var cb = availableQuantity(row) / row.quantity; if (can_build == null || cb < can_build) { @@ -1080,23 +1110,7 @@ function loadBomTable(table, options={}) { } else { return formatDecimal(can_build, 2); } - }, - sorter: function(valA, valB, rowA, rowB) { - // Function to sort the "can build" quantity - var cb_a = 0; - var cb_b = 0; - - if (rowA.quantity > 0) { - cb_a = availableQuantity(rowA) / rowA.quantity; - } - - if (rowB.quantity > 0) { - cb_b = availableQuantity(rowB) / rowB.quantity; - } - - return (cb_a > cb_b) ? 1 : -1; - }, - sortable: true, + } } ); @@ -1107,6 +1121,9 @@ function loadBomTable(table, options={}) { title: '{% trans "Notes" %}', searchable: true, sortable: true, + formatter: function(value) { + return withTitle(shortenString(value), value); + } } ); diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index b09be32ffd..1e3eb85aa2 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -1859,33 +1859,37 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var icons = ''; - if (available_stock < (required - allocated)) { - icons += ``; + if (row.consumable) { + icons += ``; } else { - icons += ``; + if (available_stock < (required - allocated)) { + icons += ``; + } else { + icons += ``; + } + + if (available_stock <= 0) { + icons += `{% trans "No Stock Available" %}`; + } else { + var extra = ''; + if ((substitute_stock > 0) && (variant_stock > 0)) { + extra = '{% trans "Includes variant and substitute stock" %}'; + } else if (variant_stock > 0) { + extra = '{% trans "Includes variant stock" %}'; + } else if (substitute_stock > 0) { + extra = '{% trans "Includes substitute stock" %}'; + } + + if (extra) { + icons += ``; + } + } } if (row.on_order && row.on_order > 0) { icons += ``; } - if (available_stock <= 0) { - icons += `{% trans "No Stock Available" %}`; - } else { - var extra = ''; - if ((substitute_stock > 0) && (variant_stock > 0)) { - extra = '{% trans "Includes variant and substitute stock" %}'; - } else if (variant_stock > 0) { - extra = '{% trans "Includes variant stock" %}'; - } else if (substitute_stock > 0) { - extra = '{% trans "Includes substitute stock" %}'; - } - - if (extra) { - icons += ``; - } - } - return renderLink(text, url) + icons; }, sorter: function(valA, valB, rowA, rowB) { @@ -1898,8 +1902,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { title: '{% trans "Allocated" %}', sortable: true, formatter: function(value, row) { - var allocated = allocatedQuantity(row); var required = requiredQuantity(row); + var allocated = row.consumable ? required : allocatedQuantity(row); return makeProgressBar(allocated, required); }, sorter: function(valA, valB, rowA, rowB) { @@ -1938,6 +1942,11 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { field: 'actions', title: '{% trans "Actions" %}', formatter: function(value, row) { + + if (row.consumable) { + return `{% trans "Consumable item" %}`; + } + // Generate action buttons for this build output var html = `
`; @@ -2093,6 +2102,11 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { for (var idx = 0; idx < bom_items.length; idx++) { var bom_item = bom_items[idx]; + // Ignore "consumable" BOM items + if (bom_item.consumable) { + continue; + } + var required = bom_item.required || 0; var allocated = bom_item.allocated || 0; var remaining = required - allocated; diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index e817c17af8..62a5935e69 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -79,6 +79,14 @@ function getAvailableTableFilters(tableKey) { type: 'bool', title: '{% trans "Allow Variant Stock" %}', }, + optional: { + type: 'bool', + title: '{% trans "Optional" %}', + }, + consumable: { + type: 'bool', + title: '{% trans "Consumable" %}', + }, }; }