From 6cc577fa733709fe6b38f2f63244ebcb9a1fb8e5 Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Wed, 20 Nov 2024 19:12:43 +0000 Subject: [PATCH 1/5] add PO wildcard default setting --- .pre-commit-config.yaml | 4 +-- src/backend/InvenTree/InvenTree/models.py | 27 +++++++++++++++++++ src/backend/InvenTree/common/models.py | 9 +++++++ src/backend/InvenTree/order/models.py | 3 +++ .../templates/InvenTree/settings/po.html | 1 + 5 files changed, 42 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5185cbac917c..510be7184838 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: - id: check-yaml - id: mixed-line-ending - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.3 + rev: v0.7.4 hooks: - id: ruff-format args: [--preview] @@ -28,7 +28,7 @@ repos: --preview ] - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.5.1 + rev: 0.5.3 hooks: - id: pip-compile name: pip-compile requirements-dev.in diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 8b465222b20a..ab289e067d77 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -289,6 +289,7 @@ class ReferenceIndexingMixin(models.Model): # Name of the global setting which defines the required reference pattern for this model REFERENCE_PATTERN_SETTING = None + REFERENCE_PATTERN_WILDCARD_DEFAULT_SETTING = None class Meta: """Metaclass options. Abstract ensures no database table is created.""" @@ -309,6 +310,28 @@ def get_reference_pattern(cls): cls.REFERENCE_PATTERN_SETTING, create=False ).strip() + @classmethod + def get_reference_pattern_wildcard_default(cls): + """Returns the reference pattern associated with this model. + + This is defined by a global setting object, specified by the REFERENCE_PATTERN_WILDCARD_DEFAULT_SETTING attribute + """ + # By default, we return None to indicate no default exists + if cls.REFERENCE_PATTERN_WILDCARD_DEFAULT_SETTING is None: + return None + + # import at function level to prevent cyclic imports + from common.models import InvenTreeSetting + + setting = InvenTreeSetting.get_setting( + cls.REFERENCE_PATTERN_WILDCARD_DEFAULT_SETTING, create=False + ).strip() + + if setting == '': + return None + else: + return setting + @classmethod def get_reference_context(cls): """Generate context data for generating the 'reference' field for this class. @@ -365,6 +388,7 @@ def generate_reference(cls): """Generate the next 'reference' field based on specified pattern.""" fmt = cls.get_reference_pattern() ctx = cls.get_reference_context() + wildcard_default = cls.get_reference_pattern_wildcard_default() reference = None @@ -394,6 +418,9 @@ def generate_reference(cls): recent = cls.get_most_recent_item() reference = recent.reference if recent else '' + if wildcard_default is not None: + reference = reference.replace('?', wildcard_default) + return reference @classmethod diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index fb57bfa67db1..7ee33fde46fa 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -1981,6 +1981,15 @@ def save(self, *args, **kwargs): 'default': 'PO-{ref:04d}', 'validator': order.validators.validate_purchase_order_reference_pattern, }, + 'PURCHASEORDER_REFERENCE_PATTERN_WILDCARD_DEFAULT': { + 'name': _('Purchase Order Reference Pattern Wildcard Default'), + 'description': _( + 'Replace all wildcard ? characters in reference pattern with a default value' + ), + 'default': None, + # TODO: add validator + 'validator': lambda x: x, + }, 'PURCHASEORDER_REQUIRE_RESPONSIBLE': { 'name': _('Require Responsible Owner'), 'description': _('A responsible owner must be assigned to each order'), diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 857897471111..e27613588f52 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -378,6 +378,9 @@ class PurchaseOrder(TotalPriceMixin, Order): """ REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN' + REFERENCE_PATTERN_WILDCARD_DEFAULT_SETTING = ( + 'PURCHASEORDER_REFERENCE_PATTERN_WILDCARD_DEFAULT' + ) REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE' class Meta: diff --git a/src/backend/InvenTree/templates/InvenTree/settings/po.html b/src/backend/InvenTree/templates/InvenTree/settings/po.html index 6759d14bf993..ea12dcd3bfaa 100644 --- a/src/backend/InvenTree/templates/InvenTree/settings/po.html +++ b/src/backend/InvenTree/templates/InvenTree/settings/po.html @@ -11,6 +11,7 @@ {% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PATTERN" %} + {% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PATTERN_WILDCARD_DEFAULT" %} {% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REQUIRE_RESPONSIBLE" %} {% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_EDIT_COMPLETED_ORDERS" icon='fa-edit' %} {% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_AUTO_COMPLETE" icon='fa-check-circle' %} From a4e3b2f52979e8cb8e22df7a0fef915d23d76c78 Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Thu, 21 Nov 2024 18:06:27 +0000 Subject: [PATCH 2/5] Revert "add PO wildcard default setting" This reverts commit 6cc577fa733709fe6b38f2f63244ebcb9a1fb8e5. --- .pre-commit-config.yaml | 4 +-- src/backend/InvenTree/InvenTree/models.py | 27 ------------------- src/backend/InvenTree/common/models.py | 9 ------- src/backend/InvenTree/order/models.py | 3 --- .../templates/InvenTree/settings/po.html | 1 - 5 files changed, 2 insertions(+), 42 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 510be7184838..5185cbac917c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: - id: check-yaml - id: mixed-line-ending - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.4 + rev: v0.7.3 hooks: - id: ruff-format args: [--preview] @@ -28,7 +28,7 @@ repos: --preview ] - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.5.3 + rev: 0.5.1 hooks: - id: pip-compile name: pip-compile requirements-dev.in diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index ab289e067d77..8b465222b20a 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -289,7 +289,6 @@ class ReferenceIndexingMixin(models.Model): # Name of the global setting which defines the required reference pattern for this model REFERENCE_PATTERN_SETTING = None - REFERENCE_PATTERN_WILDCARD_DEFAULT_SETTING = None class Meta: """Metaclass options. Abstract ensures no database table is created.""" @@ -310,28 +309,6 @@ def get_reference_pattern(cls): cls.REFERENCE_PATTERN_SETTING, create=False ).strip() - @classmethod - def get_reference_pattern_wildcard_default(cls): - """Returns the reference pattern associated with this model. - - This is defined by a global setting object, specified by the REFERENCE_PATTERN_WILDCARD_DEFAULT_SETTING attribute - """ - # By default, we return None to indicate no default exists - if cls.REFERENCE_PATTERN_WILDCARD_DEFAULT_SETTING is None: - return None - - # import at function level to prevent cyclic imports - from common.models import InvenTreeSetting - - setting = InvenTreeSetting.get_setting( - cls.REFERENCE_PATTERN_WILDCARD_DEFAULT_SETTING, create=False - ).strip() - - if setting == '': - return None - else: - return setting - @classmethod def get_reference_context(cls): """Generate context data for generating the 'reference' field for this class. @@ -388,7 +365,6 @@ def generate_reference(cls): """Generate the next 'reference' field based on specified pattern.""" fmt = cls.get_reference_pattern() ctx = cls.get_reference_context() - wildcard_default = cls.get_reference_pattern_wildcard_default() reference = None @@ -418,9 +394,6 @@ def generate_reference(cls): recent = cls.get_most_recent_item() reference = recent.reference if recent else '' - if wildcard_default is not None: - reference = reference.replace('?', wildcard_default) - return reference @classmethod diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 7ee33fde46fa..fb57bfa67db1 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -1981,15 +1981,6 @@ def save(self, *args, **kwargs): 'default': 'PO-{ref:04d}', 'validator': order.validators.validate_purchase_order_reference_pattern, }, - 'PURCHASEORDER_REFERENCE_PATTERN_WILDCARD_DEFAULT': { - 'name': _('Purchase Order Reference Pattern Wildcard Default'), - 'description': _( - 'Replace all wildcard ? characters in reference pattern with a default value' - ), - 'default': None, - # TODO: add validator - 'validator': lambda x: x, - }, 'PURCHASEORDER_REQUIRE_RESPONSIBLE': { 'name': _('Require Responsible Owner'), 'description': _('A responsible owner must be assigned to each order'), diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index e27613588f52..857897471111 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -378,9 +378,6 @@ class PurchaseOrder(TotalPriceMixin, Order): """ REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN' - REFERENCE_PATTERN_WILDCARD_DEFAULT_SETTING = ( - 'PURCHASEORDER_REFERENCE_PATTERN_WILDCARD_DEFAULT' - ) REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE' class Meta: diff --git a/src/backend/InvenTree/templates/InvenTree/settings/po.html b/src/backend/InvenTree/templates/InvenTree/settings/po.html index ea12dcd3bfaa..6759d14bf993 100644 --- a/src/backend/InvenTree/templates/InvenTree/settings/po.html +++ b/src/backend/InvenTree/templates/InvenTree/settings/po.html @@ -11,7 +11,6 @@
{% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PATTERN" %} - {% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PATTERN_WILDCARD_DEFAULT" %} {% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REQUIRE_RESPONSIBLE" %} {% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_EDIT_COMPLETED_ORDERS" icon='fa-edit' %} {% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_AUTO_COMPLETE" icon='fa-check-circle' %} From 6bbbe7380b1449d1da182455c0baf8ce7fb1b0f4 Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Thu, 21 Nov 2024 18:23:44 +0000 Subject: [PATCH 3/5] use custom format spec for "wildcard with default" reference pattern --- src/backend/InvenTree/InvenTree/format.py | 4 ++++ src/backend/InvenTree/InvenTree/models.py | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/format.py b/src/backend/InvenTree/InvenTree/format.py index 4d927221679b..c2d6fbdf44d4 100644 --- a/src/backend/InvenTree/InvenTree/format.py +++ b/src/backend/InvenTree/InvenTree/format.py @@ -113,6 +113,10 @@ def construct_format_regex(fmt_string: str) -> str: # TODO: Introspect required width w = '+' + # replace invalid regex group name '?' with a valid name + if name == '?': + name = 'wild' + pattern += f'(?P<{name}>{c}{w})' pattern += '$' diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 8b465222b20a..bad2f1589760 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -2,6 +2,7 @@ import logging from datetime import datetime +from string import Formatter from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType @@ -315,8 +316,9 @@ def get_reference_context(cls): - Returns a python dict object which contains the context data for formatting the reference string. - The default implementation provides some default context information + - The '?' key is required to accept our wildcard-with-default syntax {?:default} """ - return {'ref': cls.get_next_reference(), 'date': datetime.now()} + return {'ref': cls.get_next_reference(), 'date': datetime.now(), '?': '?'} @classmethod def get_most_recent_item(cls): @@ -363,8 +365,18 @@ def get_next_reference(cls): @classmethod def generate_reference(cls): """Generate the next 'reference' field based on specified pattern.""" - fmt = cls.get_reference_pattern() + + # Based on https://stackoverflow.com/a/57570269/14488558 + class ReferenceFormatter(Formatter): + def format_field(self, value, format_spec): + if isinstance(value, str) and value == '?': + value = format_spec + format_spec = '' + return super().format_field(value, format_spec) + + ref_ptn = cls.get_reference_pattern() ctx = cls.get_reference_context() + fmt = ReferenceFormatter() reference = None @@ -372,7 +384,7 @@ def generate_reference(cls): while reference is None: try: - ref = fmt.format(**ctx) + ref = fmt.format(ref_ptn, **ctx) if ref in attempts: # We are stuck in a loop! From 8ada566566446bf6aa182696c80d5671f45f227a Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Thu, 21 Nov 2024 19:02:09 +0000 Subject: [PATCH 4/5] add wildcard with default to docs --- docs/docs/settings/reference.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/docs/settings/reference.md b/docs/docs/settings/reference.md index 261ff2ec10ac..9b5e73f7d9b6 100644 --- a/docs/docs/settings/reference.md +++ b/docs/docs/settings/reference.md @@ -32,8 +32,9 @@ When building a reference, the following variables are available for use: | Variable | Description | | --- | --- | -| `{% raw %}{ref}{% endraw %}` | Incrementing portion of the reference (**required*)). Determines which part of the reference field auto-increments | +| `{% raw %}{ref}{% endraw %}` | Incrementing portion of the reference (**required*). Determines which part of the reference field auto-increments | | `{% raw %}{date}{% endraw %}` | The current date / time. This is a [Python datetime object](https://docs.python.org/3/library/datetime.html#datetime.datetime.now) | +| `{% raw %}{?:default}{% endraw %}` | A wildcard *with default*. Any character(s) will be accepted in this position, but the reference pattern suggests the character(s) specified. | The reference field pattern uses Python string formatting for value substitution. @@ -44,8 +45,9 @@ The reference field pattern uses PO-00123-B | | `{% raw %}PO-{ref:05d}-{date:%Y-%m-%d}{% endraw %}` | Render the *date* variable in isoformat | PO-00123-2023-01-17 | From b1fc8dcd5b7306eacb8ccd679f7aab7bfef4d63d Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Thu, 21 Nov 2024 20:28:03 +0000 Subject: [PATCH 5/5] add test for wildcard with default --- src/backend/InvenTree/order/test_api.py | 45 +++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index d3f5caa00b8a..7de88dcf95d6 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -2,6 +2,7 @@ import base64 import io +import json from datetime import datetime, timedelta from django.core.exceptions import ValidationError @@ -15,6 +16,7 @@ from common.currency import currency_codes from common.models import InvenTreeSetting +from common.settings import set_global_setting from company.models import Company, SupplierPart, SupplierPriceBreak from InvenTree.unit_test import InvenTreeAPITestCase from order import models @@ -256,6 +258,49 @@ def test_po_reference(self): self.assertEqual(order.reference, 'PO-92233720368547758089999999999999999') self.assertEqual(order.reference_int, 0x7FFFFFFF) + def test_po_reference_wildcard_default(self): + """Test that a reference with a wildcard default.""" + # get permissions + self.assignRole('purchase_order.add') + + # set PO reference setting + set_global_setting('PURCHASEORDER_REFERENCE_PATTERN', '{?:PO}-{ref:04d}') + + url = reverse('api-po-list') + + # first, check that the default character is suggested by OPTIONS + options = json.loads(self.options(url).content) + suggested_reference = options['actions']['POST']['reference']['default'] + self.assertTrue(suggested_reference.startswith('PO-')) + + # next, check that certain variations of a provided reference are accepted + test_accepted_references = ['PO-9991', 'P-9992', 'T-9993', 'ABC-9994'] + for ref in test_accepted_references: + response = self.post( + url, + { + 'supplier': 1, + 'reference': ref, + 'description': 'PO created via the API', + }, + expected_code=201, + ) + order = models.PurchaseOrder.objects.get(pk=response.data['pk']) + self.assertEqual(order.reference, ref) + + # finally, check that certain provided referencees are rejected (because the wildcard character is required!) + test_rejected_references = ['9995', '-9996'] + for ref in test_rejected_references: + response = self.post( + url, + { + 'supplier': 1, + 'reference': ref, + 'description': 'PO created via the API', + }, + expected_code=400, + ) + def test_po_attachments(self): """Test the list endpoint for the PurchaseOrderAttachment model.""" url = reverse('api-attachment-list')