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:
InvenTree demo mode
# logo: logo.png +# hide_admin_link: true +# hide_password_reset: true diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 1a80c87322..3752f7daf4 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -383,7 +383,7 @@ class PartTestTemplateList(generics.ListCreateAPIView): required = params.get('required', None) if required is not None: - queryset = queryset.filter(required=required) + queryset = queryset.filter(required=str2bool(required)) return queryset diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 1edae69351..365ed62914 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2740,8 +2740,8 @@ class BomItem(models.Model, DataImportMixin): if not p.active: continue - # Trackable parts cannot be 'auto allocated' - if p.trackable: + # Trackable status must be the same as the sub_part + if p.trackable != self.sub_part.trackable: continue valid_parts.append(p) diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 5701658087..889946ff19 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -160,13 +160,6 @@ def inventree_in_debug_mode(*args, **kwargs): return djangosettings.DEBUG -@register.simple_tag() -def inventree_demo_mode(*args, **kwargs): - """ Return True if the server is running in DEMO mode """ - - return djangosettings.DEMO_MODE - - @register.simple_tag() def inventree_show_about(user, *args, **kwargs): """ Return True if the about modal should be shown """ diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index d4fc5c93d1..c88c29e64e 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -23,6 +23,8 @@ from rest_framework.serializers import ValidationError from rest_framework.response import Response from rest_framework import generics, filters +from build.models import Build + import common.settings import common.models @@ -1159,6 +1161,19 @@ class StockItemTestResultList(generics.ListCreateAPIView): queryset = super().filter_queryset(queryset) + # Filter by 'build' + build = params.get('build', None) + + if build is not None: + + try: + build = Build.objects.get(pk=build) + + queryset = queryset.filter(stock_item__build=build) + + except (ValueError, Build.DoesNotExist): + pass + # Filter by stock item item = params.get('stock_item', None) diff --git a/InvenTree/templates/InvenTree/settings/user.html b/InvenTree/templates/InvenTree/settings/user.html index 32bc4d43e7..d3d1e35210 100644 --- a/InvenTree/templates/InvenTree/settings/user.html +++ b/InvenTree/templates/InvenTree/settings/user.html @@ -13,15 +13,15 @@ {% endblock %} {% block actions %} -{% inventree_demo_mode as demo %} -{% if not demo %} +{% inventree_customize 'hide_password_reset' as hide_password_reset %} +{% if not hide_password_reset %}
{% trans "Set Password" %}
+{% endif %}
{% trans "Edit" %}
-{% endif %} {% endblock %} {% block content %} diff --git a/InvenTree/templates/account/login.html b/InvenTree/templates/account/login.html index fcdd08a23c..042c119440 100644 --- a/InvenTree/templates/account/login.html +++ b/InvenTree/templates/account/login.html @@ -12,7 +12,6 @@ {% settings_value 'LOGIN_ENABLE_SSO' as enable_sso %} {% inventree_customize 'login_message' as login_message %} {% mail_configured as mail_conf %} -{% inventree_demo_mode as demo %}

{% trans "Sign In" %}

@@ -37,12 +36,12 @@ for a account and sign in below:{% endblocktrans %}


{% if login_message %} -
{{ login_message }}
+
{{ login_message | safe }}
{% endif %}
- {% if mail_conf and enable_pwd_forgot and not demo %} + {% if mail_conf and enable_pwd_forgot %} {% trans "Forgot Password?" %} {% endif %} diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index 0188ecefa5..8c2a949353 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -8,7 +8,6 @@ {% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %} {% settings_value "LABEL_ENABLE" with user=user as labels_enabled %} {% inventree_show_about user as show_about %} -{% inventree_demo_mode as demo_mode %} @@ -94,7 +93,7 @@ {% block alerts %}
- {% if server_restart_required and not demo_mode %} + {% if server_restart_required %}