Skip to content

Commit 648faf4

Browse files
Reference fields (inventree#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
1 parent 6133c74 commit 648faf4

45 files changed

Lines changed: 1166 additions & 294 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

InvenTree/InvenTree/api_tester.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,12 @@ def post(self, url, data=None, expected_code=None, format='json'):
133133
if expected_code is not None:
134134

135135
if response.status_code != expected_code:
136-
print(f"Unexpected response at '{url}':")
137-
print(response.data)
136+
print(f"Unexpected response at '{url}': status code = {response.status_code}")
137+
138+
if hasattr(response, 'data'):
139+
print(response.data)
140+
else:
141+
print(f"(response object {type(response)} has no 'data' attribute")
138142

139143
self.assertEqual(response.status_code, expected_code)
140144

InvenTree/InvenTree/format.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""Custom string formatting functions and helpers"""
2+
3+
import re
4+
import string
5+
6+
from django.utils.translation import gettext_lazy as _
7+
8+
9+
def parse_format_string(fmt_string: str) -> dict:
10+
"""Extract formatting information from the provided format string.
11+
12+
Returns a dict object which contains structured information about the format groups
13+
"""
14+
15+
groups = string.Formatter().parse(fmt_string)
16+
17+
info = {}
18+
19+
for group in groups:
20+
# Skip any group which does not have a named value
21+
if not group[1]:
22+
continue
23+
24+
info[group[1]] = {
25+
'format': group[1],
26+
'prefix': group[0],
27+
}
28+
29+
return info
30+
31+
32+
def construct_format_regex(fmt_string: str) -> str:
33+
r"""Construct a regular expression based on a provided format string
34+
35+
This function turns a python format string into a regular expression,
36+
which can be used for two purposes:
37+
38+
- Ensure that a particular string matches the specified format
39+
- Extract named variables from a matching string
40+
41+
This function also provides support for wildcard characters:
42+
43+
- '?' provides single character matching; is converted to a '.' (period) for regex
44+
- '#' provides single digit matching; is converted to '\d'
45+
46+
Args:
47+
fmt_string: A typical format string e.g. "PO-???-{ref:04d}"
48+
49+
Returns:
50+
str: A regular expression pattern e.g. ^PO\-...\-(?P<ref>.*)$
51+
52+
Raises:
53+
ValueError: Format string is invalid
54+
"""
55+
56+
pattern = "^"
57+
58+
for group in string.Formatter().parse(fmt_string):
59+
prefix = group[0] # Prefix (literal text appearing before this group)
60+
name = group[1] # Name of this format variable
61+
format = group[2] # Format specifier e.g :04d
62+
63+
rep = [
64+
'+', '-', '.',
65+
'{', '}', '(', ')',
66+
'^', '$', '~', '!', '@', ':', ';', '|', '\'', '"',
67+
]
68+
69+
# Escape any special regex characters
70+
for ch in rep:
71+
prefix = prefix.replace(ch, '\\' + ch)
72+
73+
# Replace ? with single-character match
74+
prefix = prefix.replace('?', '.')
75+
76+
# Replace # with single-digit match
77+
prefix = prefix.replace('#', r'\d')
78+
79+
pattern += prefix
80+
81+
# Add a named capture group for the format entry
82+
if name:
83+
84+
# Check if integer values are requried
85+
if format.endswith('d'):
86+
chr = '\d'
87+
else:
88+
chr = '.'
89+
90+
# Specify width
91+
# TODO: Introspect required width
92+
w = '+'
93+
94+
pattern += f"(?P<{name}>{chr}{w})"
95+
96+
pattern += "$"
97+
98+
return pattern
99+
100+
101+
def validate_string(value: str, fmt_string: str) -> str:
102+
"""Validate that the provided string matches the specified format.
103+
104+
Args:
105+
value: The string to be tested e.g. 'SO-1234-ABC',
106+
fmt_string: The required format e.g. 'SO-{ref}-???',
107+
108+
Returns:
109+
bool: True if the value matches the required format, else False
110+
111+
Raises:
112+
ValueError: The provided format string is invalid
113+
"""
114+
115+
pattern = construct_format_regex(fmt_string)
116+
117+
result = re.match(pattern, value)
118+
119+
return result is not None
120+
121+
122+
def extract_named_group(name: str, value: str, fmt_string: str) -> str:
123+
"""Extract a named value from the provided string, given the provided format string
124+
125+
Args:
126+
name: Name of group to extract e.g. 'ref'
127+
value: Raw string e.g. 'PO-ABC-1234'
128+
fmt_string: Format pattern e.g. 'PO-???-{ref}
129+
130+
Returns:
131+
str: String value of the named group
132+
133+
Raises:
134+
ValueError: format string is incorrectly specified, or provided value does not match format string
135+
NameError: named value does not exist in the format string
136+
IndexError: named value could not be found in the provided entry
137+
"""
138+
139+
info = parse_format_string(fmt_string)
140+
141+
if name not in info.keys():
142+
raise NameError(_(f"Value '{name}' does not appear in pattern format"))
143+
144+
# Construct a regular expression for matching against the provided format string
145+
# Note: This will raise a ValueError if 'fmt_string' is incorrectly specified
146+
pattern = construct_format_regex(fmt_string)
147+
148+
# Run the regex matcher against the raw string
149+
result = re.match(pattern, value)
150+
151+
if not result:
152+
raise ValueError(_("Provided value does not match required pattern: ") + fmt_string)
153+
154+
# And return the value we are interested in
155+
# Note: This will raise an IndexError if the named group was not matched
156+
return result.group(name)

0 commit comments

Comments
 (0)