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 |
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!
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')