From 648faf4ed2a8b05e034abefffd3f1c8f805b5b4e Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 11 Jul 2022 00:01:46 +1000 Subject: [PATCH] Reference fields (#3267) * Adds a configurable 'reference pattern' to the IndexingReferenceMixin class * Expand tests for reference_pattern validator: - Prevent inclusion of illegal characters - Prevent multiple groups of hash (#) characters - Add unit tests * Validator now checks for valid strftime formatter * Adds build order reference pattern * Adds function for creating a valid regex from the supplied pattern - More unit tests - Use it to validate BuildOrder reference field * Refactoring the whole thing again - try using python string.format * remove datetime-matcher from requirements.txt * Add some more formatting helper functions - Construct a regular expression from a format string - Extract named values from a string, based on a format string * Fix validator for build order reference field * Adding unit tests for the new format string functionality * Adds validation for reference fields * Require the 'ref' format key as part of a valid reference pattern * Extend format extraction to allow specification of integer groups * Remove unused import * Fix requirements * Add method for generating the 'next' reference field for a model * Fix function for generating next BuildOrder reference value - A function is required as class methods cannot be used - Simply wraps the existing class method * Remove BUILDORDER_REFERENCE_REGEX setting * Add unit test for build order reference field validation * Adds unit testing for extracting integer values from a reference field * Fix bugs from previous commit * Add unit test for generation of default build order reference * Add data migration for BuildOrder model - Update reference field with old prefix - Construct new pattern based on old prefix * Adds unit test for data migration - Check that the BuildOrder reference field is updated as expected * Remove 'BUILDORDER_REFERENCE_PREFIX' setting * Adds new setting for SalesOrder reference pattern * Update method by which next reference value is generated * Improved error handling in api_tester code * Improve automated generation of order reference fields - Handle potential errors - Return previous reference if something goes wrong * SalesOrder reference has now been updated also - New reference pattern setting - Updated default and validator for reference field - Updated serializer and API - Added unit tests * Migrate the "PurchaseOrder" reference field to the new system * Data migration for SalesOrder and PurchaseOrder reference fields * Remove PURCHASEORDER_REFERENCE_PREFIX * Remove references to SALESORDER_REFERENCE_PREFIX * Re-add maximum value validation * Bug fixes * Improve algorithm for generating new reference - Handle case where most recent reference does not conform to the reference pattern * Fixes for 'order' unit tests * Unit test fixes for order app * More unit test fixes * More unit test fixing * Revert behaviour for "extract_int" clipping function * Unit test value fix * Prevent build order notification if we are importing records --- InvenTree/InvenTree/api_tester.py | 8 +- InvenTree/InvenTree/format.py | 156 ++++++++++++ InvenTree/InvenTree/models.py | 223 +++++++++++++++++- InvenTree/InvenTree/serializers.py | 13 - InvenTree/InvenTree/tests.py | 132 +++++++++++ InvenTree/InvenTree/validators.py | 11 - InvenTree/build/fixtures/build.yaml | 10 +- .../migrations/0019_auto_20201019_1302.py | 4 +- .../migrations/0030_alter_build_reference.py | 4 +- .../migrations/0036_auto_20220707_1101.py | 69 ++++++ InvenTree/build/models.py | 53 ++--- InvenTree/build/serializers.py | 14 +- InvenTree/build/test_api.py | 12 +- InvenTree/build/test_build.py | 95 +++++++- InvenTree/build/test_migrations.py | 54 +++++ InvenTree/build/tests.py | 7 +- InvenTree/build/validators.py | 25 ++ InvenTree/common/models.py | 34 +-- InvenTree/order/fixtures/order.yaml | 14 +- .../migrations/0048_auto_20210702_2321.py | 5 +- .../0072_alter_salesorder_reference.py | 19 ++ .../0073_alter_purchaseorder_reference.py | 19 ++ .../migrations/0074_auto_20220709_0108.py | 107 +++++++++ InvenTree/order/models.py | 99 +++----- InvenTree/order/serializers.py | 23 +- .../order/templates/order/order_base.html | 4 +- .../templates/order/sales_order_base.html | 4 +- InvenTree/order/test_api.py | 63 +++-- InvenTree/order/test_sales_order.py | 12 +- InvenTree/order/tests.py | 12 +- InvenTree/order/validators.py | 49 ++++ InvenTree/part/test_api.py | 1 + InvenTree/report/models.py | 2 - InvenTree/stock/models.py | 6 +- InvenTree/stock/serializers.py | 7 +- InvenTree/stock/tests.py | 7 +- .../templates/InvenTree/settings/build.html | 3 +- .../templates/InvenTree/settings/po.html | 2 +- .../templates/InvenTree/settings/so.html | 2 +- InvenTree/templates/js/translated/bom.js | 5 +- InvenTree/templates/js/translated/build.js | 15 +- .../js/translated/model_renderers.js | 10 +- InvenTree/templates/js/translated/order.js | 37 +-- InvenTree/templates/js/translated/part.js | 4 +- InvenTree/templates/js/translated/stock.js | 5 +- 45 files changed, 1166 insertions(+), 294 deletions(-) create mode 100644 InvenTree/InvenTree/format.py create mode 100644 InvenTree/build/migrations/0036_auto_20220707_1101.py create mode 100644 InvenTree/build/validators.py create mode 100644 InvenTree/order/migrations/0072_alter_salesorder_reference.py create mode 100644 InvenTree/order/migrations/0073_alter_purchaseorder_reference.py create mode 100644 InvenTree/order/migrations/0074_auto_20220709_0108.py create mode 100644 InvenTree/order/validators.py diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index a95a39d040..1b3cc9c5b4 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -133,8 +133,12 @@ class InvenTreeAPITestCase(UserMixin, APITestCase): if expected_code is not None: if response.status_code != expected_code: - print(f"Unexpected response at '{url}':") - print(response.data) + print(f"Unexpected response at '{url}': status code = {response.status_code}") + + if hasattr(response, 'data'): + print(response.data) + else: + print(f"(response object {type(response)} has no 'data' attribute") self.assertEqual(response.status_code, expected_code) diff --git a/InvenTree/InvenTree/format.py b/InvenTree/InvenTree/format.py new file mode 100644 index 0000000000..9df8211972 --- /dev/null +++ b/InvenTree/InvenTree/format.py @@ -0,0 +1,156 @@ +"""Custom string formatting functions and helpers""" + +import re +import string + +from django.utils.translation import gettext_lazy as _ + + +def parse_format_string(fmt_string: str) -> dict: + """Extract formatting information from the provided format string. + + Returns a dict object which contains structured information about the format groups + """ + + groups = string.Formatter().parse(fmt_string) + + info = {} + + for group in groups: + # Skip any group which does not have a named value + if not group[1]: + continue + + info[group[1]] = { + 'format': group[1], + 'prefix': group[0], + } + + return info + + +def construct_format_regex(fmt_string: str) -> str: + r"""Construct a regular expression based on a provided format string + + This function turns a python format string into a regular expression, + which can be used for two purposes: + + - Ensure that a particular string matches the specified format + - Extract named variables from a matching string + + This function also provides support for wildcard characters: + + - '?' provides single character matching; is converted to a '.' (period) for regex + - '#' provides single digit matching; is converted to '\d' + + Args: + fmt_string: A typical format string e.g. "PO-???-{ref:04d}" + + Returns: + str: A regular expression pattern e.g. ^PO\-...\-(?P.*)$ + + Raises: + ValueError: Format string is invalid + """ + + pattern = "^" + + for group in string.Formatter().parse(fmt_string): + prefix = group[0] # Prefix (literal text appearing before this group) + name = group[1] # Name of this format variable + format = group[2] # Format specifier e.g :04d + + rep = [ + '+', '-', '.', + '{', '}', '(', ')', + '^', '$', '~', '!', '@', ':', ';', '|', '\'', '"', + ] + + # Escape any special regex characters + for ch in rep: + prefix = prefix.replace(ch, '\\' + ch) + + # Replace ? with single-character match + prefix = prefix.replace('?', '.') + + # Replace # with single-digit match + prefix = prefix.replace('#', r'\d') + + pattern += prefix + + # Add a named capture group for the format entry + if name: + + # Check if integer values are requried + if format.endswith('d'): + chr = '\d' + else: + chr = '.' + + # Specify width + # TODO: Introspect required width + w = '+' + + pattern += f"(?P<{name}>{chr}{w})" + + pattern += "$" + + return pattern + + +def validate_string(value: str, fmt_string: str) -> str: + """Validate that the provided string matches the specified format. + + Args: + value: The string to be tested e.g. 'SO-1234-ABC', + fmt_string: The required format e.g. 'SO-{ref}-???', + + Returns: + bool: True if the value matches the required format, else False + + Raises: + ValueError: The provided format string is invalid + """ + + pattern = construct_format_regex(fmt_string) + + result = re.match(pattern, value) + + return result is not None + + +def extract_named_group(name: str, value: str, fmt_string: str) -> str: + """Extract a named value from the provided string, given the provided format string + + Args: + name: Name of group to extract e.g. 'ref' + value: Raw string e.g. 'PO-ABC-1234' + fmt_string: Format pattern e.g. 'PO-???-{ref} + + Returns: + str: String value of the named group + + Raises: + ValueError: format string is incorrectly specified, or provided value does not match format string + NameError: named value does not exist in the format string + IndexError: named value could not be found in the provided entry + """ + + info = parse_format_string(fmt_string) + + if name not in info.keys(): + raise NameError(_(f"Value '{name}' does not appear in pattern format")) + + # Construct a regular expression for matching against the provided format string + # Note: This will raise a ValueError if 'fmt_string' is incorrectly specified + pattern = construct_format_regex(fmt_string) + + # Run the regex matcher against the raw string + result = re.match(pattern, value) + + if not result: + raise ValueError(_("Provided value does not match required pattern: ") + fmt_string) + + # And return the value we are interested in + # Note: This will raise an IndexError if the named group was not matched + return result.group(name) diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index eaa8ef3b0d..740671f1dd 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -3,6 +3,7 @@ import logging import os import re +from datetime import datetime from django.conf import settings from django.contrib.auth import get_user_model @@ -19,7 +20,9 @@ from error_report.models import Error from mptt.exceptions import InvalidMove from mptt.models import MPTTModel, TreeForeignKey +import InvenTree.format import InvenTree.helpers +from common.models import InvenTreeSetting from InvenTree.fields import InvenTreeURLField from InvenTree.validators import validate_tree_name @@ -96,9 +99,6 @@ class DataImportMixin(object): class ReferenceIndexingMixin(models.Model): """A mixin for keeping track of numerical copies of the "reference" field. - !!DANGER!! always add `ReferenceIndexingSerializerMixin`to all your models serializers to - ensure the reference field is not too big - Here, we attempt to convert a "reference" field value (char) to an integer, for performing fast natural sorting. @@ -112,24 +112,216 @@ class ReferenceIndexingMixin(models.Model): - Otherwise, we store zero """ + # Name of the global setting which defines the required reference pattern for this model + REFERENCE_PATTERN_SETTING = None + + @classmethod + def get_reference_pattern(cls): + """Returns the reference pattern associated with this model. + + This is defined by a global setting object, specified by the REFERENCE_PATTERN_SETTING attribute + """ + + # By default, we return an empty string + if cls.REFERENCE_PATTERN_SETTING is None: + return '' + + return InvenTreeSetting.get_setting(cls.REFERENCE_PATTERN_SETTING, create=False).strip() + + @classmethod + def get_reference_context(cls): + """Generate context data for generating the 'reference' field for this class. + + - Returns a python dict object which contains the context data for formatting the reference string. + - The default implementation provides some default context information + """ + + return { + 'ref': cls.get_next_reference(), + 'date': datetime.now(), + } + + @classmethod + def get_most_recent_item(cls): + """Return the item which is 'most recent' + + In practice, this means the item with the highest reference value + """ + + query = cls.objects.all().order_by('-reference_int', '-pk') + + if query.exists(): + return query.first() + else: + return None + + @classmethod + def get_next_reference(cls): + """Return the next available reference value for this particular class.""" + + # Find the "most recent" item + latest = cls.get_most_recent_item() + + if not latest: + # No existing items + return 1 + + reference = latest.reference.strip + + try: + reference = InvenTree.format.extract_named_group('ref', reference, cls.get_reference_pattern()) + except Exception: + # If reference cannot be extracted using the pattern, try just the integer value + reference = str(latest.reference_int) + + # Attempt to perform 'intelligent' incrementing of the reference field + incremented = InvenTree.helpers.increment(reference) + + try: + incremented = int(incremented) + except ValueError: + pass + + return incremented + + @classmethod + def generate_reference(cls): + """Generate the next 'reference' field based on specified pattern""" + + fmt = cls.get_reference_pattern() + ctx = cls.get_reference_context() + + reference = None + + attempts = set() + + while reference is None: + try: + ref = fmt.format(**ctx) + + if ref in attempts: + # We are stuck in a loop! + reference = ref + break + else: + attempts.add(ref) + + if cls.objects.filter(reference=ref).exists(): + # Handle case where we have duplicated an existing reference + ctx['ref'] = InvenTree.helpers.increment(ctx['ref']) + else: + # We have found an 'unused' reference + reference = ref + break + + except Exception: + # If anything goes wrong, return the most recent reference + recent = cls.get_most_recent_item() + if recent: + reference = recent.reference + else: + reference = "" + + return reference + + @classmethod + def validate_reference_pattern(cls, pattern): + """Ensure that the provided pattern is valid""" + + ctx = cls.get_reference_context() + + try: + info = InvenTree.format.parse_format_string(pattern) + except Exception: + raise ValidationError({ + "value": _("Improperly formatted pattern"), + }) + + # Check that only 'allowed' keys are provided + for key in info.keys(): + if key not in ctx.keys(): + raise ValidationError({ + "value": _("Unknown format key specified") + f": '{key}'" + }) + + # Check that the 'ref' variable is specified + if 'ref' not in info.keys(): + raise ValidationError({ + 'value': _("Missing required format key") + ": 'ref'" + }) + + @classmethod + def validate_reference_field(cls, value): + """Check that the provided 'reference' value matches the requisite pattern""" + + pattern = cls.get_reference_pattern() + + value = str(value).strip() + + if len(value) == 0: + raise ValidationError(_("Reference field cannot be empty")) + + # An 'empty' pattern means no further validation is required + if not pattern: + return + + if not InvenTree.format.validate_string(value, pattern): + raise ValidationError(_("Reference must match required pattern") + ": " + pattern) + + # Check that the reference field can be rebuild + cls.rebuild_reference_field(value, validate=True) + class Meta: """Metaclass options. Abstract ensures no database table is created.""" abstract = True - def rebuild_reference_field(self): - """Extract integer out of reference for sorting.""" - reference = getattr(self, 'reference', '') - self.reference_int = extract_int(reference) + @classmethod + def rebuild_reference_field(cls, reference, validate=False): + """Extract integer out of reference for sorting. + + If the 'integer' portion is buried somewhere 'within' the reference, + we can first try to extract it using the pattern. + + Example: + reference - BO-123-ABC + pattern - BO-{ref}-??? + extracted - 123 + + If we cannot extract using the pattern for some reason, fallback to the entire reference + """ + + try: + # Extract named group based on provided pattern + reference = InvenTree.format.extract_named_group('ref', reference, cls.get_reference_pattern()) + except Exception: + pass + + reference_int = extract_int(reference) + + if validate: + if reference_int > models.BigIntegerField.MAX_BIGINT: + raise ValidationError({ + "reference": _("Reference number is too large") + }) + + return reference_int reference_int = models.BigIntegerField(default=0) -def extract_int(reference, clip=0x7fffffff): - """Extract integer out of reference.""" +def extract_int(reference, clip=0x7fffffff, allow_negative=False): + """Extract an integer out of reference.""" + # Default value if we cannot convert to an integer ref_int = 0 + reference = str(reference).strip() + + # Ignore empty string + if len(reference) == 0: + return 0 + # Look at the start of the string - can it be "integerized"? result = re.match(r"^(\d+)", reference) @@ -139,6 +331,16 @@ def extract_int(reference, clip=0x7fffffff): ref_int = int(ref) except Exception: ref_int = 0 + else: + # Look at the "end" of the string + result = re.search(r'(\d+)$', reference) + + if result and len(result.groups()) == 1: + ref = result.groups()[0] + try: + ref_int = int(ref) + except Exception: + ref_int = 0 # Ensure that the returned values are within the range that can be stored in an IntegerField # Note: This will result in large values being "clipped" @@ -148,6 +350,9 @@ def extract_int(reference, clip=0x7fffffff): elif ref_int < -clip: ref_int = -clip + if not allow_negative and ref_int < 0: + ref_int = abs(ref_int) + return ref_int diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 5b72062c13..5c6414ea94 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -7,7 +7,6 @@ from decimal import Decimal from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ValidationError as DjangoValidationError -from django.db import models from django.utils.translation import gettext_lazy as _ import tablib @@ -20,8 +19,6 @@ from rest_framework.fields import empty from rest_framework.serializers import DecimalField from rest_framework.utils import model_meta -from .models import extract_int - class InvenTreeMoneySerializer(MoneyField): """Custom serializer for 'MoneyField', which ensures that passed values are numerically valid. @@ -211,16 +208,6 @@ class UserSerializer(InvenTreeModelSerializer): ] -class ReferenceIndexingSerializerMixin(): - """This serializer mixin ensures the the reference is not to big / small for the BigIntegerField.""" - - def validate_reference(self, value): - """Ensures the reference is not to big / small for the BigIntegerField.""" - if extract_int(value) > models.BigIntegerField.MAX_BIGINT: - raise serializers.ValidationError('reference is to to big') - return value - - class InvenTreeAttachmentSerializerField(serializers.FileField): """Override the DRF native FileField serializer, to remove the leading server path. diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 4347e5ebac..2a3377c408 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -17,6 +17,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.models import Rate, convert_money from djmoney.money import Money +import InvenTree.format import InvenTree.tasks from common.models import InvenTreeSetting from common.settings import currency_codes @@ -60,6 +61,137 @@ class ValidatorTest(TestCase): validate_overage("aaaa") +class FormatTest(TestCase): + """Unit tests for custom string formatting functionality""" + + def test_parse(self): + """Tests for the 'parse_format_string' function""" + + # Extract data from a valid format string + fmt = "PO-{abc:02f}-{ref:04d}-{date}-???" + + info = InvenTree.format.parse_format_string(fmt) + + self.assertIn('abc', info) + self.assertIn('ref', info) + self.assertIn('date', info) + + # Try with invalid strings + for fmt in [ + 'PO-{{xyz}', + 'PO-{xyz}}', + 'PO-{xyz}-{', + ]: + + with self.assertRaises(ValueError): + InvenTree.format.parse_format_string(fmt) + + def test_create_regex(self): + """Test function for creating a regex from a format string""" + + tests = { + "PO-123-{ref:04f}": r"^PO\-123\-(?P.+)$", + "{PO}-???-{ref}-{date}-22": r"^(?P.+)\-...\-(?P.+)\-(?P.+)\-22$", + "ABC-123-###-{ref}": r"^ABC\-123\-\d\d\d\-(?P.+)$", + "ABC-123": r"^ABC\-123$", + } + + for fmt, reg in tests.items(): + self.assertEqual(InvenTree.format.construct_format_regex(fmt), reg) + + def test_validate_format(self): + """Test that string validation works as expected""" + + # These tests should pass + for value, pattern in { + "ABC-hello-123": "???-{q}-###", + "BO-1234": "BO-{ref}", + "111.222.fred.china": "???.###.{name}.{place}", + "PO-1234": "PO-{ref:04d}" + }.items(): + self.assertTrue(InvenTree.format.validate_string(value, pattern)) + + # These tests should fail + for value, pattern in { + "ABC-hello-123": "###-{q}-???", + "BO-1234": "BO.{ref}", + "BO-####": "BO-{pattern}-{next}", + "BO-123d": "BO-{ref:04d}" + }.items(): + self.assertFalse(InvenTree.format.validate_string(value, pattern)) + + def test_extract_value(self): + """Test that we can extract named values based on a format string""" + + # Simple tests based on a straight-forward format string + fmt = "PO-###-{ref:04d}" + + tests = { + "123": "PO-123-123", + "456": "PO-123-456", + "789": "PO-123-789", + } + + for k, v in tests.items(): + self.assertEqual(InvenTree.format.extract_named_group('ref', v, fmt), k) + + # However these ones should fail + tests = { + 'abc': 'PO-123-abc', + 'xyz': 'PO-123-xyz', + } + + for v in tests.values(): + with self.assertRaises(ValueError): + InvenTree.format.extract_named_group('ref', v, fmt) + + # More complex tests + fmt = "PO-{date}-{test}-???-{ref}-###" + val = "PO-2022-02-01-hello-ABC-12345-222" + + data = { + 'date': '2022-02-01', + 'test': 'hello', + 'ref': '12345', + } + + for k, v in data.items(): + self.assertEqual(InvenTree.format.extract_named_group(k, val, fmt), v) + + # Test for error conditions + + # Raises a ValueError as the format string is bad + with self.assertRaises(ValueError): + InvenTree.format.extract_named_group( + "test", + "PO-1234-5", + "PO-{test}-{" + ) + + # Raises a NameError as the named group does not exist in the format string + with self.assertRaises(NameError): + InvenTree.format.extract_named_group( + "missing", + "PO-12345", + "PO-{test}", + ) + + # Raises a ValueError as the value does not match the format string + with self.assertRaises(ValueError): + InvenTree.format.extract_named_group( + "test", + "PO-1234", + "PO-{test}-1234", + ) + + with self.assertRaises(ValueError): + InvenTree.format.extract_named_group( + "test", + "PO-ABC-xyz", + "PO-###-{test}", + ) + + class TestHelpers(TestCase): """Tests for InvenTree helper functions.""" diff --git a/InvenTree/InvenTree/validators.py b/InvenTree/InvenTree/validators.py index c66cd0b0cc..46b02ca506 100644 --- a/InvenTree/InvenTree/validators.py +++ b/InvenTree/InvenTree/validators.py @@ -57,17 +57,6 @@ def validate_part_ipn(value): raise ValidationError(_('IPN must match regex pattern {pat}').format(pat=pattern)) -def validate_build_order_reference(value): - """Validate the 'reference' field of a BuildOrder.""" - pattern = common.models.InvenTreeSetting.get_setting('BUILDORDER_REFERENCE_REGEX') - - if pattern: - match = re.search(pattern, value) - - if match is None: - raise ValidationError(_('Reference must match pattern {pattern}').format(pattern=pattern)) - - def validate_purchase_order_reference(value): """Validate the 'reference' field of a PurchaseOrder.""" pattern = common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_REGEX') diff --git a/InvenTree/build/fixtures/build.yaml b/InvenTree/build/fixtures/build.yaml index 2f9c7b05bd..186faa9c5a 100644 --- a/InvenTree/build/fixtures/build.yaml +++ b/InvenTree/build/fixtures/build.yaml @@ -5,7 +5,7 @@ fields: part: 100 # Build against part 100 "Bob" batch: 'B1' - reference: "0001" + reference: "BO-0001" title: 'Building 7 parts' quantity: 7 notes: 'Some simple notes' @@ -21,7 +21,7 @@ pk: 2 fields: part: 50 - reference: "0002" + reference: "BO-0002" title: 'Making things' batch: 'B2' status: 40 # COMPLETE @@ -37,7 +37,7 @@ pk: 3 fields: part: 50 - reference: "0003" + reference: "BO-003" title: 'Making things' batch: 'B2' status: 40 # COMPLETE @@ -53,7 +53,7 @@ pk: 4 fields: part: 50 - reference: "0004" + reference: "BO-4" title: 'Making things' batch: 'B4' status: 40 # COMPLETE @@ -69,7 +69,7 @@ pk: 5 fields: part: 25 - reference: "0005" + reference: "BO-0005" title: "Building some Widgets" batch: "B10" status: 40 # Complete diff --git a/InvenTree/build/migrations/0019_auto_20201019_1302.py b/InvenTree/build/migrations/0019_auto_20201019_1302.py index c767d3a3d9..9b12c5d021 100644 --- a/InvenTree/build/migrations/0019_auto_20201019_1302.py +++ b/InvenTree/build/migrations/0019_auto_20201019_1302.py @@ -1,6 +1,6 @@ # Generated by Django 3.0.7 on 2020-10-19 13:02 -import InvenTree.validators +import build.validators from django.db import migrations, models @@ -18,6 +18,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='build', name='reference', - field=models.CharField(help_text='Build Order Reference', max_length=64, unique=True, validators=[InvenTree.validators.validate_build_order_reference], verbose_name='Reference'), + field=models.CharField(help_text='Build Order Reference', max_length=64, unique=True, validators=[build.validators.validate_build_order_reference], verbose_name='Reference'), ), ] diff --git a/InvenTree/build/migrations/0030_alter_build_reference.py b/InvenTree/build/migrations/0030_alter_build_reference.py index 75f43c77dc..9f940e7eae 100644 --- a/InvenTree/build/migrations/0030_alter_build_reference.py +++ b/InvenTree/build/migrations/0030_alter_build_reference.py @@ -1,6 +1,6 @@ # Generated by Django 3.2.4 on 2021-07-08 14:14 -import InvenTree.validators +import build.validators import build.models from django.db import migrations, models @@ -15,6 +15,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='build', name='reference', - field=models.CharField(default=build.models.get_next_build_number, help_text='Build Order Reference', max_length=64, unique=True, validators=[InvenTree.validators.validate_build_order_reference], verbose_name='Reference'), + field=models.CharField(default=build.validators.generate_next_build_reference, help_text='Build Order Reference', max_length=64, unique=True, validators=[build.validators.validate_build_order_reference], verbose_name='Reference'), ), ] diff --git a/InvenTree/build/migrations/0036_auto_20220707_1101.py b/InvenTree/build/migrations/0036_auto_20220707_1101.py new file mode 100644 index 0000000000..3fbe72cdaf --- /dev/null +++ b/InvenTree/build/migrations/0036_auto_20220707_1101.py @@ -0,0 +1,69 @@ +# Generated by Django 3.2.14 on 2022-07-07 11:01 + +from django.db import migrations + + +def update_build_reference(apps, schema_editor): + """Update the build order reference. + + Ref: https://github.com/inventree/InvenTree/pull/3267 + + Performs the following steps: + - Extract existing 'prefix' value + - Generate a build order pattern based on the prefix value + - Update any existing build order references with the specified prefix + """ + + InvenTreeSetting = apps.get_model('common', 'inventreesetting') + + try: + prefix = InvenTreeSetting.objects.get(key='BUILDORDER_REFERENCE_PREFIX').value + except Exception: + prefix = 'BO-' + + # Construct a reference pattern + pattern = prefix + '{ref:04d}' + + # Create or update the BuildOrder.reference pattern + try: + setting = InvenTreeSetting.objects.get(key='BUILDORDER_REFERENCE_PATTERN') + setting.value = pattern + setting.save() + except InvenTreeSetting.DoesNotExist: + setting = InvenTreeSetting.objects.create( + key='BUILDORDER_REFERENCE_PATTERN', + value=pattern, + ) + + # Update any existing build order references with the prefix + Build = apps.get_model('build', 'build') + + n = 0 + + for build in Build.objects.all(): + if not build.reference.startswith(prefix): + build.reference = prefix + build.reference + build.save() + n += 1 + + if n > 0: + print(f"Updated reference field for {n} BuildOrder objects") + + +def nupdate_build_reference(apps, schema_editor): + """Reverse migration code. Does nothing.""" + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0035_alter_build_notes'), + ] + + operations = [ + migrations.RunPython( + update_build_reference, + reverse_code=nupdate_build_reference, + ) + ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index c15f28ba1d..620b4f343b 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -22,12 +22,14 @@ from mptt.exceptions import InvalidMove from rest_framework import serializers from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode -from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode, notify_responsible +from InvenTree.helpers import increment, normalize, MakeBarcode, notify_responsible from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin -from InvenTree.validators import validate_build_order_reference + +from build.validators import generate_next_build_reference, validate_build_order_reference import InvenTree.fields import InvenTree.helpers +import InvenTree.ready import InvenTree.tasks from plugin.events import trigger_event @@ -38,32 +40,6 @@ from stock import models as StockModels from users import models as UserModels -def get_next_build_number(): - """Returns the next available BuildOrder reference number.""" - if Build.objects.count() == 0: - return '0001' - - build = Build.objects.exclude(reference=None).last() - - attempts = {build.reference} - - reference = build.reference - - while 1: - reference = increment(reference) - - if reference in attempts: - # Escape infinite recursion - return reference - - if Build.objects.filter(reference=reference).exists(): - attempts.add(reference) - else: - break - - return reference - - class Build(MPTTModel, ReferenceIndexingMixin): """A Build object organises the creation of new StockItem objects from other existing StockItem objects. @@ -89,6 +65,9 @@ class Build(MPTTModel, ReferenceIndexingMixin): OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date()) + # Global setting for specifying reference pattern + REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN' + @staticmethod def get_api_url(): """Return the API URL associated with the BuildOrder model""" @@ -106,7 +85,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): def api_defaults(cls, request): """Return default values for this model when issuing an API OPTIONS request.""" defaults = { - 'reference': get_next_build_number(), + 'reference': generate_next_build_reference(), } if request and request.user: @@ -116,7 +95,8 @@ class Build(MPTTModel, ReferenceIndexingMixin): def save(self, *args, **kwargs): """Custom save method for the BuildOrder model""" - self.rebuild_reference_field() + self.validate_reference_field(self.reference) + self.reference_int = self.rebuild_reference_field(self.reference) try: super().save(*args, **kwargs) @@ -172,9 +152,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): def __str__(self): """String representation of a BuildOrder""" - prefix = getSetting("BUILDORDER_REFERENCE_PREFIX") - - return f"{prefix}{self.reference}" + return self.reference def get_absolute_url(self): """Return the web URL associated with this BuildOrder""" @@ -186,9 +164,9 @@ class Build(MPTTModel, ReferenceIndexingMixin): blank=False, help_text=_('Build Order Reference'), verbose_name=_('Reference'), - default=get_next_build_number, + default=generate_next_build_reference, validators=[ - validate_build_order_reference + validate_build_order_reference, ] ) @@ -199,7 +177,6 @@ class Build(MPTTModel, ReferenceIndexingMixin): help_text=_('Brief description of the build') ) - # TODO - Perhaps delete the build "tree" parent = TreeForeignKey( 'self', on_delete=models.SET_NULL, @@ -1092,6 +1069,10 @@ class Build(MPTTModel, ReferenceIndexingMixin): @receiver(post_save, sender=Build, dispatch_uid='build_post_save_log') def after_save_build(sender, instance: Build, created: bool, **kwargs): """Callback function to be executed after a Build instance is saved.""" + # Escape if we are importing data + if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True): + return + from . import tasks as build_tasks if created: diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 7278c3f3af..74fdf599ee 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -11,7 +11,7 @@ from rest_framework import serializers from rest_framework.serializers import ValidationError from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer -from InvenTree.serializers import ReferenceIndexingSerializerMixin, UserSerializer +from InvenTree.serializers import UserSerializer import InvenTree.helpers from InvenTree.helpers import extract_serial_numbers @@ -28,7 +28,7 @@ from users.serializers import OwnerSerializer from .models import Build, BuildItem, BuildOrderAttachment -class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer): +class BuildSerializer(InvenTreeModelSerializer): """Serializes a Build object.""" url = serializers.CharField(source='get_absolute_url', read_only=True) @@ -74,6 +74,16 @@ class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer if part_detail is not True: self.fields.pop('part_detail') + reference = serializers.CharField(required=True) + + def validate_reference(self, reference): + """Custom validation for the Build reference field""" + + # Ensure the reference matches the required pattern + Build.validate_reference_field(reference) + + return reference + class Meta: """Serializer metaclass""" model = Build diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index abd25e7d42..8af14ddb67 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -748,6 +748,7 @@ class BuildListTest(BuildAPITest): Build.objects.create( part=part, + reference="BO-0006", quantity=10, title='Just some thing', status=BuildStatus.PRODUCTION, @@ -773,20 +774,23 @@ class BuildListTest(BuildAPITest): Build.objects.create( part=part, quantity=10, - reference=f"build-000{i}", + reference=f"BO-{i + 10}", title=f"Sub build {i}", parent=parent ) # And some sub-sub builds - for sub_build in Build.objects.filter(parent=parent): + for ii, sub_build in enumerate(Build.objects.filter(parent=parent)): for i in range(3): + + x = ii * 10 + i + 50 + Build.objects.create( part=part, - reference=f"{sub_build.reference}-00{i}-sub", + reference=f"BO-{x}", + title=f"{sub_build.reference}-00{i}-sub", quantity=40, - title=f"sub sub build {i}", parent=sub_build ) diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 663a247adf..79c7953c1d 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -12,7 +12,7 @@ from InvenTree import status_codes as status import common.models import build.tasks -from build.models import Build, BuildItem, get_next_build_number +from build.models import Build, BuildItem, generate_next_build_reference from part.models import Part, BomItem, BomItemSubstitute from stock.models import StockItem from users.models import Owner @@ -88,7 +88,7 @@ class BuildTestBase(TestCase): quantity=2 ) - ref = get_next_build_number() + ref = generate_next_build_reference() # Create a "Build" object to make 10x objects self.build = Build.objects.create( @@ -133,20 +133,97 @@ class BuildTest(BuildTestBase): def test_ref_int(self): """Test the "integer reference" field used for natural sorting""" - for ii in range(10): + common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None) + + refs = { + 'BO-123-456': 123, + 'BO-456-123': 456, + 'BO-999-ABC': 999, + 'BO-123ABC-ABC': 123, + 'BO-ABC123-ABC': 123, + } + + for ref, ref_int in refs.items(): build = Build( - reference=f"{ii}_abcde", + reference=ref, quantity=1, part=self.assembly, - title="Making some parts" + title='Making some parts', ) self.assertEqual(build.reference_int, 0) - build.save() + self.assertEqual(build.reference_int, ref_int) - # After saving, the integer reference should have been updated - self.assertEqual(build.reference_int, ii) + def test_ref_validation(self): + """Test that the reference field validation works as expected""" + + # Default reference pattern = 'BO-{ref:04d} + + # These patterns should fail + for ref in [ + 'BO-1234x', + 'BO1234', + 'OB-1234', + 'BO--1234' + ]: + with self.assertRaises(ValidationError): + Build.objects.create( + part=self.assembly, + quantity=10, + reference=ref, + title='Invalid reference', + ) + + for ref in [ + 'BO-1234', + 'BO-9999', + 'BO-123' + ]: + Build.objects.create( + part=self.assembly, + quantity=10, + reference=ref, + title='Valid reference', + ) + + # Try a new validator pattern + common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None) + + for ref in [ + '1234-BO', + '9999-BO' + ]: + Build.objects.create( + part=self.assembly, + quantity=10, + reference=ref, + title='Valid reference', + ) + + def test_next_ref(self): + """Test that the next reference is automatically generated""" + + common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None) + + build = Build.objects.create( + part=self.assembly, + quantity=5, + reference='XYZ-987', + title='Some thing', + ) + + self.assertEqual(build.reference_int, 987) + + # Now create one *without* specifying the reference + build = Build.objects.create( + part=self.assembly, + quantity=1, + title='Some new title', + ) + + self.assertEqual(build.reference, 'XYZ-000988') + self.assertEqual(build.reference_int, 988) def test_init(self): """Perform some basic tests before we start the ball rolling""" @@ -404,7 +481,7 @@ class BuildTest(BuildTestBase): """Test that a notification is sent when a new build is created""" Build.objects.create( - reference='IIIII', + reference='BO-9999', title='Some new build', part=self.assembly, quantity=5, diff --git a/InvenTree/build/test_migrations.py b/InvenTree/build/test_migrations.py index 0a6ea3dd8b..1d04beb414 100644 --- a/InvenTree/build/test_migrations.py +++ b/InvenTree/build/test_migrations.py @@ -104,3 +104,57 @@ class TestReferenceMigration(MigratorTestCase): # Check that the build reference is properly assigned for build in Build.objects.all(): self.assertEqual(str(build.reference), str(build.pk)) + + +class TestReferencePatternMigration(MigratorTestCase): + """Unit test for data migration which converts reference to new format. + + Ref: https://github.com/inventree/InvenTree/pull/3267 + """ + + migrate_from = ('build', '0019_auto_20201019_1302') + migrate_to = ('build', helpers.getNewestMigrationFile('build')) + + def prepare(self): + """Create some initial data prior to migration""" + + Setting = self.old_state.apps.get_model('common', 'inventreesetting') + + # Create a custom existing prefix so we can confirm the operation is working + Setting.objects.create( + key='BUILDORDER_REFERENCE_PREFIX', + value='BuildOrder-', + ) + + Part = self.old_state.apps.get_model('part', 'part') + + assembly = Part.objects.create( + name='Assy 1', + description='An assembly', + level=0, lft=0, rght=0, tree_id=0, + ) + + Build = self.old_state.apps.get_model('build', 'build') + + for idx in range(1, 11): + Build.objects.create( + part=assembly, + title=f"Build {idx}", + quantity=idx, + reference=f"{idx + 100}", + level=0, lft=0, rght=0, tree_id=0, + ) + + def test_reference_migration(self): + """Test that the reference fields have been correctly updated""" + + Build = self.new_state.apps.get_model('build', 'build') + + for build in Build.objects.all(): + self.assertTrue(build.reference.startswith('BuildOrder-')) + + Setting = self.new_state.apps.get_model('common', 'inventreesetting') + + pattern = Setting.objects.get(key='BUILDORDER_REFERENCE_PATTERN') + + self.assertEqual(pattern.value, 'BuildOrder-{ref:04d}') diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py index e09e59d958..f197479d5c 100644 --- a/InvenTree/build/tests.py +++ b/InvenTree/build/tests.py @@ -35,7 +35,7 @@ class BuildTestSimple(InvenTreeTestCase): self.assertEqual(b.batch, 'B2') self.assertEqual(b.quantity, 21) - self.assertEqual(str(b), 'BO0002') + self.assertEqual(str(b), 'BO-0002') def test_url(self): """Test URL lookup""" @@ -75,11 +75,6 @@ class BuildTestSimple(InvenTreeTestCase): self.assertEqual(b1.is_active, True) self.assertEqual(b2.is_active, False) - def test_required_parts(self): - """Test set of required BOM items for the build""" - # TODO: Generate BOM for test part - ... - def test_cancel_build(self): """Test build cancellation function.""" build = Build.objects.get(id=1) diff --git a/InvenTree/build/validators.py b/InvenTree/build/validators.py new file mode 100644 index 0000000000..7e39735efb --- /dev/null +++ b/InvenTree/build/validators.py @@ -0,0 +1,25 @@ +"""Validation methods for the build app""" + + +def generate_next_build_reference(): + """Generate the next available BuildOrder reference""" + + from build.models import Build + + return Build.generate_reference() + + +def validate_build_order_reference_pattern(pattern): + """Validate the BuildOrder reference 'pattern' setting""" + + from build.models import Build + + Build.validate_reference_pattern(pattern) + + +def validate_build_order_reference(value): + """Validate that the BuildOrder reference field matches the required pattern""" + + from build.models import Build + + Build.validate_reference_field(value) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index fb0e19e4c4..f76bb9eaee 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -36,10 +36,12 @@ from djmoney.contrib.exchange.models import convert_money from djmoney.settings import CURRENCY_CHOICES from rest_framework.exceptions import PermissionDenied +import build.validators import InvenTree.fields import InvenTree.helpers import InvenTree.ready import InvenTree.validators +import order.validators logger = logging.getLogger('inventree') @@ -1139,21 +1141,18 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, }, - 'BUILDORDER_REFERENCE_PREFIX': { - 'name': _('Build Order Reference Prefix'), - 'description': _('Prefix value for build order reference'), - 'default': 'BO', + 'BUILDORDER_REFERENCE_PATTERN': { + 'name': _('Build Order Reference Pattern'), + 'description': _('Required pattern for generating Build Order reference field'), + 'default': 'BO-{ref:04d}', + 'validator': build.validators.validate_build_order_reference_pattern, }, - 'BUILDORDER_REFERENCE_REGEX': { - 'name': _('Build Order Reference Regex'), - 'description': _('Regular expression pattern for matching build order reference') - }, - - 'SALESORDER_REFERENCE_PREFIX': { - 'name': _('Sales Order Reference Prefix'), - 'description': _('Prefix value for sales order reference'), - 'default': 'SO', + 'SALESORDER_REFERENCE_PATTERN': { + 'name': _('Sales Order Reference Pattern'), + 'description': _('Required pattern for generating Sales Order reference field'), + 'default': 'SO-{ref:04d}', + 'validator': order.validators.validate_sales_order_reference_pattern, }, 'SALESORDER_DEFAULT_SHIPMENT': { @@ -1163,10 +1162,11 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, }, - 'PURCHASEORDER_REFERENCE_PREFIX': { - 'name': _('Purchase Order Reference Prefix'), - 'description': _('Prefix value for purchase order reference'), - 'default': 'PO', + 'PURCHASEORDER_REFERENCE_PATTERN': { + 'name': _('Purchase Order Reference Pattern'), + 'description': _('Required pattern for generating Purchase Order reference field'), + 'default': 'PO-{ref:04d}', + 'validator': order.validators.validate_purchase_order_reference_pattern, }, # login / SSO diff --git a/InvenTree/order/fixtures/order.yaml b/InvenTree/order/fixtures/order.yaml index 769f7702e5..6c13b10020 100644 --- a/InvenTree/order/fixtures/order.yaml +++ b/InvenTree/order/fixtures/order.yaml @@ -4,7 +4,7 @@ - model: order.purchaseorder pk: 1 fields: - reference: '0001' + reference: 'PO-0001' description: "Ordering some screws" supplier: 1 status: 10 # Pending @@ -13,7 +13,7 @@ - model: order.purchaseorder pk: 2 fields: - reference: '0002' + reference: 'PO-0002' description: "Ordering some more screws" supplier: 3 status: 10 # Pending @@ -21,7 +21,7 @@ - model: order.purchaseorder pk: 3 fields: - reference: '0003' + reference: 'PO-0003' description: 'Another PO' supplier: 3 status: 20 # Placed @@ -29,7 +29,7 @@ - model: order.purchaseorder pk: 4 fields: - reference: '0004' + reference: 'PO-0004' description: 'Another PO' supplier: 3 status: 20 # Placed @@ -37,7 +37,7 @@ - model: order.purchaseorder pk: 5 fields: - reference: '0005' + reference: 'PO-0005' description: 'Another PO' supplier: 3 status: 30 # Complete @@ -45,7 +45,7 @@ - model: order.purchaseorder pk: 6 fields: - reference: '0006' + reference: 'PO-0006' description: 'Another PO' supplier: 3 status: 40 # Cancelled @@ -54,7 +54,7 @@ - model: order.purchaseorder pk: 7 fields: - reference: '0007' + reference: 'PO-0007' description: 'Another PO' supplier: 2 status: 10 # Pending diff --git a/InvenTree/order/migrations/0048_auto_20210702_2321.py b/InvenTree/order/migrations/0048_auto_20210702_2321.py index d6785e669e..6d68c1a0ca 100644 --- a/InvenTree/order/migrations/0048_auto_20210702_2321.py +++ b/InvenTree/order/migrations/0048_auto_20210702_2321.py @@ -1,7 +1,6 @@ # Generated by Django 3.2.4 on 2021-07-02 13:21 from django.db import migrations, models -import order.models class Migration(migrations.Migration): @@ -14,11 +13,11 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='purchaseorder', name='reference', - field=models.CharField(default=order.models.get_next_po_number, help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'), + field=models.CharField(default="PO", help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'), ), migrations.AlterField( model_name='salesorder', name='reference', - field=models.CharField(default=order.models.get_next_so_number, help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'), + field=models.CharField(default="SO", help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'), ), ] diff --git a/InvenTree/order/migrations/0072_alter_salesorder_reference.py b/InvenTree/order/migrations/0072_alter_salesorder_reference.py new file mode 100644 index 0000000000..118e2883be --- /dev/null +++ b/InvenTree/order/migrations/0072_alter_salesorder_reference.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.14 on 2022-07-07 11:55 + +from django.db import migrations, models +import order.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0071_auto_20220628_0133'), + ] + + operations = [ + migrations.AlterField( + model_name='salesorder', + name='reference', + field=models.CharField(default=order.validators.generate_next_sales_order_reference, help_text='Order reference', max_length=64, unique=True, validators=[order.validators.validate_sales_order_reference], verbose_name='Reference'), + ), + ] diff --git a/InvenTree/order/migrations/0073_alter_purchaseorder_reference.py b/InvenTree/order/migrations/0073_alter_purchaseorder_reference.py new file mode 100644 index 0000000000..1befaaa38f --- /dev/null +++ b/InvenTree/order/migrations/0073_alter_purchaseorder_reference.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.14 on 2022-07-09 01:01 + +from django.db import migrations, models +import order.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0072_alter_salesorder_reference'), + ] + + operations = [ + migrations.AlterField( + model_name='purchaseorder', + name='reference', + field=models.CharField(default=order.validators.generate_next_purchase_order_reference, help_text='Order reference', max_length=64, unique=True, validators=[order.validators.validate_purchase_order_reference], verbose_name='Reference'), + ), + ] diff --git a/InvenTree/order/migrations/0074_auto_20220709_0108.py b/InvenTree/order/migrations/0074_auto_20220709_0108.py new file mode 100644 index 0000000000..8c7a546274 --- /dev/null +++ b/InvenTree/order/migrations/0074_auto_20220709_0108.py @@ -0,0 +1,107 @@ +# Generated by Django 3.2.14 on 2022-07-09 01:08 + +from django.db import migrations + + +def update_order_references(order_model, prefix): + """Update all references of the given model, with the specified prefix""" + + n = 0 + + for order in order_model.objects.all(): + if not order.reference.startswith(prefix): + order.reference = prefix + order.reference + order.save() + + n += 1 + + return n + + +def update_salesorder_reference(apps, schema_editor): + """Migrate the reference pattern for the SalesOrder model""" + + # Extract the existing "prefix" value + InvenTreeSetting = apps.get_model('common', 'inventreesetting') + + try: + prefix = InvenTreeSetting.objects.get(key='SALESORDER_REFERENCE_PREFIX').value + except Exception: + prefix = 'SO-' + + # Construct a reference pattern + pattern = prefix + '{ref:04d}' + + # Create or update the BuildOrder.reference pattern + try: + setting = InvenTreeSetting.objects.get(key='SALESORDER_REFERENCE_PATTERN') + setting.value = pattern + setting.save() + except InvenTreeSetting.DoesNotExist: + setting = InvenTreeSetting.objects.create( + key='SALESORDER_REFERENCE_PATTERN', + value=pattern, + ) + + # Update any existing sales order references + SalesOrder = apps.get_model('order', 'salesorder') + n = update_order_references(SalesOrder, prefix) + + if n > 0: + print(f"Updated reference field for {n} SalesOrder objects") + + +def update_purchaseorder_reference(apps, schema_editor): + """Migrate the reference pattern for the PurchaseOrder model""" + + # Extract the existing "prefix" value + InvenTreeSetting = apps.get_model('common', 'inventreesetting') + + try: + prefix = InvenTreeSetting.objects.get(key='PURCHASEORDER_REFERENCE_PREFIX').value + except Exception: + prefix = 'PO-' + + # Construct a reference pattern + pattern = prefix + '{ref:04d}' + + # Create or update the BuildOrder.reference pattern + try: + setting = InvenTreeSetting.objects.get(key='PURCHASEORDER_REFERENCE_PATTERN') + setting.value = pattern + setting.save() + except InvenTreeSetting.DoesNotExist: + setting = InvenTreeSetting.objects.create( + key='PURCHASEORDER_REFERENCE_PATTERN', + value=pattern, + ) + + # Update any existing sales order references + PurchaseOrder = apps.get_model('order', 'purchaseorder') + n = update_order_references(PurchaseOrder, prefix) + + if n > 0: + print(f"Updated reference field for {n} PurchaseOrder objects") + + +def nop(apps, schema_editor): + """Empty function for reverse migration""" + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0073_alter_purchaseorder_reference'), + ] + + operations = [ + migrations.RunPython( + update_salesorder_reference, + reverse_code=nop, + ), + migrations.RunPython( + update_purchaseorder_reference, + reverse_code=nop, + ) + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 5206a54997..22ea2dba8e 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -24,14 +24,14 @@ from mptt.models import TreeForeignKey import InvenTree.helpers import InvenTree.ready +import order.validators from common.notifications import InvenTreeNotificationBodies from common.settings import currency_code_default from company.models import Company, SupplierPart from InvenTree.exceptions import log_error from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField, RoundingDecimalField) -from InvenTree.helpers import (decimal2string, getSetting, increment, - notify_responsible) +from InvenTree.helpers import decimal2string, getSetting, notify_responsible from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus, StockHistoryCode, StockStatus) @@ -44,58 +44,6 @@ from users import models as UserModels logger = logging.getLogger('inventree') -def get_next_po_number(): - """Returns the next available PurchaseOrder reference number.""" - if PurchaseOrder.objects.count() == 0: - return '0001' - - order = PurchaseOrder.objects.exclude(reference=None).last() - - attempts = {order.reference} - - reference = order.reference - - while 1: - reference = increment(reference) - - if reference in attempts: - # Escape infinite recursion - return reference - - if PurchaseOrder.objects.filter(reference=reference).exists(): - attempts.add(reference) - else: - break - - return reference - - -def get_next_so_number(): - """Returns the next available SalesOrder reference number.""" - if SalesOrder.objects.count() == 0: - return '0001' - - order = SalesOrder.objects.exclude(reference=None).last() - - attempts = {order.reference} - - reference = order.reference - - while 1: - reference = increment(reference) - - if reference in attempts: - # Escape infinite recursion - return reference - - if SalesOrder.objects.filter(reference=reference).exists(): - attempts.add(reference) - else: - break - - return reference - - class Order(MetadataMixin, ReferenceIndexingMixin): """Abstract model for an order. @@ -119,7 +67,7 @@ class Order(MetadataMixin, ReferenceIndexingMixin): Ensures that the reference field is rebuilt whenever the instance is saved. """ - self.rebuild_reference_field() + self.reference_int = self.rebuild_reference_field(self.reference) if not self.creation_date: self.creation_date = datetime.now().date() @@ -230,8 +178,21 @@ class PurchaseOrder(Order): """Return the API URL associated with the PurchaseOrder model""" return reverse('api-po-list') + @classmethod + def api_defaults(cls, request): + """Return default values for thsi model when issuing an API OPTIONS request""" + + defaults = { + 'reference': order.validators.generate_next_purchase_order_reference(), + } + + return defaults + OVERDUE_FILTER = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date()) + # Global setting for specifying reference pattern + REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN' + @staticmethod def filterByDate(queryset, min_date, max_date): """Filter by 'minimum and maximum date range'. @@ -269,9 +230,8 @@ class PurchaseOrder(Order): def __str__(self): """Render a string representation of this PurchaseOrder""" - prefix = getSetting('PURCHASEORDER_REFERENCE_PREFIX') - return f"{prefix}{self.reference} - {self.supplier.name if self.supplier else _('deleted')}" + return f"{self.reference} - {self.supplier.name if self.supplier else _('deleted')}" reference = models.CharField( unique=True, @@ -279,7 +239,10 @@ class PurchaseOrder(Order): blank=False, verbose_name=_('Reference'), help_text=_('Order reference'), - default=get_next_po_number, + default=order.validators.generate_next_purchase_order_reference, + validators=[ + order.validators.validate_purchase_order_reference, + ] ) status = models.PositiveIntegerField(default=PurchaseOrderStatus.PENDING, choices=PurchaseOrderStatus.items(), @@ -595,8 +558,20 @@ class SalesOrder(Order): """Return the API URL associated with the SalesOrder model""" return reverse('api-so-list') + @classmethod + def api_defaults(cls, request): + """Return default values for this model when issuing an API OPTIONS request""" + defaults = { + 'reference': order.validators.generate_next_sales_order_reference(), + } + + return defaults + OVERDUE_FILTER = Q(status__in=SalesOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date()) + # Global setting for specifying reference pattern + REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN' + @staticmethod def filterByDate(queryset, min_date, max_date): """Filter by "minimum and maximum date range". @@ -634,9 +609,8 @@ class SalesOrder(Order): def __str__(self): """Render a string representation of this SalesOrder""" - prefix = getSetting('SALESORDER_REFERENCE_PREFIX') - return f"{prefix}{self.reference} - {self.customer.name if self.customer else _('deleted')}" + return f"{self.reference} - {self.customer.name if self.customer else _('deleted')}" def get_absolute_url(self): """Return the web URL for the detail view of this order""" @@ -648,7 +622,10 @@ class SalesOrder(Order): blank=False, verbose_name=_('Reference'), help_text=_('Order reference'), - default=get_next_so_number, + default=order.validators.generate_next_sales_order_reference, + validators=[ + order.validators.validate_sales_order_reference, + ] ) customer = models.ForeignKey( diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 95a41902d5..dafd9b5817 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -23,8 +23,7 @@ from InvenTree.helpers import extract_serial_numbers, normalize from InvenTree.serializers import (InvenTreeAttachmentSerializer, InvenTreeDecimalField, InvenTreeModelSerializer, - InvenTreeMoneySerializer, - ReferenceIndexingSerializerMixin) + InvenTreeMoneySerializer) from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus, StockStatus) from part.serializers import PartBriefSerializer @@ -86,7 +85,7 @@ class AbstractExtraLineMeta: ] -class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerMixin, InvenTreeModelSerializer): +class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer): """Serializer for a PurchaseOrder object.""" def __init__(self, *args, **kwargs): @@ -130,6 +129,14 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializ reference = serializers.CharField(required=True) + def validate_reference(self, reference): + """Custom validation for the reference field""" + + # Ensure that the reference matches the required pattern + order.models.PurchaseOrder.validate_reference_field(reference) + + return reference + responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False) class Meta: @@ -639,7 +646,7 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer): ] -class SalesOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerMixin, InvenTreeModelSerializer): +class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer): """Serializers for the SalesOrder object.""" def __init__(self, *args, **kwargs): @@ -683,6 +690,14 @@ class SalesOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerM reference = serializers.CharField(required=True) + def validate_reference(self, reference): + """Custom validation for the reference field""" + + # Ensure that the reference matches the required pattern + order.models.SalesOrder.validate_reference_field(reference) + + return reference + class Meta: """Metaclass options.""" diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index a14fea16f7..2d16796bc3 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -82,7 +82,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Order Reference" %} - {% settings_value 'PURCHASEORDER_REFERENCE_PREFIX' %}{{ order.reference }}{% include "clip.html"%} + {{ order.reference }}{% include "clip.html"%} @@ -222,7 +222,7 @@ $("#edit-order").click(function() { constructForm('{% url "api-po-detail" order.pk %}', { fields: { reference: { - prefix: global_settings.PURCHASEORDER_REFERENCE_PREFIX, + icon: 'fa-hashtag', }, {% if order.lines.count == 0 and order.status == PurchaseOrderStatus.PENDING %} supplier: { diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 131280ac7a..0c14d9859d 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -78,7 +78,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Order Reference" %} - {% settings_value 'SALESORDER_REFERENCE_PREFIX' %}{{ order.reference }}{% include "clip.html"%} + {{ order.reference }}{% include "clip.html"%} @@ -209,7 +209,7 @@ $("#edit-order").click(function() { constructForm('{% url "api-so-detail" order.pk %}', { fields: { reference: { - prefix: global_settings.SALESORDER_REFERENCE_PREFIX, + icon: 'fa-hashtag', }, {% if order.lines.count == 0 and order.status == SalesOrderStatus.PENDING %} customer: { diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index f3bc6e9a42..7824fcb4a7 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -94,23 +94,28 @@ class PurchaseOrderTest(OrderTest): self.assertEqual(data['description'], 'Ordering some screws') def test_po_reference(self): - """Test that a reference with a too big / small reference is not possible.""" + """Test that a reference with a too big / small reference is handled correctly.""" # get permissions self.assignRole('purchase_order.add') url = reverse('api-po-list') - huge_number = 9223372036854775808 + huge_number = "PO-92233720368547758089999999999999999" - self.post( + response = self.post( url, { 'supplier': 1, 'reference': huge_number, - 'description': 'PO not created via the API', + 'description': 'PO created via the API', }, expected_code=201, ) + order = models.PurchaseOrder.objects.get(pk=response.data['pk']) + + self.assertEqual(order.reference, 'PO-92233720368547758089999999999999999') + self.assertEqual(order.reference_int, 0x7fffffff) + def test_po_attachments(self): """Test the list endpoint for the PurchaseOrderAttachment model""" url = reverse('api-po-attachment-list') @@ -149,7 +154,7 @@ class PurchaseOrderTest(OrderTest): url, { 'supplier': 1, - 'reference': '123456789-xyz', + 'reference': 'PO-123456789', 'description': 'PO created via the API', }, expected_code=201 @@ -177,19 +182,19 @@ class PurchaseOrderTest(OrderTest): # Get detail info! response = self.get(url) self.assertEqual(response.data['pk'], pk) - self.assertEqual(response.data['reference'], '123456789-xyz') + self.assertEqual(response.data['reference'], 'PO-123456789') # Try to alter (edit) the PurchaseOrder response = self.patch( url, { - 'reference': '12345-abc', + 'reference': 'PO-12345', }, expected_code=200 ) # Reference should have changed - self.assertEqual(response.data['reference'], '12345-abc') + self.assertEqual(response.data['reference'], 'PO-12345') # Now, let's try to delete it! # Initially, we do *not* have the required permission! @@ -213,7 +218,7 @@ class PurchaseOrderTest(OrderTest): self.post( reverse('api-po-list'), { - 'reference': '12345678', + 'reference': 'PO-12345678', 'supplier': 1, 'description': 'A test purchase order', }, @@ -807,7 +812,7 @@ class SalesOrderTest(OrderTest): url, { 'customer': 4, - 'reference': '12345', + 'reference': 'SO-12345', 'description': 'Sales order', }, expected_code=201 @@ -824,7 +829,7 @@ class SalesOrderTest(OrderTest): url, { 'customer': 4, - 'reference': '12345', + 'reference': 'SO-12345', 'description': 'Another sales order', }, expected_code=400 @@ -834,19 +839,28 @@ class SalesOrderTest(OrderTest): # Extract detail info for the SalesOrder response = self.get(url) - self.assertEqual(response.data['reference'], '12345') + self.assertEqual(response.data['reference'], 'SO-12345') # Try to alter (edit) the SalesOrder + # Initially try with an invalid reference field value response = self.patch( url, { - 'reference': '12345-a', + 'reference': 'SO-12345-a', + }, + expected_code=400 + ) + + response = self.patch( + url, + { + 'reference': 'SO-12346', }, expected_code=200 ) # Reference should have changed - self.assertEqual(response.data['reference'], '12345-a') + self.assertEqual(response.data['reference'], 'SO-12346') # Now, let's try to delete this SalesOrder # Initially, we do not have the required permission @@ -866,14 +880,29 @@ class SalesOrderTest(OrderTest): """Test that we can create a new SalesOrder via the API.""" self.assignRole('sales_order.add') - self.post( - reverse('api-so-list'), + url = reverse('api-so-list') + + # Will fail due to invalid reference field + response = self.post( + url, { 'reference': '1234566778', 'customer': 4, 'description': 'A test sales order', }, - expected_code=201 + expected_code=400, + ) + + self.assertIn('Reference must match required pattern', str(response.data['reference'])) + + self.post( + url, + { + 'reference': 'SO-12345', + 'customer': 4, + 'description': 'A better test sales order', + }, + expected_code=201, ) def test_so_cancel(self): diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index 21303a2c2c..2d00f69ac6 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -40,19 +40,27 @@ class SalesOrderTest(TestCase): # Create a SalesOrder to ship against self.order = SalesOrder.objects.create( customer=self.customer, - reference='1234', + reference='SO-1234', customer_reference='ABC 55555' ) # Create a Shipment against this SalesOrder self.shipment = SalesOrderShipment.objects.create( order=self.order, - reference='001', + reference='SO-001', ) # Create a line item self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part) + def test_so_reference(self): + """Unit tests for sales order generation""" + + # Test that a good reference is created when we have no existing orders + SalesOrder.objects.all().delete() + + self.assertEqual(SalesOrder.generate_reference(), 'SO-0001') + def test_rebuild_reference(self): """Test that the 'reference_int' field gets rebuilt when the model is saved""" diff --git a/InvenTree/order/tests.py b/InvenTree/order/tests.py index a2042f08ed..3885c972c4 100644 --- a/InvenTree/order/tests.py +++ b/InvenTree/order/tests.py @@ -35,15 +35,17 @@ class OrderTest(TestCase): def test_basics(self): """Basic tests e.g. repr functions etc.""" - order = PurchaseOrder.objects.get(pk=1) - self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/') + for pk in range(1, 8): - self.assertEqual(str(order), 'PO0001 - ACME') + order = PurchaseOrder.objects.get(pk=pk) + + self.assertEqual(order.get_absolute_url(), f'/order/purchase-order/{pk}/') + + self.assertEqual(order.reference, f'PO-{pk:04d}') line = PurchaseOrderLineItem.objects.get(pk=1) - - self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO0001 - ACME)") + self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO-0001 - ACME)") def test_rebuild_reference(self): """Test that the reference_int field is correctly updated when the model is saved""" diff --git a/InvenTree/order/validators.py b/InvenTree/order/validators.py new file mode 100644 index 0000000000..3ca3a58940 --- /dev/null +++ b/InvenTree/order/validators.py @@ -0,0 +1,49 @@ +"""Validation methods for the order app""" + + +def generate_next_sales_order_reference(): + """Generate the next available SalesOrder reference""" + + from order.models import SalesOrder + + return SalesOrder.generate_reference() + + +def generate_next_purchase_order_reference(): + """Generate the next available PurchasesOrder reference""" + + from order.models import PurchaseOrder + + return PurchaseOrder.generate_reference() + + +def validate_sales_order_reference_pattern(pattern): + """Validate the SalesOrder reference 'pattern' setting""" + + from order.models import SalesOrder + + SalesOrder.validate_reference_pattern(pattern) + + +def validate_purchase_order_reference_pattern(pattern): + """Validate the PurchaseOrder reference 'pattern' setting""" + + from order.models import PurchaseOrder + + PurchaseOrder.validate_reference_pattern(pattern) + + +def validate_sales_order_reference(value): + """Validate that the SalesOrder reference field matches the required pattern""" + + from order.models import SalesOrder + + SalesOrder.validate_reference_field(value) + + +def validate_purchase_order_reference(value): + """Validate that the PurchaseOrder reference field matches the required pattern""" + + from order.models import PurchaseOrder + + PurchaseOrder.validate_reference_field(value) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index ec3d39abb3..6d0bc89226 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -1514,6 +1514,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): part=Part.objects.get(pk=101), quantity=10, title='Making some assemblies', + reference='BO-9999', status=BuildStatus.PRODUCTION, ) diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 453ade931f..5072028394 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -425,7 +425,6 @@ class PurchaseOrderReport(ReportTemplateBase): 'order': order, 'reference': order.reference, 'supplier': order.supplier, - 'prefix': common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_PREFIX'), 'title': str(order), } @@ -463,7 +462,6 @@ class SalesOrderReport(ReportTemplateBase): 'lines': order.lines, 'extra_lines': order.extra_lines, 'order': order, - 'prefix': common.models.InvenTreeSetting.get_setting('SALESORDER_REFERENCE_PREFIX'), 'reference': order.reference, 'title': str(order), } diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 97bd10b1fd..2196d2c193 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -30,8 +30,7 @@ import report.models from company import models as CompanyModels from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField, InvenTreeURLField) -from InvenTree.models import InvenTreeAttachment, InvenTreeTree -from InvenTree.serializers import extract_int +from InvenTree.models import InvenTreeAttachment, InvenTreeTree, extract_int from InvenTree.status_codes import StockHistoryCode, StockStatus from part import models as PartModels from plugin.events import trigger_event @@ -1708,8 +1707,7 @@ class StockItem(MetadataMixin, MPTTModel): s += ' @ {loc}'.format(loc=self.location.name) if self.purchase_order: - s += " ({pre}{po})".format( - pre=InvenTree.helpers.getSetting("PURCHASEORDER_REFERENCE_PREFIX"), + s += " ({po})".format( po=self.purchase_order, ) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index a08a5e95ea..5fd27887fb 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -20,7 +20,8 @@ import InvenTree.serializers import part.models as part_models from common.settings import currency_code_default, currency_code_mappings from company.serializers import SupplierPartSerializer -from InvenTree.serializers import InvenTreeDecimalField, extract_int +from InvenTree.models import extract_int +from InvenTree.serializers import InvenTreeDecimalField from part.serializers import PartBriefSerializer from .models import (StockItem, StockItemAttachment, StockItemTestResult, @@ -67,8 +68,8 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer): def validate_serial(self, value): """Make sure serial is not to big.""" - if extract_int(value) > 2147483647: - raise serializers.ValidationError('serial is to to big') + if abs(extract_int(value)) > 0x7fffffff: + raise serializers.ValidationError(_("Serial number is too large")) return value diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index ec197e9f8b..93b6cc87bc 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -87,7 +87,7 @@ class StockTest(InvenTreeTestCase): # And there should be *no* items being build self.assertEqual(part.quantity_being_built, 0) - build = Build.objects.create(reference='12345', part=part, title='A test build', quantity=1) + build = Build.objects.create(reference='BO-4444', part=part, title='A test build', quantity=1) # Add some stock items which are "building" for _ in range(10): @@ -395,13 +395,14 @@ class StockTest(InvenTreeTestCase): item.serial = "-123" item.save() - # Negative number should map to zero - self.assertEqual(item.serial_int, 0) + # Negative number should map to positive value + self.assertEqual(item.serial_int, 123) # Test a very very large value item.serial = '99999999999999999999999999999999999999999999999999999' item.save() + # The 'integer' portion has been clipped to a maximum value self.assertEqual(item.serial_int, 0x7fffffff) # Non-numeric values should encode to zero diff --git a/InvenTree/templates/InvenTree/settings/build.html b/InvenTree/templates/InvenTree/settings/build.html index de414a6284..5c168f4354 100644 --- a/InvenTree/templates/InvenTree/settings/build.html +++ b/InvenTree/templates/InvenTree/settings/build.html @@ -12,8 +12,7 @@ - {% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_PREFIX" %} - {% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_REGEX" %} + {% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_PATTERN" %}
diff --git a/InvenTree/templates/InvenTree/settings/po.html b/InvenTree/templates/InvenTree/settings/po.html index 193f9ecb93..d9fd29088e 100644 --- a/InvenTree/templates/InvenTree/settings/po.html +++ b/InvenTree/templates/InvenTree/settings/po.html @@ -10,7 +10,7 @@ {% block content %} - {% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PREFIX" %} + {% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PATTERN" %}
{% endblock %} diff --git a/InvenTree/templates/InvenTree/settings/so.html b/InvenTree/templates/InvenTree/settings/so.html index cdc5213314..b887f24c96 100644 --- a/InvenTree/templates/InvenTree/settings/so.html +++ b/InvenTree/templates/InvenTree/settings/so.html @@ -11,7 +11,7 @@ - {% include "InvenTree/settings/setting.html" with key="SALESORDER_REFERENCE_PREFIX" %} + {% include "InvenTree/settings/setting.html" with key="SALESORDER_REFERENCE_PATTERN" %} {% include "InvenTree/settings/setting.html" with key="SALESORDER_DEFAULT_SHIPMENT" icon="fa-truck-loading" %}
diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index df51aae679..a97d0e5f5a 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -292,8 +292,8 @@ function exportBom(part_id, options={}) { choices: exportFormatOptions(), }, cascade: { - label: '{% trans "Cascading" %}', - help_text: '{% trans "Download cascading / multi-level BOM" %}', + label: '{% trans "Multi Level BOM" %}', + help_text: '{% trans "Include BOM data for subassemblies" %}', type: 'boolean', value: inventreeLoad('bom-export-cascading', true), }, @@ -302,6 +302,7 @@ function exportBom(part_id, options={}) { help_text: '{% trans "Select maximum number of BOM levels to export (0 = all levels)" %}', type: 'integer', value: 0, + required: true, min_value: 0, }, parameter_data: { diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 2a4e0a6edb..223427e68e 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -4,7 +4,6 @@ /* globals buildStatusDisplay, constructForm, - global_settings, imageHoverIcon, inventreeGet, launchModalForm, @@ -36,7 +35,7 @@ function buildFormFields() { return { reference: { - prefix: global_settings.BUILDORDER_REFERENCE_PREFIX, + icon: 'fa-hashtag', }, part: { filters: { @@ -731,9 +730,8 @@ function loadBuildOrderAllocationTable(table, options={}) { switchable: false, title: '{% trans "Build Order" %}', formatter: function(value, row) { - var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX; - var ref = `${prefix}${row.build_detail.reference}`; + var ref = `${row.build_detail.reference}`; return renderLink(ref, `/build/${row.build}/`); } @@ -2372,7 +2370,6 @@ function loadBuildTable(table, options) { filters, { success: function(response) { - var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX; for (var idx = 0; idx < response.length; idx++) { @@ -2386,7 +2383,7 @@ function loadBuildTable(table, options) { date = order.target_date; } - var title = `${prefix}${order.reference}`; + var title = `${order.reference}`; var color = '#4c68f5'; @@ -2460,12 +2457,6 @@ function loadBuildTable(table, options) { switchable: true, formatter: function(value, row) { - var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX; - - if (prefix) { - value = `${prefix}${value}`; - } - var html = renderLink(value, '/build/' + row.pk + '/'); if (row.overdue) { diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index 5f3d9e8279..f3527bcdee 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -255,8 +255,7 @@ function renderOwner(name, data, parameters={}, options={}) { // eslint-disable-next-line no-unused-vars function renderPurchaseOrder(name, data, parameters={}, options={}) { - var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX; - var html = `${prefix}${data.reference}`; + var html = `${data.reference}`; var thumbnail = null; @@ -281,8 +280,7 @@ function renderPurchaseOrder(name, data, parameters={}, options={}) { // eslint-disable-next-line no-unused-vars function renderSalesOrder(name, data, parameters={}, options={}) { - var prefix = global_settings.SALESORDER_REFERENCE_PREFIX; - var html = `${prefix}${data.reference}`; + var html = `${data.reference}`; var thumbnail = null; @@ -307,10 +305,8 @@ function renderSalesOrder(name, data, parameters={}, options={}) { // eslint-disable-next-line no-unused-vars 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} + ${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference} {% trans "Shipment ID" %}: ${data.pk} diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 04bed69c6c..f8166fc693 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -431,7 +431,7 @@ function createSalesOrderShipment(options={}) { var fields = salesOrderShipmentFields(options); fields.reference.value = ref; - fields.reference.prefix = global_settings.SALESORDER_REFERENCE_PREFIX + options.reference; + fields.reference.prefix = options.reference; constructForm('{% url "api-so-shipment-list" %}', { method: 'POST', @@ -456,7 +456,7 @@ function createSalesOrder(options={}) { method: 'POST', fields: { reference: { - prefix: global_settings.SALESORDER_REFERENCE_PREFIX, + icon: 'fa-hashtag', }, customer: { value: options.customer, @@ -497,7 +497,7 @@ function createPurchaseOrder(options={}) { method: 'POST', fields: { reference: { - prefix: global_settings.PURCHASEORDER_REFERENCE_PREFIX, + icon: 'fa-hashtag', }, supplier: { icon: 'fa-building', @@ -1081,9 +1081,7 @@ function newPurchaseOrderFromOrderWizard(e) { }, { success: function(response) { - var text = global_settings.PURCHASEORDER_REFERENCE_PREFIX || ''; - - text += response.reference; + var text = response.reference; if (response.supplier_detail) { text += ` ${response.supplier_detail.name}`; @@ -1545,8 +1543,6 @@ function loadPurchaseOrderTable(table, options) { filters, { success: function(response) { - var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX; - for (var idx = 0; idx < response.length; idx++) { var order = response[idx]; @@ -1559,7 +1555,7 @@ function loadPurchaseOrderTable(table, options) { date = order.target_date; } - var title = `${prefix}${order.reference} - ${order.supplier_detail.name}`; + var title = `${order.reference} - ${order.supplier_detail.name}`; var color = '#4c68f5'; @@ -1623,12 +1619,6 @@ function loadPurchaseOrderTable(table, options) { switchable: false, formatter: function(value, row) { - var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX; - - if (prefix) { - value = `${prefix}${value}`; - } - var html = renderLink(value, `/order/purchase-order/${row.pk}/`); if (row.overdue) { @@ -2336,8 +2326,6 @@ function loadSalesOrderTable(table, options) { { success: function(response) { - var prefix = global_settings.SALESORDER_REFERENCE_PREFIX; - for (var idx = 0; idx < response.length; idx++) { var order = response[idx]; @@ -2349,7 +2337,7 @@ function loadSalesOrderTable(table, options) { date = order.target_date; } - var title = `${prefix}${order.reference} - ${order.customer_detail.name}`; + var title = `${order.reference} - ${order.customer_detail.name}`; // Default color is blue var color = '#4c68f5'; @@ -2435,13 +2423,6 @@ function loadSalesOrderTable(table, options) { field: 'reference', title: '{% trans "Sales Order" %}', formatter: function(value, row) { - - var prefix = global_settings.SALESORDER_REFERENCE_PREFIX; - - if (prefix) { - value = `${prefix}${value}`; - } - var html = renderLink(value, `/order/sales-order/${row.pk}/`); if (row.overdue) { @@ -2891,7 +2872,7 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) { var fields = salesOrderShipmentFields(options); fields.reference.value = ref; - fields.reference.prefix = global_settings.SALESORDER_REFERENCE_PREFIX + options.reference; + fields.reference.prefix = options.reference; return fields; } @@ -3123,9 +3104,7 @@ function loadSalesOrderAllocationTable(table, options={}) { title: '{% trans "Order" %}', formatter: function(value, row) { - var prefix = global_settings.SALESORDER_REFERENCE_PREFIX; - - var ref = `${prefix}${row.order_detail.reference}`; + var ref = `${row.order_detail.reference}`; return renderLink(ref, `/order/sales-order/${row.order}/`); } diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 1cc98fa57c..46579493c4 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -974,9 +974,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) { return '-'; } - var ref = global_settings.PURCHASEORDER_REFERENCE_PREFIX + order.reference; - - var html = renderLink(ref, `/order/purchase-order/${order.pk}/`); + var html = renderLink(order.reference, `/order/purchase-order/${order.pk}/`); html += purchaseOrderStatusDisplay( order.status, diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 35de58dd97..f12f136dff 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1916,10 +1916,7 @@ function loadStockTable(table, options) { var text = `${row.purchase_order}`; if (row.purchase_order_reference) { - - var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX; - - text = prefix + row.purchase_order_reference; + text = row.purchase_order_reference; } return renderLink(text, link);