diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 0b34dee851..593a01bbf6 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -342,7 +342,7 @@ def normalize(d): return d.quantize(Decimal(1)) if d == d.to_integral() else d.normalize() -def increment(n): +def increment(value): """Attempt to increment an integer (or a string that looks like an integer). e.g. @@ -351,12 +351,14 @@ def increment(n): 2 -> 3 AB01 -> AB02 QQQ -> QQQ + """ - value = str(n).strip() + value = str(value).strip() # Ignore empty strings - if not value: - return value + if value in ['', None]: + # Provide a default value if provided with a null input + return '1' pattern = r"(.*?)(\d+)?$" @@ -542,138 +544,211 @@ def DownloadFile(data, filename, content_type='application/text', inline=False) return response -def extract_serial_numbers(serials, expected_quantity, next_number: int): - """Attempt to extract serial numbers from an input string. +def increment_serial_number(serial: str): + """Given a serial number, (attempt to) generate the *next* serial number. - Requirements: - - Serial numbers can be either strings, or integers - - Serial numbers can be split by whitespace / newline / commma chars - - Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20 - - Serial numbers can be defined as ~ for getting the next available serial number - - Serial numbers can be supplied as + for getting all expecteded numbers starting from - - Serial numbers can be supplied as + for getting numbers starting from + Note: This method is exposed to custom plugins. - Args: - serials: input string with patterns - expected_quantity: The number of (unique) serial numbers we expect - next_number(int): the next possible serial number + Arguments: + serial: The serial number which should be incremented + + Returns: + incremented value, or None if incrementing could not be performed. """ - serials = serials.strip() - # fill in the next serial number into the serial - while '~' in serials: - serials = serials.replace('~', str(next_number), 1) - next_number += 1 + from plugin.registry import registry - # Split input string by whitespace or comma (,) characters - groups = re.split(r"[\s,]+", serials) + # Ensure we start with a string value + if serial is not None: + serial = str(serial).strip() - numbers = [] - errors = [] + # First, let any plugins attempt to increment the serial number + for plugin in registry.with_mixin('validation'): + result = plugin.increment_serial_number(serial) + if result is not None: + return str(result) - # 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 we get to here, no plugins were able to "increment" the provided serial value + # Attempt to perform increment according to some basic rules + return increment(serial) - if sn in numbers: - errors.append(_('Duplicate serial: {sn}').format(sn=sn)) - else: - numbers.append(sn) + +def extract_serial_numbers(input_string, expected_quantity: int, starting_value=None): + """Extract a list of serial numbers from a provided input string. + + The input string can be specified using the following concepts: + + - Individual serials are separated by comma: 1, 2, 3, 6,22 + - Sequential ranges with provided limits are separated by hyphens: 1-5, 20 - 40 + - The "next" available serial number can be specified with the tilde (~) character + - Serial numbers can be supplied as + for getting all expecteded numbers starting from + - Serial numbers can be supplied as + for getting numbers starting from + + Actual generation of sequential serials is passed to the 'validation' plugin mixin, + allowing custom plugins to determine how serial values are incremented. + + Arguments: + input_string: Input string with specified serial numbers (string, or integer) + expected_quantity: The number of (unique) serial numbers we expect + starting_value: Provide a starting value for the sequence (or None) + """ + + if starting_value is None: + starting_value = increment_serial_number(None) try: expected_quantity = int(expected_quantity) except ValueError: raise ValidationError([_("Invalid quantity provided")]) - if len(serials) == 0: + if input_string: + input_string = str(input_string).strip() + else: + input_string = '' + + if len(input_string) == 0: raise ValidationError([_("Empty serial number string")]) - # 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 + next_value = increment_serial_number(starting_value) + + # Substitute ~ character with latest value + while '~' in input_string and next_value: + input_string = input_string.replace('~', str(next_value), 1) + next_value = increment_serial_number(next_value) + + # Split input string by whitespace or comma (,) characters + groups = re.split(r"[\s,]+", input_string) + + serials = [] + errors = [] + + def add_error(error: str): + """Helper function for adding an error message""" + if error not in errors: + errors.append(error) + + def add_serial(serial): + """Helper function to check for duplicated values""" + if serial in serials: + add_error(_("Duplicate serial") + f": {serial}") + else: + serials.append(serial) + + # If the user has supplied the correct number of serials, do not split into groups if len(groups) == expected_quantity: for group in groups: - add_sn(group) + add_serial(group) if len(errors) > 0: raise ValidationError(errors) - - return numbers + else: + return serials for group in groups: group = group.strip() - # Hyphen indicates a range of numbers if '-' in group: + """Hyphen indicates a range of values: + e.g. 10-20 + """ items = group.split('-') - if len(items) == 2 and all([i.isnumeric() for i in items]): - a = items[0].strip() - b = items[1].strip() + if len(items) == 2: + a = items[0] + b = items[1] - try: - a = int(a) - b = int(b) - - if a < b: - for n in range(a, b + 1): - add_sn(n) - else: - errors.append(_("Invalid group range: {g}").format(g=group)) - - except ValueError: - errors.append(_("Invalid group: {g}").format(g=group)) + if a == b: + # Invalid group + add_error(_("Invalid group range: {g}").format(g=group)) continue - else: - # 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 - # 2: 'start+number': number of serials, starting at start + group_items = [] + + count = 0 + + a_next = a + + while a_next is not None and a_next not in group_items: + group_items.append(a_next) + count += 1 + + # Progress to the 'next' sequential value + a_next = str(increment_serial_number(a_next)) + + if a_next == b: + # Successfully got to the end of the range + group_items.append(b) + break + + elif count > expected_quantity: + # More than the allowed number of items + break + + elif a_next is None: + break + + if len(group_items) > 0 and group_items[0] == a and group_items[-1] == b: + # In this case, the range extraction looks like it has worked + for item in group_items: + add_serial(item) + else: + add_serial(group) + # add_error(_("Invalid group range: {g}").format(g=group)) + + else: + # In the case of a different number of hyphens, simply add the entire group + add_serial(group) + elif '+' in group: + """Plus character (+) indicates either: + - + - Expected number of serials, beginning at the specified 'start' character + - + - Specified number of serials, beginning at the specified 'start' character + """ items = group.split('+') - # case 1, 2 - if len(items) == 2: - start = int(items[0]) + sequence_items = [] + counter = 0 + sequence_count = max(0, expected_quantity - len(serials)) - # case 2 - if bool(items[1]): - end = start + int(items[1]) + 1 + if len(items) > 2 or len(items) == 0: + add_error(_("Invalid group sequence: {g}").format(g=group)) + continue + elif len(items) == 2: + try: + if items[1] not in ['', None]: + sequence_count = int(items[1]) + 1 + except ValueError: + add_error(_("Invalid group sequence: {g}").format(g=group)) + continue - # case 1 - else: - end = start + (expected_quantity - len(numbers)) + value = items[0] - for n in range(start, end): - add_sn(n) - # no case + # Keep incrementing up to the specified quantity + while value is not None and value not in sequence_items and counter < sequence_count: + sequence_items.append(value) + value = increment_serial_number(value) + counter += 1 + + if len(sequence_items) == sequence_count: + for item in sequence_items: + add_serial(item) else: - errors.append(_("Invalid group sequence: {g}").format(g=group)) + add_error(_("Invalid group sequence: {g}").format(g=group)) - # At this point, we assume that the "group" is just a single serial value - elif group: - add_sn(group) - - # No valid input group detected else: - raise ValidationError(_(f"Invalid/no group {group}")) + # At this point, we assume that the 'group' is just a single serial value + add_serial(group) if len(errors) > 0: raise ValidationError(errors) - if len(numbers) == 0: + if len(serials) == 0: raise ValidationError([_("No serial numbers found")]) - # The number of extracted serial numbers must match the expected quantity - if expected_quantity != len(numbers): - raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)]) + if len(serials) != expected_quantity: + raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(serials), q=expected_quantity)]) - return numbers + return serials def validateFilterString(value, model=None): diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 4c827d39a7..3658a216cf 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -679,6 +679,10 @@ main { color: #A94442; } +.form-error-message { + display: block; +} + .modal input { width: 100%; } diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 5cf07df637..145b271480 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -39,8 +39,9 @@ class ValidatorTest(TestCase): """Test part name validator.""" validate_part_name('hello world') + # Validate with some strange chars with self.assertRaises(django_exceptions.ValidationError): - validate_part_name('This | name is not } valid') + validate_part_name('### <> This | name is not } valid') def test_overage(self): """Test overage validator.""" @@ -309,7 +310,7 @@ class TestIncrement(TestCase): def tests(self): """Test 'intelligent' incrementing function.""" tests = [ - ("", ""), + ("", '1'), (1, "2"), ("001", "002"), ("1001", "1002"), @@ -418,7 +419,11 @@ class TestMPTT(TestCase): class TestSerialNumberExtraction(TestCase): - """Tests for serial number extraction code.""" + """Tests for serial number extraction code. + + Note that while serial number extraction is made available to custom plugins, + only simple integer-based extraction is tested here. + """ def test_simple(self): """Test simple serial numbers.""" @@ -427,7 +432,7 @@ class TestSerialNumberExtraction(TestCase): sn = e("1-5", 5, 1) self.assertEqual(len(sn), 5, 1) for i in range(1, 6): - self.assertIn(i, sn) + self.assertIn(str(i), sn) sn = e("1, 2, 3, 4, 5", 5, 1) self.assertEqual(len(sn), 5) @@ -435,55 +440,55 @@ class TestSerialNumberExtraction(TestCase): # Test partially specifying serials sn = e("1, 2, 4+", 5, 1) self.assertEqual(len(sn), 5) - self.assertEqual(sn, [1, 2, 4, 5, 6]) + 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]) + 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]) + 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]) + self.assertEqual(sn, ['1', "A-2", '3', '4', '5']) # Test multiple placeholders - sn = e("1 2 ~ ~ ~", 5, 3) + sn = e("1 2 ~ ~ ~", 5, 2) self.assertEqual(len(sn), 5) - self.assertEqual(sn, [1, 2, 3, 4, 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) + self.assertIn('3', sn) + self.assertIn('13', sn) sn = e("1+", 10, 1) self.assertEqual(len(sn), 10) - self.assertEqual(sn, [_ for _ in range(1, 11)]) + self.assertEqual(sn, [str(_) for _ in range(1, 11)]) sn = e("4, 1+2", 4, 1) self.assertEqual(len(sn), 4) - self.assertEqual(sn, [4, 1, 2, 3]) + self.assertEqual(sn, ['4', '1', '2', '3']) sn = e("~", 1, 1) self.assertEqual(len(sn), 1) - self.assertEqual(sn, [1]) + self.assertEqual(sn, ['2']) sn = e("~", 1, 3) self.assertEqual(len(sn), 1) - self.assertEqual(sn, [3]) + self.assertEqual(sn, ['4']) - sn = e("~+", 2, 5) + sn = e("~+", 2, 4) self.assertEqual(len(sn), 2) - self.assertEqual(sn, [5, 6]) + self.assertEqual(sn, ['5', '6']) - sn = e("~+3", 4, 5) + sn = e("~+3", 4, 4) self.assertEqual(len(sn), 4) - self.assertEqual(sn, [5, 6, 7, 8]) + self.assertEqual(sn, ['5', '6', '7', '8']) def test_failures(self): """Test wron serial numbers.""" @@ -522,19 +527,19 @@ class TestSerialNumberExtraction(TestCase): sn = e("1 3-5 9+2", 7, 1) self.assertEqual(len(sn), 7) - self.assertEqual(sn, [1, 3, 4, 5, 9, 10, 11]) + self.assertEqual(sn, ['1', '3', '4', '5', '9', '10', '11']) sn = e("1,3-5,9+2", 7, 1) self.assertEqual(len(sn), 7) - self.assertEqual(sn, [1, 3, 4, 5, 9, 10, 11]) + self.assertEqual(sn, ['1', '3', '4', '5', '9', '10', '11']) - sn = e("~+2", 3, 14) + sn = e("~+2", 3, 13) self.assertEqual(len(sn), 3) - self.assertEqual(sn, [14, 15, 16]) + self.assertEqual(sn, ['14', '15', '16']) - sn = e("~+", 2, 14) + sn = e("~+", 2, 13) self.assertEqual(len(sn), 2) - self.assertEqual(sn, [14, 15]) + self.assertEqual(sn, ['14', '15']) class TestVersionNumber(TestCase): diff --git a/InvenTree/InvenTree/validators.py b/InvenTree/InvenTree/validators.py index 82368e90ac..c05a573cf2 100644 --- a/InvenTree/InvenTree/validators.py +++ b/InvenTree/InvenTree/validators.py @@ -47,16 +47,40 @@ class AllowedURLValidator(validators.URLValidator): def validate_part_name(value): - """Prevent some illegal characters in part names.""" - for c in ['|', '#', '$', '{', '}']: - if c in str(value): - raise ValidationError( - _('Invalid character in part name') - ) + """Validate the name field for a Part instance + + This function is exposed to any Validation plugins, and thus can be customized. + """ + + from plugin.registry import registry + + for plugin in registry.with_mixin('validation'): + # Run the name through each custom validator + # If the plugin returns 'True' we will skip any subsequent validation + if plugin.validate_part_name(value): + return def validate_part_ipn(value): - """Validate the Part IPN against regex rule.""" + """Validate the IPN field for a Part instance. + + This function is exposed to any Validation plugins, and thus can be customized. + + If no validation errors are raised, the IPN is also validated against a configurable regex pattern. + """ + + from plugin.registry import registry + + plugins = registry.with_mixin('validation') + + for plugin in plugins: + # Run the IPN through each custom validator + # If the plugin returns 'True' we will skip any subsequent validation + if plugin.validate_part_ipn(value): + return + + # If we get to here, none of the plugins have raised an error + pattern = common.models.InvenTreeSetting.get_setting('PART_IPN_REGEX') if pattern: @@ -68,28 +92,25 @@ def validate_part_ipn(value): def validate_purchase_order_reference(value): """Validate the 'reference' field of a PurchaseOrder.""" - pattern = common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_REGEX') - if pattern: - match = re.search(pattern, value) + from order.models import PurchaseOrder - if match is None: - raise ValidationError(_('Reference must match pattern {pattern}').format(pattern=pattern)) + # If we get to here, run the "default" validation routine + PurchaseOrder.validate_reference_field(value) def validate_sales_order_reference(value): """Validate the 'reference' field of a SalesOrder.""" - pattern = common.models.InvenTreeSetting.get_setting('SALESORDER_REFERENCE_REGEX') - if pattern: - match = re.search(pattern, value) + from order.models import SalesOrder - if match is None: - raise ValidationError(_('Reference must match pattern {pattern}').format(pattern=pattern)) + # If we get to here, run the "default" validation routine + SalesOrder.validate_reference_field(value) def validate_tree_name(value): """Placeholder for legacy function used in migrations.""" + ... def validate_overage(value): diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index fd880c7a38..b22902bd65 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -14,7 +14,6 @@ from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentS from InvenTree.serializers import UserSerializer import InvenTree.helpers -from InvenTree.helpers import extract_serial_numbers from InvenTree.serializers import InvenTreeDecimalField from InvenTree.status_codes import StockStatus @@ -260,7 +259,11 @@ class BuildOutputCreateSerializer(serializers.Serializer): if serial_numbers: try: - self.serials = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt()) + self.serials = InvenTree.helpers.extract_serial_numbers( + serial_numbers, + quantity, + part.get_latest_serial_number() + ) except DjangoValidationError as e: raise ValidationError({ 'serial_numbers': e.messages, @@ -270,12 +273,12 @@ class BuildOutputCreateSerializer(serializers.Serializer): existing = [] for serial in self.serials: - if part.checkIfSerialNumberExists(serial): + if not part.validate_serial_number(serial): existing.append(serial) if len(existing) > 0: - msg = _("The following serial numbers already exist") + msg = _("The following serial numbers already exist or are invalid") msg += " : " msg += ",".join([str(e) for e in existing]) diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index 02304b0066..33c4d541f5 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -389,7 +389,7 @@ class BuildTest(BuildAPITest): expected_code=400, ) - self.assertIn('The following serial numbers already exist : 1,2,3', str(response.data)) + self.assertIn('The following serial numbers already exist or are invalid : 1,2,3', str(response.data)) # Double check no new outputs have been created self.assertEqual(n_outputs + 5, bo.output_count) diff --git a/InvenTree/build/validators.py b/InvenTree/build/validators.py index 7e39735efb..5d529218a1 100644 --- a/InvenTree/build/validators.py +++ b/InvenTree/build/validators.py @@ -18,8 +18,9 @@ def validate_build_order_reference_pattern(pattern): def validate_build_order_reference(value): - """Validate that the BuildOrder reference field matches the required pattern""" + """Validate that the BuildOrder reference field matches the required pattern.""" from build.models import Build + # If we get to here, run the "default" validation routine Build.validate_reference_field(value) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index a972e8487b..582503ae6f 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1139,6 +1139,13 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, }, + 'SERIAL_NUMBER_GLOBALLY_UNIQUE': { + 'name': _('Globally Unique Serials'), + 'description': _('Serial numbers for stock items must be globally unique'), + 'default': False, + 'validator': bool, + }, + 'STOCK_BATCH_CODE_TEMPLATE': { 'name': _('Batch Code Template'), 'description': _('Template for generating default batch codes for stock items'), diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 6d23e83628..33d4c7f535 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -531,7 +531,11 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): if serial_numbers: try: # Pass the serial numbers through to the parent serializer once validated - data['serials'] = extract_serial_numbers(serial_numbers, pack_quantity, base_part.getLatestSerialNumberInt()) + data['serials'] = extract_serial_numbers( + serial_numbers, + pack_quantity, + base_part.get_latest_serial_number() + ) except DjangoValidationError as e: raise ValidationError({ 'serial_numbers': e.messages, @@ -1256,7 +1260,11 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer): part = line_item.part try: - data['serials'] = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt()) + data['serials'] = extract_serial_numbers( + serial_numbers, + quantity, + part.get_latest_serial_number() + ) except DjangoValidationError as e: raise ValidationError({ 'serial_numbers': e.messages, diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 59fba9b2c8..6633758b43 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -25,8 +25,8 @@ from company.models import Company, ManufacturerPart, SupplierPart from InvenTree.api import (APIDownloadMixin, AttachmentMixin, ListCreateDestroyAPIView) from InvenTree.filters import InvenTreeOrderingFilter -from InvenTree.helpers import (DownloadFile, increment, isNull, str2bool, - str2int) +from InvenTree.helpers import (DownloadFile, increment_serial_number, isNull, + str2bool, str2int) from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, RetrieveAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI, UpdateAPI) @@ -717,16 +717,16 @@ class PartSerialNumberDetail(RetrieveAPI): part = self.get_object() # Calculate the "latest" serial number - latest = part.getLatestSerialNumber() + latest = part.get_latest_serial_number() data = { 'latest': latest, } if latest is not None: - next_serial = increment(latest) + next_serial = increment_serial_number(latest) - if next_serial != increment: + if next_serial != latest: data['next'] = next_serial return Response(data) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 5ab60edbd5..0c1177ea6a 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -529,112 +529,126 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): return result - def checkIfSerialNumberExists(self, sn, exclude_self=False): - """Check if a serial number exists for this Part. + def validate_serial_number(self, serial: str, stock_item=None, check_duplicates=True, raise_error=False): + """Validate a serial number against this Part instance. - Note: Serial numbers must be unique across an entire Part "tree", so here we filter by the entire tree. + Note: This function is exposed to any Validation plugins, and thus can be customized. + + Any plugins which implement the 'validate_serial_number' method have three possible outcomes: + + - Decide the serial is objectionable and raise a django.core.exceptions.ValidationError + - Decide the serial is acceptable, and return None to proceed to other tests + - Decide the serial is acceptable, and return True to skip any further tests + + Arguments: + serial: The proposed serial number + stock_item: (optional) A StockItem instance which has this serial number assigned (e.g. testing for duplicates) + raise_error: If False, and ValidationError(s) will be handled + + Returns: + True if serial number is 'valid' else False + + Raises: + ValidationError if serial number is invalid and raise_error = True """ - parts = Part.objects.filter(tree_id=self.tree_id) - stock = StockModels.StockItem.objects.filter(part__in=parts, serial=sn) + serial = str(serial).strip() - if exclude_self: - stock = stock.exclude(pk=self.pk) + # First, throw the serial number against each of the loaded validation plugins + from plugin.registry import registry - return stock.exists() + try: + for plugin in registry.with_mixin('validation'): + # Run the serial number through each custom validator + # If the plugin returns 'True' we will skip any subsequent validation + if plugin.validate_serial_number(serial): + return True + except ValidationError as exc: + if raise_error: + # Re-throw the error + raise exc + else: + return False - def find_conflicting_serial_numbers(self, serials): + """ + If we are here, none of the loaded plugins (if any) threw an error or exited early + + Now, we run the "default" serial number validation routine, + which checks that the serial number is not duplicated + """ + + if not check_duplicates: + return + + from part.models import Part + from stock.models import StockItem + + if common.models.InvenTreeSetting.get_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False): + # Serial number must be unique across *all* parts + parts = Part.objects.all() + else: + # Serial number must only be unique across this part "tree" + parts = Part.objects.filter(tree_id=self.tree_id) + + stock = StockItem.objects.filter(part__in=parts, serial=serial) + + if stock_item: + # Exclude existing StockItem from query + stock = stock.exclude(pk=stock_item.pk) + + if stock.exists(): + if raise_error: + raise ValidationError(_("Stock item with this serial number already exists") + ": " + serial) + else: + return False + else: + # This serial number is perfectly valid + return True + + def find_conflicting_serial_numbers(self, serials: list): """For a provided list of serials, return a list of those which are conflicting.""" + conflicts = [] for serial in serials: - if self.checkIfSerialNumberExists(serial, exclude_self=True): + if not self.validate_serial_number(serial): conflicts.append(serial) return conflicts - def getLatestSerialNumber(self): - """Return the "latest" serial number for this Part. + def get_latest_serial_number(self): + """Find the 'latest' serial number for this Part. - If *all* the serial numbers are integers, then this will return the highest one. - Otherwise, it will simply return the serial number most recently added. + Here we attempt to find the "highest" serial number which exists for this Part. + There are a number of edge cases where this method can fail, + but this is accepted to keep database performance at a reasonable level. Note: Serial numbers must be unique across an entire Part "tree", so we filter by the entire tree. - """ - parts = Part.objects.filter(tree_id=self.tree_id) - stock = StockModels.StockItem.objects.filter(part__in=parts).exclude(serial=None) - # There are no matchin StockItem objects (skip further tests) + Returns: + The latest serial number specified for this part, or None + """ + + stock = StockModels.StockItem.objects.all().exclude(serial=None).exclude(serial='') + + # Generate a query for any stock items for this part variant tree with non-empty serial numbers + if common.models.InvenTreeSetting.get_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False): + # Serial numbers are unique across all parts + pass + else: + # Serial numbers are unique acros part trees + stock = stock.filter(part__tree_id=self.tree_id) + + # There are no matching StockItem objects (skip further tests) if not stock.exists(): return None - # Attempt to coerce the returned serial numbers to integers - # If *any* are not integers, fail! - try: - ordered = sorted(stock.all(), reverse=True, key=lambda n: int(n.serial)) + # Sort in descending order + stock = stock.order_by('-serial_int', '-serial', '-pk') - if len(ordered) > 0: - return ordered[0].serial - - # One or more of the serial numbers was non-numeric - # In this case, the "best" we can do is return the most recent - except ValueError: - return stock.last().serial - - # No serial numbers found - return None - - def getLatestSerialNumberInt(self): - """Return the "latest" serial number for this Part as a integer. - - If it is not an integer the result is 0 - """ - latest = self.getLatestSerialNumber() - - # No serial number = > 0 - if latest is None: - latest = 0 - - # Attempt to turn into an integer and return - try: - latest = int(latest) - return latest - except Exception: - # not an integer so 0 - return 0 - - def getSerialNumberString(self, quantity=1): - """Return a formatted string representing the next available serial numbers, given a certain quantity of items.""" - latest = self.getLatestSerialNumber() - - quantity = int(quantity) - - # No serial numbers can be found, assume 1 as the first serial - if latest is None: - latest = 0 - - # Attempt to turn into an integer - try: - latest = int(latest) - except Exception: - pass - - if type(latest) is int: - - if quantity >= 2: - text = '{n} - {m}'.format(n=latest + 1, m=latest + 1 + quantity) - - return _('Next available serial numbers are') + ' ' + text - else: - text = str(latest + 1) - - return _('Next available serial number is') + ' ' + text - - else: - # Non-integer values, no option but to return latest - - return _('Most recent serial number is') + ' ' + str(latest) + # Return the first serial value + return stock[0].serial @property def full_name(self): diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index aef2183740..8749292c7e 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -323,12 +323,13 @@ {% endif %} - {% if part.trackable and part.getLatestSerialNumber %} + {% with part.get_latest_serial_number as sn %} + {% if part.trackable and sn %} {% trans "Latest Serial Number" %} - {{ part.getLatestSerialNumber }} + {{ sn }}
@@ -337,6 +338,7 @@ {% endif %} + {% endwith %} {% if part.default_location %} diff --git a/InvenTree/plugin/base/integration/mixins.py b/InvenTree/plugin/base/integration/mixins.py index 6a18216209..c5f6b68933 100644 --- a/InvenTree/plugin/base/integration/mixins.py +++ b/InvenTree/plugin/base/integration/mixins.py @@ -214,6 +214,139 @@ class ScheduleMixin: logger.warning("unregister_tasks failed, database not ready") +class ValidationMixin: + """Mixin class that allows custom validation for various parts of InvenTree + + Custom generation and validation functionality can be provided for: + + - Part names + - Part IPN (internal part number) values + - Serial numbers + - Batch codes + + Notes: + - Multiple ValidationMixin plugins can be used simultaneously + - The stub methods provided here generally return None (null value). + - The "first" plugin to return a non-null value for a particular method "wins" + - In the case of "validation" functions, all loaded plugins are checked until an exception is thrown + + Implementing plugins may override any of the following methods which are of interest. + + For 'validation' methods, there are three 'acceptable' outcomes: + - The method determines that the value is 'invalid' and raises a django.core.exceptions.ValidationError + - The method passes and returns None (the code then moves on to the next plugin) + - The method passes and returns True (and no subsequent plugins are checked) + + """ + + class MixinMeta: + """Metaclass for this mixin""" + MIXIN_NAME = "Validation" + + def __init__(self): + """Register the mixin""" + super().__init__() + self.add_mixin('validation', True, __class__) + + def validate_part_name(self, name: str): + """Perform validation on a proposed Part name + + Arguments: + name: The proposed part name + + Returns: + None or True + + Raises: + ValidationError if the proposed name is objectionable + """ + return None + + def validate_part_ipn(self, ipn: str): + """Perform validation on a proposed Part IPN (internal part number) + + Arguments: + ipn: The proposed part IPN + + Returns: + None or True + + Raises: + ValidationError if the proposed IPN is objectionable + """ + return None + + def validate_batch_code(self, batch_code: str): + """Validate the supplied batch code + + Arguments: + batch_code: The proposed batch code (string) + + Returns: + None or True + + Raises: + ValidationError if the proposed batch code is objectionable + """ + return None + + def generate_batch_code(self): + """Generate a new batch code + + Returns: + A new batch code (string) or None + """ + return None + + def validate_serial_number(self, serial: str): + """Validate the supplied serial number + + Arguments: + serial: The proposed serial number (string) + + Returns: + None or True + + Raises: + ValidationError if the proposed serial is objectionable + """ + return None + + def convert_serial_to_int(self, serial: str): + """Convert a serial number (string) into an integer representation. + + This integer value is used for efficient sorting based on serial numbers. + + A plugin which implements this method can either return: + + - An integer based on the serial string, according to some algorithm + - A fixed value, such that serial number sorting reverts to the string representation + - None (null value) to let any other plugins perform the converrsion + + Note that there is no requirement for the returned integer value to be unique. + + Arguments: + serial: Serial value (string) + + Returns: + integer representation of the serial number, or None + """ + return None + + def increment_serial_number(self, serial: str): + """Return the next sequential serial based on the provided value. + + A plugin which implements this method can either return: + + - A string which represents the "next" serial number in the sequence + - None (null value) if the next value could not be determined + + Arguments: + serial: Current serial value (string) + """ + return None + + class UrlsMixin: """Mixin that enables custom URLs for the plugin.""" diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index 207a65ab42..df4b9dcb0f 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -8,7 +8,8 @@ from ..base.barcodes.mixins import BarcodeMixin from ..base.event.mixins import EventMixin from ..base.integration.mixins import (APICallMixin, AppMixin, NavigationMixin, PanelMixin, ScheduleMixin, - SettingsMixin, UrlsMixin) + SettingsMixin, UrlsMixin, + ValidationMixin) from ..base.label.mixins import LabelPrintingMixin from ..base.locate.mixins import LocateMixin @@ -25,6 +26,7 @@ __all__ = [ 'ActionMixin', 'BarcodeMixin', 'LocateMixin', + 'ValidationMixin', 'SingleNotificationMethod', 'BulkNotificationMethod', ] diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 95037ebec0..bc832fa128 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -398,6 +398,7 @@ class PluginsRegistry: plg_db = None except (IntegrityError) as error: # pragma: no cover logger.error(f"Error initializing plugin `{plg_name}`: {error}") + handle_error(error, log_name='init') # Append reference to plugin plg.db = plg_db diff --git a/InvenTree/plugin/samples/integration/validation_sample.py b/InvenTree/plugin/samples/integration/validation_sample.py new file mode 100644 index 0000000000..aac6b0cd8e --- /dev/null +++ b/InvenTree/plugin/samples/integration/validation_sample.py @@ -0,0 +1,79 @@ +"""Sample plugin which demonstrates custom validation functionality""" + +from datetime import datetime + +from django.core.exceptions import ValidationError + +from plugin import InvenTreePlugin +from plugin.mixins import SettingsMixin, ValidationMixin + + +class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin): + """A sample plugin class for demonstrating custom validation functions""" + + NAME = "CustomValidator" + SLUG = "validator" + TITLE = "Custom Validator Plugin" + DESCRIPTION = "A sample plugin for demonstrating custom validation functionality" + VERSION = "0.1" + + SETTINGS = { + 'ILLEGAL_PART_CHARS': { + 'name': 'Illegal Part Characters', + 'description': 'Characters which are not allowed to appear in Part names', + 'default': '!@#$%^&*()~`' + }, + 'IPN_MUST_CONTAIN_Q': { + 'name': 'IPN Q Requirement', + 'description': 'Part IPN field must contain the character Q', + 'default': False, + 'validator': bool, + }, + 'SERIAL_MUST_BE_PALINDROME': { + 'name': 'Palindromic Serials', + 'description': 'Serial numbers must be palindromic', + 'default': False, + 'validator': bool, + }, + 'BATCH_CODE_PREFIX': { + 'name': 'Batch prefix', + 'description': 'Required prefix for batch code', + 'default': '', + } + } + + def validate_part_name(self, name: str): + """Validate part name""" + + illegal_chars = self.get_setting('ILLEGAL_PART_CHARS') + + for c in illegal_chars: + if c in name: + raise ValidationError(f"Illegal character in part name: '{c}'") + + def validate_part_ipn(self, ipn: str): + """Validate part IPN""" + + if self.get_setting('IPN_MUST_CONTAIN_Q') and 'Q' not in ipn: + raise ValidationError("IPN must contain 'Q'") + + def validate_serial_number(self, serial: str): + """Validate serial number for a given StockItem""" + + if self.get_setting('SERIAL_MUST_BE_PALINDROME'): + if serial != serial[::-1]: + raise ValidationError("Serial must be a palindrome") + + def validate_batch_code(self, batch_code: str): + """Ensure that a particular batch code meets specification""" + + prefix = self.get_setting('BATCH_CODE_PREFIX') + + if not batch_code.startswith(prefix): + raise ValidationError(f"Batch code must start with '{prefix}'") + + def generate_batch_code(self): + """Generate a new batch code.""" + + now = datetime.now() + return f"BATCH-{now.year}:{now.month}:{now.day}" diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 7ecf3ee480..287b76ce8d 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -563,23 +563,35 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): # If serial numbers are specified, check that they match! try: - serials = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt()) + serials = extract_serial_numbers( + serial_numbers, + quantity, + part.get_latest_serial_number() + ) - # Determine if any of the specified serial numbers already exist! - existing = [] + # Determine if any of the specified serial numbers are invalid + # Note "invalid" means either they already exist, or do not pass custom rules + invalid = [] + errors = [] for serial in serials: - if part.checkIfSerialNumberExists(serial): - existing.append(serial) + try: + part.validate_serial_number(serial, raise_error=True) + except DjangoValidationError as exc: + # Catch raised error to extract specific error information + invalid.append(serial) - if len(existing) > 0: + if exc.message not in errors: + errors.append(exc.message) - msg = _("The following serial numbers already exist") + if len(errors) > 0: + + msg = _("The following serial numbers already exist or are invalid") msg += " : " - msg += ",".join([str(e) for e in existing]) + msg += ",".join([str(e) for e in invalid]) raise ValidationError({ - 'serial_numbers': [msg], + 'serial_numbers': errors + [msg] }) except DjangoValidationError as e: diff --git a/InvenTree/stock/fixtures/stock.yaml b/InvenTree/stock/fixtures/stock.yaml index 103d224f8d..00d1b1ef4f 100644 --- a/InvenTree/stock/fixtures/stock.yaml +++ b/InvenTree/stock/fixtures/stock.yaml @@ -96,6 +96,7 @@ location: 7 quantity: 1 serial: 1000 + serial_int: 1000 level: 0 tree_id: 0 lft: 0 @@ -121,6 +122,7 @@ location: 7 quantity: 1 serial: 1 + serial_int: 1 level: 0 tree_id: 0 lft: 0 @@ -133,6 +135,7 @@ location: 7 quantity: 1 serial: 2 + serial_int: 2 level: 0 tree_id: 0 lft: 0 @@ -145,6 +148,7 @@ location: 7 quantity: 1 serial: 3 + serial_int: 3 level: 0 tree_id: 0 lft: 0 @@ -157,6 +161,7 @@ location: 7 quantity: 1 serial: 4 + serial_int: 4 level: 0 tree_id: 0 lft: 0 @@ -169,6 +174,7 @@ location: 7 quantity: 1 serial: 5 + serial_int: 5 level: 0 tree_id: 0 lft: 0 @@ -181,6 +187,7 @@ location: 7 quantity: 1 serial: 10 + serial_int: 10 level: 0 tree_id: 0 lft: 0 @@ -193,6 +200,7 @@ location: 7 quantity: 1 serial: 11 + serial_int: 11 level: 0 tree_id: 0 lft: 0 @@ -205,6 +213,7 @@ location: 7 quantity: 1 serial: 12 + serial_int: 12 level: 0 tree_id: 0 lft: 0 @@ -217,6 +226,7 @@ location: 7 quantity: 1 serial: 20 + serial_int: 20 level: 0 tree_id: 0 lft: 0 @@ -231,6 +241,7 @@ location: 7 quantity: 1 serial: 21 + serial_int: 21 level: 0 tree_id: 0 lft: 0 @@ -245,6 +256,7 @@ location: 7 quantity: 1 serial: 22 + serial_int: 22 level: 0 tree_id: 0 lft: 0 diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index dc26556f0f..cb8c80995a 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -180,9 +180,24 @@ class StockItemManager(TreeManager): def generate_batch_code(): """Generate a default 'batch code' for a new StockItem. - This uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured), + By default, this uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured), which can be passed through a simple template. + + Also, this function is exposed to the ValidationMixin plugin class, + allowing custom plugins to be used to generate new batch code values """ + + # First, check if any plugins can generate batch codes + from plugin.registry import registry + + for plugin in registry.with_mixin('validation'): + batch = plugin.generate_batch_code() + + if batch is not None: + # Return the first non-null value generated by a plugin + return batch + + # If we get to this point, no plugin was able to generate a new batch code batch_template = common.models.InvenTreeSetting.get_setting('STOCK_BATCH_CODE_TEMPLATE', '') now = datetime.now() @@ -260,15 +275,38 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): This is used for efficient numerical sorting """ - serial = getattr(self, 'serial', '') + + serial = str(getattr(self, 'serial', '')).strip() + + from plugin.registry import registry + + # First, let any plugins convert this serial number to an integer value + # If a non-null value is returned (by any plugin) we will use that + + serial_int = None + + for plugin in registry.with_mixin('validation'): + serial_int = plugin.convert_serial_to_int(serial) + + if serial_int is not None: + # Save the first returned result + # Ensure that it is clipped within a range allowed in the database schema + clip = 0x7fffffff + + serial_int = abs(serial_int) + + if serial_int > clip: + serial_int = clip + + self.serial_int = serial_int + return + + # If we get to this point, none of the available plugins provided an integer value # Default value if we cannot convert to an integer serial_int = 0 - if serial is not None: - - serial = str(serial).strip() - + if serial not in [None, '']: serial_int = extract_int(serial) self.serial_int = serial_int @@ -408,16 +446,32 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): # If the serial number is set, make sure it is not a duplicate if self.serial: - # Query to look for duplicate serial numbers - parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id) - stock = StockItem.objects.filter(part__in=parts, serial=self.serial) - # Exclude myself from the search - if self.pk is not None: - stock = stock.exclude(pk=self.pk) + self.serial = str(self.serial).strip() - if stock.exists(): - raise ValidationError({"serial": _("StockItem with this serial number already exists")}) + try: + self.part.validate_serial_number(self.serial, self, raise_error=True) + except ValidationError as exc: + raise ValidationError({ + 'serial': exc.message, + }) + + def validate_batch_code(self): + """Ensure that the batch code is valid for this StockItem. + + - Validation is performed by custom plugins. + - By default, no validation checks are performed + """ + + from plugin.registry import registry + + for plugin in registry.with_mixin('validation'): + try: + plugin.validate_batch_code(self.batch) + except ValidationError as exc: + raise ValidationError({ + 'batch': exc.message + }) def clean(self): """Validate the StockItem object (separate to field validation). @@ -438,6 +492,8 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): if type(self.batch) is str: self.batch = self.batch.strip() + self.validate_batch_code() + try: # Trackable parts must have integer values for quantity field! if self.part.trackable: diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 2ab079a5ac..7a0697aa7a 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -342,7 +342,11 @@ class SerializeStockItemSerializer(serializers.Serializer): serial_numbers = data['serial_numbers'] try: - serials = InvenTree.helpers.extract_serial_numbers(serial_numbers, quantity, item.part.getLatestSerialNumberInt()) + serials = InvenTree.helpers.extract_serial_numbers( + serial_numbers, + quantity, + item.part.get_latest_serial_number() + ) except DjangoValidationError as e: raise ValidationError({ 'serial_numbers': e.messages, @@ -371,7 +375,7 @@ class SerializeStockItemSerializer(serializers.Serializer): serials = InvenTree.helpers.extract_serial_numbers( data['serial_numbers'], data['quantity'], - item.part.getLatestSerialNumberInt() + item.part.get_latest_serial_number() ) item.serializeStock( diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index b3e94e3b92..17e96ca7c6 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -495,7 +495,7 @@ class StockItemTest(StockAPITestCase): # Check that each serial number was created for i in range(1, 11): - self.assertTrue(i in sn) + self.assertTrue(str(i) in sn) # Check the unique stock item has been created diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index eb9111fae0..ddfc625ca9 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -7,6 +7,7 @@ from django.db.models import Sum from django.test import override_settings from build.models import Build +from common.models import InvenTreeSetting from InvenTree.helpers import InvenTreeTestCase from InvenTree.status_codes import StockHistoryCode from part.models import Part @@ -172,6 +173,39 @@ class StockTest(StockTestBase): item.save() item.full_clean() + def test_serial_numbers(self): + """Test serial number uniqueness""" + + # Ensure that 'global uniqueness' setting is enabled + InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', True, self.user) + + part_a = Part.objects.create(name='A', description='A', trackable=True) + part_b = Part.objects.create(name='B', description='B', trackable=True) + + # Create a StockItem for part_a + StockItem.objects.create( + part=part_a, + quantity=1, + serial='ABCDE', + ) + + # Create a StockItem for part_a (but, will error due to identical serial) + with self.assertRaises(ValidationError): + StockItem.objects.create( + part=part_b, + quantity=1, + serial='ABCDE', + ) + + # Now, allow serial numbers to be duplicated between different parts + InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False, self.user) + + StockItem.objects.create( + part=part_b, + quantity=1, + serial='ABCDE', + ) + def test_expiry(self): """Test expiry date functionality for StockItem model.""" today = datetime.datetime.now().date() @@ -857,22 +891,21 @@ class VariantTest(StockTestBase): def test_serial_numbers(self): """Test serial number functionality for variant / template parts.""" + + InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False, self.user) + chair = Part.objects.get(pk=10000) # Operations on the top-level object - self.assertTrue(chair.checkIfSerialNumberExists(1)) - self.assertTrue(chair.checkIfSerialNumberExists(2)) - self.assertTrue(chair.checkIfSerialNumberExists(3)) - self.assertTrue(chair.checkIfSerialNumberExists(4)) - self.assertTrue(chair.checkIfSerialNumberExists(5)) + [self.assertFalse(chair.validate_serial_number(i)) for i in [1, 2, 3, 4, 5, 20, 21, 22]] - self.assertTrue(chair.checkIfSerialNumberExists(20)) - self.assertTrue(chair.checkIfSerialNumberExists(21)) - self.assertTrue(chair.checkIfSerialNumberExists(22)) + self.assertFalse(chair.validate_serial_number(20)) + self.assertFalse(chair.validate_serial_number(21)) + self.assertFalse(chair.validate_serial_number(22)) - self.assertFalse(chair.checkIfSerialNumberExists(30)) + self.assertTrue(chair.validate_serial_number(30)) - self.assertEqual(chair.getLatestSerialNumber(), '22') + self.assertEqual(chair.get_latest_serial_number(), '22') # Check for conflicting serial numbers to_check = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] @@ -883,10 +916,10 @@ class VariantTest(StockTestBase): # Same operations on a sub-item variant = Part.objects.get(pk=10003) - self.assertEqual(variant.getLatestSerialNumber(), '22') + self.assertEqual(variant.get_latest_serial_number(), '22') # Create a new serial number - n = variant.getLatestSerialNumber() + n = variant.get_latest_serial_number() item = StockItem( part=variant, @@ -898,12 +931,6 @@ class VariantTest(StockTestBase): with self.assertRaises(ValidationError): item.save() - # Verify items with a non-numeric serial don't offer a next serial. - item.serial = "string" - item.save() - - self.assertEqual(variant.getLatestSerialNumber(), "string") - # This should pass, although not strictly an int field now. item.serial = int(n) + 1 item.save() @@ -915,7 +942,7 @@ class VariantTest(StockTestBase): with self.assertRaises(ValidationError): item.save() - item.serial += 1 + item.serial = int(n) + 2 item.save() diff --git a/InvenTree/templates/InvenTree/settings/stock.html b/InvenTree/templates/InvenTree/settings/stock.html index e897b13ea7..2f6c92c7d4 100644 --- a/InvenTree/templates/InvenTree/settings/stock.html +++ b/InvenTree/templates/InvenTree/settings/stock.html @@ -11,6 +11,7 @@ + {% include "InvenTree/settings/setting.html" with key="SERIAL_NUMBER_GLOBALLY_UNIQUE" icon="fa-hashtag" %} {% include "InvenTree/settings/setting.html" with key="STOCK_BATCH_CODE_TEMPLATE" icon="fa-layer-group" %} {% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %} {% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %} diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index da9e2b0686..2fb97be235 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1230,12 +1230,7 @@ function handleNestedErrors(errors, field_name, options={}) { // Find the target (nested) field var target = `${field_name}_${sub_field_name}_${nest_id}`; - for (var ii = errors.length-1; ii >= 0; ii--) { - - var error_text = errors[ii]; - - addFieldErrorMessage(target, error_text, ii, options); - } + addFieldErrorMessage(target, errors, options); } } } @@ -1312,13 +1307,7 @@ function handleFormErrors(errors, fields={}, options={}) { first_error_field = field_name; } - // Add an entry for each returned error message - for (var ii = field_errors.length-1; ii >= 0; ii--) { - - var error_text = field_errors[ii]; - - addFieldErrorMessage(field_name, error_text, ii, options); - } + addFieldErrorMessage(field_name, field_errors, options); } } @@ -1341,6 +1330,16 @@ function handleFormErrors(errors, fields={}, options={}) { */ function addFieldErrorMessage(name, error_text, error_idx=0, options={}) { + // Handle a 'list' of error message recursively + if (typeof(error_text) == 'object') { + // Iterate backwards through the list + for (var ii = error_text.length - 1; ii >= 0; ii--) { + addFieldErrorMessage(name, error_text[ii], ii, options); + } + + return; + } + field_name = getFieldName(name, options); var field_dom = null;