diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index 02b993d31b..91863f04e2 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -150,13 +150,13 @@ class DeleteForm(forms.Form): class EditUserForm(HelperForm): - """ Form for editing user information + """ + Form for editing user information """ class Meta: model = User fields = [ - 'username', 'first_name', 'last_name', ] diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index c5a8ad4b67..7bd4fd819d 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -427,8 +427,9 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int): serials = serials.strip() # fill in the next serial number into the serial - if '~' in serials: - serials = serials.replace('~', str(next_number)) + while '~' in serials: + serials = serials.replace('~', str(next_number), 1) + next_number += 1 # Split input string by whitespace or comma (,) characters groups = re.split("[\s,]+", serials) @@ -438,6 +439,12 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int): # Helper function to check for duplicated numbers def add_sn(sn): + # Attempt integer conversion first, so numerical strings are never stored + try: + sn = int(sn) + except ValueError: + pass + if sn in numbers: errors.append(_('Duplicate serial: {sn}').format(sn=sn)) else: @@ -451,15 +458,25 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int): if len(serials) == 0: raise ValidationError([_("Empty serial number string")]) - for group in groups: + # If the user has supplied the correct number of serials, don't process them for groups + # just add them so any duplicates (or future validations) are checked + if len(groups) == expected_quantity: + for group in groups: + add_sn(group) + if len(errors) > 0: + raise ValidationError(errors) + + return numbers + + for group in groups: group = group.strip() # Hyphen indicates a range of numbers if '-' in group: items = group.split('-') - if len(items) == 2: + if len(items) == 2 and all([i.isnumeric() for i in items]): a = items[0].strip() b = items[1].strip() @@ -471,13 +488,14 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int): for n in range(a, b + 1): add_sn(n) else: - errors.append(_("Invalid group: {g}").format(g=group)) + errors.append(_("Invalid group range: {g}").format(g=group)) except ValueError: errors.append(_("Invalid group: {g}").format(g=group)) continue else: - errors.append(_("Invalid group: {g}").format(g=group)) + # More than 2 hyphens or non-numeric group so add without interpolating + add_sn(group) # plus signals either # 1: 'start+': expected number of serials, starting at start @@ -495,23 +513,17 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int): # case 1 else: - end = start + expected_quantity + end = start + (expected_quantity - len(numbers)) for n in range(start, end): add_sn(n) # no case else: - errors.append(_("Invalid group: {g}").format(g=group)) + errors.append(_("Invalid group sequence: {g}").format(g=group)) # At this point, we assume that the "group" is just a single serial value elif group: - - try: - # First attempt to add as an integer value - add_sn(int(group)) - except (ValueError): - # As a backup, add as a string value - add_sn(group) + add_sn(group) # No valid input group detected else: diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 8b43e2191c..33168fad2d 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -62,12 +62,6 @@ DEBUG = _is_true(get_setting( CONFIG.get('debug', True) )) -# Determine if we are running in "demo mode" -DEMO_MODE = _is_true(get_setting( - 'INVENTREE_DEMO', - CONFIG.get('demo', False) -)) - DOCKER = _is_true(get_setting( 'INVENTREE_DOCKER', False @@ -217,9 +211,6 @@ MEDIA_URL = '/media/' if DEBUG: logger.info("InvenTree running with DEBUG enabled") -if DEMO_MODE: - logger.warning("InvenTree running in DEMO mode") # pragma: no cover - logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'") logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'") @@ -921,7 +912,7 @@ PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False) # load plugin PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5) # how often should plugin loading be tried? PLUGIN_FILE_CHECKED = False # Was the plugin file checked? -# user interface customization values +# User interface customization values CUSTOMIZE = get_setting( 'INVENTREE_CUSTOMIZE', CONFIG.get('customize', {}), diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 669628bdea..13f9198d92 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -252,6 +252,31 @@ class TestSerialNumberExtraction(TestCase): sn = e("1, 2, 3, 4, 5", 5, 1) self.assertEqual(len(sn), 5) + # Test partially specifying serials + sn = e("1, 2, 4+", 5, 1) + self.assertEqual(len(sn), 5) + self.assertEqual(sn, [1, 2, 4, 5, 6]) + + # Test groups are not interpolated if enough serials are supplied + sn = e("1, 2, 3, AF5-69H, 5", 5, 1) + self.assertEqual(len(sn), 5) + self.assertEqual(sn, [1, 2, 3, "AF5-69H", 5]) + + # Test groups are not interpolated with more than one hyphen in a word + sn = e("1, 2, TG-4SR-92, 4+", 5, 1) + self.assertEqual(len(sn), 5) + self.assertEqual(sn, [1, 2, "TG-4SR-92", 4, 5]) + + # Test groups are not interpolated with alpha characters + sn = e("1, A-2, 3+", 5, 1) + self.assertEqual(len(sn), 5) + self.assertEqual(sn, [1, "A-2", 3, 4, 5]) + + # Test multiple placeholders + sn = e("1 2 ~ ~ ~", 5, 3) + self.assertEqual(len(sn), 5) + self.assertEqual(sn, [1, 2, 3, 4, 5]) + sn = e("1-5, 10-15", 11, 1) self.assertIn(3, sn) self.assertIn(13, sn) @@ -307,6 +332,10 @@ class TestSerialNumberExtraction(TestCase): with self.assertRaises(ValidationError): e("10, a, 7-70j", 4, 1) + # Test groups are not interpolated with word characters + with self.assertRaises(ValidationError): + e("1, 2, 3, E-5", 5, 1) + def test_combinations(self): e = helpers.extract_serial_numbers diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index feb586c844..183e491580 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -34,8 +34,7 @@ from user_sessions.views import SessionDeleteView, SessionDeleteOtherView from common.settings import currency_code_default, currency_codes -from part.models import Part, PartCategory -from stock.models import StockLocation, StockItem +from part.models import PartCategory from common.models import InvenTreeSetting, ColorTheme from users.models import check_user_role, RuleSet @@ -882,29 +881,6 @@ class DatabaseStatsView(AjaxView): ajax_template_name = "stats.html" ajax_form_title = _("System Information") - def get_context_data(self, **kwargs): - - ctx = {} - - # Part stats - ctx['part_count'] = Part.objects.count() - ctx['part_cat_count'] = PartCategory.objects.count() - - # Stock stats - ctx['stock_item_count'] = StockItem.objects.count() - ctx['stock_loc_count'] = StockLocation.objects.count() - - """ - TODO: Other ideas for database metrics - - - "Popular" parts (used to make other parts?) - - Most ordered part - - Most sold part - - etc etc etc - """ - - return ctx - class NotificationsView(TemplateView): """ View for showing notifications diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 33f3f4ab36..a720f7cbe0 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -96,6 +96,7 @@ class BuildList(generics.ListCreateAPIView): 'target_date', 'completion_date', 'quantity', + 'completed', 'issued_by', 'responsible', ] @@ -442,6 +443,18 @@ class BuildItemList(generics.ListCreateAPIView): if part_pk: queryset = queryset.filter(stock_item__part=part_pk) + # Filter by "tracked" status + # Tracked means that the item is "installed" into a build output (stock item) + tracked = params.get('tracked', None) + + if tracked is not None: + tracked = str2bool(tracked) + + if tracked: + queryset = queryset.exclude(install_into=None) + else: + queryset = queryset.filter(install_into=None) + # Filter by output target output = params.get('output', None) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index e5189e6073..86bb256539 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -1260,7 +1260,7 @@ class BuildItem(models.Model): }) @transaction.atomic - def complete_allocation(self, user): + def complete_allocation(self, user, notes=''): """ Complete the allocation of this BuildItem into the output stock item. @@ -1286,8 +1286,13 @@ class BuildItem(models.Model): self.save() # Install the stock item into the output - item.belongs_to = self.install_into - item.save() + self.install_into.installStockItem( + item, + self.quantity, + user, + notes + ) + else: # Simply remove the items from stock item.take_stock( diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 4b55182563..d037ad546e 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -161,7 +161,12 @@ class BuildOutputSerializer(serializers.Serializer): # The build output must have all tracked parts allocated if not build.is_fully_allocated(output): - raise ValidationError(_("This build output is not fully allocated")) + + # Check if the user has specified that incomplete allocations are ok + accept_incomplete = InvenTree.helpers.str2bool(self.context['request'].data.get('accept_incomplete_allocation', False)) + + if not accept_incomplete: + raise ValidationError(_("This build output is not fully allocated")) return output @@ -355,6 +360,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer): 'outputs', 'location', 'status', + 'accept_incomplete_allocation', 'notes', ] @@ -377,6 +383,13 @@ class BuildOutputCompleteSerializer(serializers.Serializer): label=_("Status"), ) + accept_incomplete_allocation = serializers.BooleanField( + default=False, + required=False, + label=_('Accept Incomplete Allocation'), + help_text=_('Complete ouputs if stock has not been fully allocated'), + ) + notes = serializers.CharField( label=_("Notes"), required=False, @@ -617,6 +630,7 @@ class BuildAllocationItemSerializer(serializers.Serializer): super().validate(data) + build = self.context['build'] bom_item = data['bom_item'] stock_item = data['stock_item'] quantity = data['quantity'] @@ -641,16 +655,20 @@ class BuildAllocationItemSerializer(serializers.Serializer): # Output *must* be set for trackable parts if output is None and bom_item.sub_part.trackable: raise ValidationError({ - 'output': _('Build output must be specified for allocation of tracked parts') + 'output': _('Build output must be specified for allocation of tracked parts'), }) # Output *cannot* be set for un-tracked parts if output is not None and not bom_item.sub_part.trackable: raise ValidationError({ - 'output': _('Build output cannot be specified for allocation of untracked parts') + 'output': _('Build output cannot be specified for allocation of untracked parts'), }) + # Check if this allocation would be unique + if BuildItem.objects.filter(build=build, stock_item=stock_item, install_into=output).exists(): + raise ValidationError(_('This stock item has already been allocated to this build output')) + return data diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 92e1177e0f..42bc51bb2f 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -270,6 +270,16 @@ + {% if build.has_tracked_bom_items %} + + + + {% endif %} + {% include "filter_list.html" with id='incompletebuilditems' %} {% endif %} @@ -401,110 +411,53 @@ function reloadTable() { $('#allocation-table-untracked').bootstrapTable('refresh'); } -// Get the list of BOM items required for this build -inventreeGet( - '{% url "api-bom-list" %}', - { +onPanelLoad('outputs', function() { + {% if build.active %} + + var build_info = { + pk: {{ build.pk }}, part: {{ build.part.pk }}, - sub_part_detail: true, - }, - { - success: function(response) { + quantity: {{ build.quantity }}, + {% if build.take_from %} + source_location: {{ build.take_from.pk }}, + {% endif %} + tracked_parts: true, + }; - var build_info = { - pk: {{ build.pk }}, - part: {{ build.part.pk }}, - quantity: {{ build.quantity }}, - bom_items: response, - {% if build.take_from %} - source_location: {{ build.take_from.pk }}, - {% endif %} - {% if build.has_tracked_bom_items %} - tracked_parts: true, - {% else %} - tracked_parts: false, - {% endif %} - }; + loadBuildOutputTable(build_info); - {% if build.active %} - loadBuildOutputTable(build_info); - linkButtonsToSelection( - '#build-output-table', - [ - '#output-options', - '#multi-output-complete', - '#multi-output-delete', - ] - ); + {% endif %} +}); - $('#multi-output-complete').click(function() { - var outputs = $('#build-output-table').bootstrapTable('getSelections'); +{% if build.active and build.has_untracked_bom_items %} - completeBuildOutputs( - build_info.pk, - outputs, - { - success: function() { - // Reload the "in progress" table - $('#build-output-table').bootstrapTable('refresh'); +function loadUntrackedStockTable() { - // Reload the "completed" table - $('#build-stock-table').bootstrapTable('refresh'); - } - } - ); - }); + var build_info = { + pk: {{ build.pk }}, + part: {{ build.part.pk }}, + quantity: {{ build.quantity }}, + {% if build.take_from %} + source_location: {{ build.take_from.pk }}, + {% endif %} + tracked_parts: false, + }; + + $('#allocation-table-untracked').bootstrapTable('destroy'); - $('#multi-output-delete').click(function() { - var outputs = $('#build-output-table').bootstrapTable('getSelections'); - - deleteBuildOutputs( - build_info.pk, - outputs, - { - success: function() { - // Reload the "in progress" table - $('#build-output-table').bootstrapTable('refresh'); - - // Reload the "completed" table - $('#build-stock-table').bootstrapTable('refresh'); - } - } - ) - }); - - $('#incomplete-output-print-label').click(function() { - var outputs = $('#build-output-table').bootstrapTable('getSelections'); - - if (outputs.length == 0) { - outputs = $('#build-output-table').bootstrapTable('getData'); - } - - var stock_id_values = []; - - outputs.forEach(function(output) { - stock_id_values.push(output.pk); - }); - - printStockItemLabels(stock_id_values); - - }); - - {% endif %} - - {% if build.active and build.has_untracked_bom_items %} - // Load allocation table for un-tracked parts - loadBuildOutputAllocationTable( - build_info, - null, - { - search: true, - } - ); - {% endif %} + // Load allocation table for un-tracked parts + loadBuildOutputAllocationTable( + build_info, + null, + { + search: true, } - } -); + ); +} + +loadUntrackedStockTable(); + +{% endif %} $('#btn-create-output').click(function() { @@ -527,6 +480,7 @@ $("#btn-auto-allocate").on('click', function() { {% if build.take_from %} location: {{ build.take_from.pk }}, {% endif %} + onSuccess: loadUntrackedStockTable, } ); }); @@ -558,9 +512,7 @@ $("#btn-allocate").on('click', function() { {% if build.take_from %} source_location: {{ build.take_from.pk }}, {% endif %} - success: function(data) { - $('#allocation-table-untracked').bootstrapTable('refresh'); - } + success: loadUntrackedStockTable, } ); } @@ -569,6 +521,7 @@ $("#btn-allocate").on('click', function() { $('#btn-unallocate').on('click', function() { unallocateStock({{ build.id }}, { table: '#allocation-table-untracked', + onSuccess: loadUntrackedStockTable, }); }); @@ -588,9 +541,7 @@ $('#allocate-selected-items').click(function() { {% if build.take_from %} source_location: {{ build.take_from.pk }}, {% endif %} - success: function(data) { - $('#allocation-table-untracked').bootstrapTable('refresh'); - } + success: loadUntrackedStockTable, } ); }); diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index a9a1db9ad8..cad7012ebe 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -201,3 +201,5 @@ static_root: '/home/inventree/data/static' # login_message: InvenTree demo instance - Click here for login details # navbar_message:
${data.title}
`; + html += renderId('{% trans "Build ID" %}', data.pk, parameters); return html; } @@ -300,12 +297,9 @@ function renderSalesOrderShipment(name, data, parameters={}, options={}) { var so_prefix = global_settings.SALESORDER_REFERENCE_PREFIX; - var html = ` - ${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference} - - {% trans "Shipment ID" %}: ${data.pk} - - `; + var html = `${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference}`; + + html += renderId('{% trans "Shipment ID" %}', data.pk, parameters); return html; } @@ -323,7 +317,7 @@ function renderPartCategory(name, data, parameters={}, options={}) { html += ` - ${data.description}`; } - html += `{% trans "Category ID" %}: ${data.pk}`; + html += renderId('{% trans "Category ID" %}', data.pk, parameters); return html; } @@ -366,7 +360,7 @@ function renderManufacturerPart(name, data, parameters={}, options={}) { html += ` ${data.manufacturer_detail.name} - ${data.MPN}`; html += ` - ${data.part_detail.full_name}`; - html += `{% trans "Manufacturer Part ID" %}: ${data.pk}`; + html += renderId('{% trans "Manufacturer Part ID" %}', data.pk, parameters); return html; } @@ -395,9 +389,7 @@ function renderSupplierPart(name, data, parameters={}, options={}) { html += ` ${data.supplier_detail.name} - ${data.SKU}`; html += ` - ${data.part_detail.full_name}`; - html += `{% trans "Supplier Part ID" %}: ${data.pk}`; - + html += renderId('{% trans "Supplier Part ID" %}', data.pk, parameters); return html; - } diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index d552bcb9d7..57f76dcae4 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -500,6 +500,11 @@ function duplicateBom(part_id, options={}) { */ function partStockLabel(part, options={}) { + // Prevent literal string 'null' from being displayed + if (part.units == null) { + part.units = ''; + } + if (part.in_stock) { // There IS stock available for this part diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html index d7d70db59f..4660123e0d 100644 --- a/InvenTree/templates/navbar.html +++ b/InvenTree/templates/navbar.html @@ -6,9 +6,10 @@ {% settings_value 'BARCODE_ENABLE' as barcodes %} {% settings_value 'STICKY_HEADER' user=request.user as sticky %} {% navigation_enabled as plugin_nav %} -{% inventree_demo_mode as demo %} + {% inventree_show_about user as show_about %} {% inventree_customize 'navbar_message' as navbar_message %} +{% inventree_customize 'hide_admin_link' as hide_admin_link %}