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
This commit is contained in:
Oliver 2022-07-11 00:01:46 +10:00 committed by GitHub
parent 6133c745d7
commit 648faf4ed2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1166 additions and 294 deletions

View File

@ -133,8 +133,12 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
if expected_code is not None: if expected_code is not None:
if response.status_code != expected_code: if response.status_code != expected_code:
print(f"Unexpected response at '{url}':") print(f"Unexpected response at '{url}': status code = {response.status_code}")
print(response.data)
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) self.assertEqual(response.status_code, expected_code)

View File

@ -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<ref>.*)$
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)

View File

@ -3,6 +3,7 @@
import logging import logging
import os import os
import re import re
from datetime import datetime
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model 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.exceptions import InvalidMove
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
import InvenTree.format
import InvenTree.helpers import InvenTree.helpers
from common.models import InvenTreeSetting
from InvenTree.fields import InvenTreeURLField from InvenTree.fields import InvenTreeURLField
from InvenTree.validators import validate_tree_name from InvenTree.validators import validate_tree_name
@ -96,9 +99,6 @@ class DataImportMixin(object):
class ReferenceIndexingMixin(models.Model): class ReferenceIndexingMixin(models.Model):
"""A mixin for keeping track of numerical copies of the "reference" field. """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, Here, we attempt to convert a "reference" field value (char) to an integer,
for performing fast natural sorting. for performing fast natural sorting.
@ -112,24 +112,216 @@ class ReferenceIndexingMixin(models.Model):
- Otherwise, we store zero - 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: class Meta:
"""Metaclass options. Abstract ensures no database table is created.""" """Metaclass options. Abstract ensures no database table is created."""
abstract = True abstract = True
def rebuild_reference_field(self): @classmethod
"""Extract integer out of reference for sorting.""" def rebuild_reference_field(cls, reference, validate=False):
reference = getattr(self, 'reference', '') """Extract integer out of reference for sorting.
self.reference_int = extract_int(reference)
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) reference_int = models.BigIntegerField(default=0)
def extract_int(reference, clip=0x7fffffff): def extract_int(reference, clip=0x7fffffff, allow_negative=False):
"""Extract integer out of reference.""" """Extract an integer out of reference."""
# Default value if we cannot convert to an integer # Default value if we cannot convert to an integer
ref_int = 0 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"? # Look at the start of the string - can it be "integerized"?
result = re.match(r"^(\d+)", reference) result = re.match(r"^(\d+)", reference)
@ -139,6 +331,16 @@ def extract_int(reference, clip=0x7fffffff):
ref_int = int(ref) ref_int = int(ref)
except Exception: except Exception:
ref_int = 0 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 # 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" # Note: This will result in large values being "clipped"
@ -148,6 +350,9 @@ def extract_int(reference, clip=0x7fffffff):
elif ref_int < -clip: elif ref_int < -clip:
ref_int = -clip ref_int = -clip
if not allow_negative and ref_int < 0:
ref_int = abs(ref_int)
return ref_int return ref_int

View File

@ -7,7 +7,6 @@ from decimal import Decimal
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import tablib import tablib
@ -20,8 +19,6 @@ from rest_framework.fields import empty
from rest_framework.serializers import DecimalField from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta from rest_framework.utils import model_meta
from .models import extract_int
class InvenTreeMoneySerializer(MoneyField): class InvenTreeMoneySerializer(MoneyField):
"""Custom serializer for 'MoneyField', which ensures that passed values are numerically valid. """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): class InvenTreeAttachmentSerializerField(serializers.FileField):
"""Override the DRF native FileField serializer, to remove the leading server path. """Override the DRF native FileField serializer, to remove the leading server path.

View File

@ -17,6 +17,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import Rate, convert_money from djmoney.contrib.exchange.models import Rate, convert_money
from djmoney.money import Money from djmoney.money import Money
import InvenTree.format
import InvenTree.tasks import InvenTree.tasks
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from common.settings import currency_codes from common.settings import currency_codes
@ -60,6 +61,137 @@ class ValidatorTest(TestCase):
validate_overage("aaaa") 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<ref>.+)$",
"{PO}-???-{ref}-{date}-22": r"^(?P<PO>.+)\-...\-(?P<ref>.+)\-(?P<date>.+)\-22$",
"ABC-123-###-{ref}": r"^ABC\-123\-\d\d\d\-(?P<ref>.+)$",
"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): class TestHelpers(TestCase):
"""Tests for InvenTree helper functions.""" """Tests for InvenTree helper functions."""

View File

@ -57,17 +57,6 @@ def validate_part_ipn(value):
raise ValidationError(_('IPN must match regex pattern {pat}').format(pat=pattern)) 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): def validate_purchase_order_reference(value):
"""Validate the 'reference' field of a PurchaseOrder.""" """Validate the 'reference' field of a PurchaseOrder."""
pattern = common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_REGEX') pattern = common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_REGEX')

View File

@ -5,7 +5,7 @@
fields: fields:
part: 100 # Build against part 100 "Bob" part: 100 # Build against part 100 "Bob"
batch: 'B1' batch: 'B1'
reference: "0001" reference: "BO-0001"
title: 'Building 7 parts' title: 'Building 7 parts'
quantity: 7 quantity: 7
notes: 'Some simple notes' notes: 'Some simple notes'
@ -21,7 +21,7 @@
pk: 2 pk: 2
fields: fields:
part: 50 part: 50
reference: "0002" reference: "BO-0002"
title: 'Making things' title: 'Making things'
batch: 'B2' batch: 'B2'
status: 40 # COMPLETE status: 40 # COMPLETE
@ -37,7 +37,7 @@
pk: 3 pk: 3
fields: fields:
part: 50 part: 50
reference: "0003" reference: "BO-003"
title: 'Making things' title: 'Making things'
batch: 'B2' batch: 'B2'
status: 40 # COMPLETE status: 40 # COMPLETE
@ -53,7 +53,7 @@
pk: 4 pk: 4
fields: fields:
part: 50 part: 50
reference: "0004" reference: "BO-4"
title: 'Making things' title: 'Making things'
batch: 'B4' batch: 'B4'
status: 40 # COMPLETE status: 40 # COMPLETE
@ -69,7 +69,7 @@
pk: 5 pk: 5
fields: fields:
part: 25 part: 25
reference: "0005" reference: "BO-0005"
title: "Building some Widgets" title: "Building some Widgets"
batch: "B10" batch: "B10"
status: 40 # Complete status: 40 # Complete

View File

@ -1,6 +1,6 @@
# Generated by Django 3.0.7 on 2020-10-19 13:02 # Generated by Django 3.0.7 on 2020-10-19 13:02
import InvenTree.validators import build.validators
from django.db import migrations, models from django.db import migrations, models
@ -18,6 +18,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='build', model_name='build',
name='reference', 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'),
), ),
] ]

View File

@ -1,6 +1,6 @@
# Generated by Django 3.2.4 on 2021-07-08 14:14 # Generated by Django 3.2.4 on 2021-07-08 14:14
import InvenTree.validators import build.validators
import build.models import build.models
from django.db import migrations, models from django.db import migrations, models
@ -15,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='build', model_name='build',
name='reference', 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'),
), ),
] ]

View File

@ -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,
)
]

View File

@ -22,12 +22,14 @@ from mptt.exceptions import InvalidMove
from rest_framework import serializers from rest_framework import serializers
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode 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.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.fields
import InvenTree.helpers import InvenTree.helpers
import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
from plugin.events import trigger_event from plugin.events import trigger_event
@ -38,32 +40,6 @@ from stock import models as StockModels
from users import models as UserModels 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): class Build(MPTTModel, ReferenceIndexingMixin):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects. """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()) 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 @staticmethod
def get_api_url(): def get_api_url():
"""Return the API URL associated with the BuildOrder model""" """Return the API URL associated with the BuildOrder model"""
@ -106,7 +85,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
def api_defaults(cls, request): def api_defaults(cls, request):
"""Return default values for this model when issuing an API OPTIONS request.""" """Return default values for this model when issuing an API OPTIONS request."""
defaults = { defaults = {
'reference': get_next_build_number(), 'reference': generate_next_build_reference(),
} }
if request and request.user: if request and request.user:
@ -116,7 +95,8 @@ class Build(MPTTModel, ReferenceIndexingMixin):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Custom save method for the BuildOrder model""" """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: try:
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -172,9 +152,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
def __str__(self): def __str__(self):
"""String representation of a BuildOrder""" """String representation of a BuildOrder"""
prefix = getSetting("BUILDORDER_REFERENCE_PREFIX") return self.reference
return f"{prefix}{self.reference}"
def get_absolute_url(self): def get_absolute_url(self):
"""Return the web URL associated with this BuildOrder""" """Return the web URL associated with this BuildOrder"""
@ -186,9 +164,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
blank=False, blank=False,
help_text=_('Build Order Reference'), help_text=_('Build Order Reference'),
verbose_name=_('Reference'), verbose_name=_('Reference'),
default=get_next_build_number, default=generate_next_build_reference,
validators=[ validators=[
validate_build_order_reference validate_build_order_reference,
] ]
) )
@ -199,7 +177,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
help_text=_('Brief description of the build') help_text=_('Brief description of the build')
) )
# TODO - Perhaps delete the build "tree"
parent = TreeForeignKey( parent = TreeForeignKey(
'self', 'self',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -1092,6 +1069,10 @@ class Build(MPTTModel, ReferenceIndexingMixin):
@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log') @receiver(post_save, sender=Build, dispatch_uid='build_post_save_log')
def after_save_build(sender, instance: Build, created: bool, **kwargs): def after_save_build(sender, instance: Build, created: bool, **kwargs):
"""Callback function to be executed after a Build instance is saved.""" """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 from . import tasks as build_tasks
if created: if created:

View File

@ -11,7 +11,7 @@ from rest_framework import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import ReferenceIndexingSerializerMixin, UserSerializer from InvenTree.serializers import UserSerializer
import InvenTree.helpers import InvenTree.helpers
from InvenTree.helpers import extract_serial_numbers from InvenTree.helpers import extract_serial_numbers
@ -28,7 +28,7 @@ from users.serializers import OwnerSerializer
from .models import Build, BuildItem, BuildOrderAttachment from .models import Build, BuildItem, BuildOrderAttachment
class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer): class BuildSerializer(InvenTreeModelSerializer):
"""Serializes a Build object.""" """Serializes a Build object."""
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)
@ -74,6 +74,16 @@ class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer
if part_detail is not True: if part_detail is not True:
self.fields.pop('part_detail') 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: class Meta:
"""Serializer metaclass""" """Serializer metaclass"""
model = Build model = Build

View File

@ -748,6 +748,7 @@ class BuildListTest(BuildAPITest):
Build.objects.create( Build.objects.create(
part=part, part=part,
reference="BO-0006",
quantity=10, quantity=10,
title='Just some thing', title='Just some thing',
status=BuildStatus.PRODUCTION, status=BuildStatus.PRODUCTION,
@ -773,20 +774,23 @@ class BuildListTest(BuildAPITest):
Build.objects.create( Build.objects.create(
part=part, part=part,
quantity=10, quantity=10,
reference=f"build-000{i}", reference=f"BO-{i + 10}",
title=f"Sub build {i}", title=f"Sub build {i}",
parent=parent parent=parent
) )
# And some sub-sub builds # 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): for i in range(3):
x = ii * 10 + i + 50
Build.objects.create( Build.objects.create(
part=part, part=part,
reference=f"{sub_build.reference}-00{i}-sub", reference=f"BO-{x}",
title=f"{sub_build.reference}-00{i}-sub",
quantity=40, quantity=40,
title=f"sub sub build {i}",
parent=sub_build parent=sub_build
) )

View File

@ -12,7 +12,7 @@ from InvenTree import status_codes as status
import common.models import common.models
import build.tasks 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 part.models import Part, BomItem, BomItemSubstitute
from stock.models import StockItem from stock.models import StockItem
from users.models import Owner from users.models import Owner
@ -88,7 +88,7 @@ class BuildTestBase(TestCase):
quantity=2 quantity=2
) )
ref = get_next_build_number() ref = generate_next_build_reference()
# Create a "Build" object to make 10x objects # Create a "Build" object to make 10x objects
self.build = Build.objects.create( self.build = Build.objects.create(
@ -133,20 +133,97 @@ class BuildTest(BuildTestBase):
def test_ref_int(self): def test_ref_int(self):
"""Test the "integer reference" field used for natural sorting""" """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( build = Build(
reference=f"{ii}_abcde", reference=ref,
quantity=1, quantity=1,
part=self.assembly, part=self.assembly,
title="Making some parts" title='Making some parts',
) )
self.assertEqual(build.reference_int, 0) self.assertEqual(build.reference_int, 0)
build.save() build.save()
self.assertEqual(build.reference_int, ref_int)
# After saving, the integer reference should have been updated def test_ref_validation(self):
self.assertEqual(build.reference_int, ii) """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): def test_init(self):
"""Perform some basic tests before we start the ball rolling""" """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""" """Test that a notification is sent when a new build is created"""
Build.objects.create( Build.objects.create(
reference='IIIII', reference='BO-9999',
title='Some new build', title='Some new build',
part=self.assembly, part=self.assembly,
quantity=5, quantity=5,

View File

@ -104,3 +104,57 @@ class TestReferenceMigration(MigratorTestCase):
# Check that the build reference is properly assigned # Check that the build reference is properly assigned
for build in Build.objects.all(): for build in Build.objects.all():
self.assertEqual(str(build.reference), str(build.pk)) 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}')

View File

@ -35,7 +35,7 @@ class BuildTestSimple(InvenTreeTestCase):
self.assertEqual(b.batch, 'B2') self.assertEqual(b.batch, 'B2')
self.assertEqual(b.quantity, 21) self.assertEqual(b.quantity, 21)
self.assertEqual(str(b), 'BO0002') self.assertEqual(str(b), 'BO-0002')
def test_url(self): def test_url(self):
"""Test URL lookup""" """Test URL lookup"""
@ -75,11 +75,6 @@ class BuildTestSimple(InvenTreeTestCase):
self.assertEqual(b1.is_active, True) self.assertEqual(b1.is_active, True)
self.assertEqual(b2.is_active, False) 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): def test_cancel_build(self):
"""Test build cancellation function.""" """Test build cancellation function."""
build = Build.objects.get(id=1) build = Build.objects.get(id=1)

View File

@ -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)

View File

@ -36,10 +36,12 @@ from djmoney.contrib.exchange.models import convert_money
from djmoney.settings import CURRENCY_CHOICES from djmoney.settings import CURRENCY_CHOICES
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
import build.validators
import InvenTree.fields import InvenTree.fields
import InvenTree.helpers import InvenTree.helpers
import InvenTree.ready import InvenTree.ready
import InvenTree.validators import InvenTree.validators
import order.validators
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -1139,21 +1141,18 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'BUILDORDER_REFERENCE_PREFIX': { 'BUILDORDER_REFERENCE_PATTERN': {
'name': _('Build Order Reference Prefix'), 'name': _('Build Order Reference Pattern'),
'description': _('Prefix value for build order reference'), 'description': _('Required pattern for generating Build Order reference field'),
'default': 'BO', 'default': 'BO-{ref:04d}',
'validator': build.validators.validate_build_order_reference_pattern,
}, },
'BUILDORDER_REFERENCE_REGEX': { 'SALESORDER_REFERENCE_PATTERN': {
'name': _('Build Order Reference Regex'), 'name': _('Sales Order Reference Pattern'),
'description': _('Regular expression pattern for matching build order reference') 'description': _('Required pattern for generating Sales Order reference field'),
}, 'default': 'SO-{ref:04d}',
'validator': order.validators.validate_sales_order_reference_pattern,
'SALESORDER_REFERENCE_PREFIX': {
'name': _('Sales Order Reference Prefix'),
'description': _('Prefix value for sales order reference'),
'default': 'SO',
}, },
'SALESORDER_DEFAULT_SHIPMENT': { 'SALESORDER_DEFAULT_SHIPMENT': {
@ -1163,10 +1162,11 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'PURCHASEORDER_REFERENCE_PREFIX': { 'PURCHASEORDER_REFERENCE_PATTERN': {
'name': _('Purchase Order Reference Prefix'), 'name': _('Purchase Order Reference Pattern'),
'description': _('Prefix value for purchase order reference'), 'description': _('Required pattern for generating Purchase Order reference field'),
'default': 'PO', 'default': 'PO-{ref:04d}',
'validator': order.validators.validate_purchase_order_reference_pattern,
}, },
# login / SSO # login / SSO

View File

@ -4,7 +4,7 @@
- model: order.purchaseorder - model: order.purchaseorder
pk: 1 pk: 1
fields: fields:
reference: '0001' reference: 'PO-0001'
description: "Ordering some screws" description: "Ordering some screws"
supplier: 1 supplier: 1
status: 10 # Pending status: 10 # Pending
@ -13,7 +13,7 @@
- model: order.purchaseorder - model: order.purchaseorder
pk: 2 pk: 2
fields: fields:
reference: '0002' reference: 'PO-0002'
description: "Ordering some more screws" description: "Ordering some more screws"
supplier: 3 supplier: 3
status: 10 # Pending status: 10 # Pending
@ -21,7 +21,7 @@
- model: order.purchaseorder - model: order.purchaseorder
pk: 3 pk: 3
fields: fields:
reference: '0003' reference: 'PO-0003'
description: 'Another PO' description: 'Another PO'
supplier: 3 supplier: 3
status: 20 # Placed status: 20 # Placed
@ -29,7 +29,7 @@
- model: order.purchaseorder - model: order.purchaseorder
pk: 4 pk: 4
fields: fields:
reference: '0004' reference: 'PO-0004'
description: 'Another PO' description: 'Another PO'
supplier: 3 supplier: 3
status: 20 # Placed status: 20 # Placed
@ -37,7 +37,7 @@
- model: order.purchaseorder - model: order.purchaseorder
pk: 5 pk: 5
fields: fields:
reference: '0005' reference: 'PO-0005'
description: 'Another PO' description: 'Another PO'
supplier: 3 supplier: 3
status: 30 # Complete status: 30 # Complete
@ -45,7 +45,7 @@
- model: order.purchaseorder - model: order.purchaseorder
pk: 6 pk: 6
fields: fields:
reference: '0006' reference: 'PO-0006'
description: 'Another PO' description: 'Another PO'
supplier: 3 supplier: 3
status: 40 # Cancelled status: 40 # Cancelled
@ -54,7 +54,7 @@
- model: order.purchaseorder - model: order.purchaseorder
pk: 7 pk: 7
fields: fields:
reference: '0007' reference: 'PO-0007'
description: 'Another PO' description: 'Another PO'
supplier: 2 supplier: 2
status: 10 # Pending status: 10 # Pending

View File

@ -1,7 +1,6 @@
# Generated by Django 3.2.4 on 2021-07-02 13:21 # Generated by Django 3.2.4 on 2021-07-02 13:21
from django.db import migrations, models from django.db import migrations, models
import order.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -14,11 +13,11 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='purchaseorder', model_name='purchaseorder',
name='reference', 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( migrations.AlterField(
model_name='salesorder', model_name='salesorder',
name='reference', 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'),
), ),
] ]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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,
)
]

View File

@ -24,14 +24,14 @@ from mptt.models import TreeForeignKey
import InvenTree.helpers import InvenTree.helpers
import InvenTree.ready import InvenTree.ready
import order.validators
from common.notifications import InvenTreeNotificationBodies from common.notifications import InvenTreeNotificationBodies
from common.settings import currency_code_default from common.settings import currency_code_default
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from InvenTree.exceptions import log_error from InvenTree.exceptions import log_error
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField, from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
RoundingDecimalField) RoundingDecimalField)
from InvenTree.helpers import (decimal2string, getSetting, increment, from InvenTree.helpers import decimal2string, getSetting, notify_responsible
notify_responsible)
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus, from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
StockHistoryCode, StockStatus) StockHistoryCode, StockStatus)
@ -44,58 +44,6 @@ from users import models as UserModels
logger = logging.getLogger('inventree') 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): class Order(MetadataMixin, ReferenceIndexingMixin):
"""Abstract model for an order. """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. 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: if not self.creation_date:
self.creation_date = datetime.now().date() self.creation_date = datetime.now().date()
@ -230,8 +178,21 @@ class PurchaseOrder(Order):
"""Return the API URL associated with the PurchaseOrder model""" """Return the API URL associated with the PurchaseOrder model"""
return reverse('api-po-list') 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()) 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 @staticmethod
def filterByDate(queryset, min_date, max_date): def filterByDate(queryset, min_date, max_date):
"""Filter by 'minimum and maximum date range'. """Filter by 'minimum and maximum date range'.
@ -269,9 +230,8 @@ class PurchaseOrder(Order):
def __str__(self): def __str__(self):
"""Render a string representation of this PurchaseOrder""" """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( reference = models.CharField(
unique=True, unique=True,
@ -279,7 +239,10 @@ class PurchaseOrder(Order):
blank=False, blank=False,
verbose_name=_('Reference'), verbose_name=_('Reference'),
help_text=_('Order 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(), 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 the API URL associated with the SalesOrder model"""
return reverse('api-so-list') 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()) 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 @staticmethod
def filterByDate(queryset, min_date, max_date): def filterByDate(queryset, min_date, max_date):
"""Filter by "minimum and maximum date range". """Filter by "minimum and maximum date range".
@ -634,9 +609,8 @@ class SalesOrder(Order):
def __str__(self): def __str__(self):
"""Render a string representation of this SalesOrder""" """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): def get_absolute_url(self):
"""Return the web URL for the detail view of this order""" """Return the web URL for the detail view of this order"""
@ -648,7 +622,10 @@ class SalesOrder(Order):
blank=False, blank=False,
verbose_name=_('Reference'), verbose_name=_('Reference'),
help_text=_('Order 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( customer = models.ForeignKey(

View File

@ -23,8 +23,7 @@ from InvenTree.helpers import extract_serial_numbers, normalize
from InvenTree.serializers import (InvenTreeAttachmentSerializer, from InvenTree.serializers import (InvenTreeAttachmentSerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeMoneySerializer, InvenTreeMoneySerializer)
ReferenceIndexingSerializerMixin)
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus, from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
StockStatus) StockStatus)
from part.serializers import PartBriefSerializer 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.""" """Serializer for a PurchaseOrder object."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -130,6 +129,14 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializ
reference = serializers.CharField(required=True) 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) responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
class Meta: class Meta:
@ -639,7 +646,7 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
] ]
class SalesOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerMixin, InvenTreeModelSerializer): class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
"""Serializers for the SalesOrder object.""" """Serializers for the SalesOrder object."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -683,6 +690,14 @@ class SalesOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerM
reference = serializers.CharField(required=True) 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: class Meta:
"""Metaclass options.""" """Metaclass options."""

View File

@ -82,7 +82,7 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td> <td>{% trans "Order Reference" %}</td>
<td>{% settings_value 'PURCHASEORDER_REFERENCE_PREFIX' %}{{ order.reference }}{% include "clip.html"%}</td> <td>{{ order.reference }}{% include "clip.html"%}</td>
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-info-circle'></span></td> <td><span class='fas fa-info-circle'></span></td>
@ -222,7 +222,7 @@ $("#edit-order").click(function() {
constructForm('{% url "api-po-detail" order.pk %}', { constructForm('{% url "api-po-detail" order.pk %}', {
fields: { fields: {
reference: { reference: {
prefix: global_settings.PURCHASEORDER_REFERENCE_PREFIX, icon: 'fa-hashtag',
}, },
{% if order.lines.count == 0 and order.status == PurchaseOrderStatus.PENDING %} {% if order.lines.count == 0 and order.status == PurchaseOrderStatus.PENDING %}
supplier: { supplier: {

View File

@ -78,7 +78,7 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td> <td>{% trans "Order Reference" %}</td>
<td>{% settings_value 'SALESORDER_REFERENCE_PREFIX' %}{{ order.reference }}{% include "clip.html"%}</td> <td>{{ order.reference }}{% include "clip.html"%}</td>
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-info-circle'></span></td> <td><span class='fas fa-info-circle'></span></td>
@ -209,7 +209,7 @@ $("#edit-order").click(function() {
constructForm('{% url "api-so-detail" order.pk %}', { constructForm('{% url "api-so-detail" order.pk %}', {
fields: { fields: {
reference: { reference: {
prefix: global_settings.SALESORDER_REFERENCE_PREFIX, icon: 'fa-hashtag',
}, },
{% if order.lines.count == 0 and order.status == SalesOrderStatus.PENDING %} {% if order.lines.count == 0 and order.status == SalesOrderStatus.PENDING %}
customer: { customer: {

View File

@ -94,23 +94,28 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(data['description'], 'Ordering some screws') self.assertEqual(data['description'], 'Ordering some screws')
def test_po_reference(self): 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 # get permissions
self.assignRole('purchase_order.add') self.assignRole('purchase_order.add')
url = reverse('api-po-list') url = reverse('api-po-list')
huge_number = 9223372036854775808 huge_number = "PO-92233720368547758089999999999999999"
self.post( response = self.post(
url, url,
{ {
'supplier': 1, 'supplier': 1,
'reference': huge_number, 'reference': huge_number,
'description': 'PO not created via the API', 'description': 'PO created via the API',
}, },
expected_code=201, 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): def test_po_attachments(self):
"""Test the list endpoint for the PurchaseOrderAttachment model""" """Test the list endpoint for the PurchaseOrderAttachment model"""
url = reverse('api-po-attachment-list') url = reverse('api-po-attachment-list')
@ -149,7 +154,7 @@ class PurchaseOrderTest(OrderTest):
url, url,
{ {
'supplier': 1, 'supplier': 1,
'reference': '123456789-xyz', 'reference': 'PO-123456789',
'description': 'PO created via the API', 'description': 'PO created via the API',
}, },
expected_code=201 expected_code=201
@ -177,19 +182,19 @@ class PurchaseOrderTest(OrderTest):
# Get detail info! # Get detail info!
response = self.get(url) response = self.get(url)
self.assertEqual(response.data['pk'], pk) 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 # Try to alter (edit) the PurchaseOrder
response = self.patch( response = self.patch(
url, url,
{ {
'reference': '12345-abc', 'reference': 'PO-12345',
}, },
expected_code=200 expected_code=200
) )
# Reference should have changed # 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! # Now, let's try to delete it!
# Initially, we do *not* have the required permission! # Initially, we do *not* have the required permission!
@ -213,7 +218,7 @@ class PurchaseOrderTest(OrderTest):
self.post( self.post(
reverse('api-po-list'), reverse('api-po-list'),
{ {
'reference': '12345678', 'reference': 'PO-12345678',
'supplier': 1, 'supplier': 1,
'description': 'A test purchase order', 'description': 'A test purchase order',
}, },
@ -807,7 +812,7 @@ class SalesOrderTest(OrderTest):
url, url,
{ {
'customer': 4, 'customer': 4,
'reference': '12345', 'reference': 'SO-12345',
'description': 'Sales order', 'description': 'Sales order',
}, },
expected_code=201 expected_code=201
@ -824,7 +829,7 @@ class SalesOrderTest(OrderTest):
url, url,
{ {
'customer': 4, 'customer': 4,
'reference': '12345', 'reference': 'SO-12345',
'description': 'Another sales order', 'description': 'Another sales order',
}, },
expected_code=400 expected_code=400
@ -834,19 +839,28 @@ class SalesOrderTest(OrderTest):
# Extract detail info for the SalesOrder # Extract detail info for the SalesOrder
response = self.get(url) response = self.get(url)
self.assertEqual(response.data['reference'], '12345') self.assertEqual(response.data['reference'], 'SO-12345')
# Try to alter (edit) the SalesOrder # Try to alter (edit) the SalesOrder
# Initially try with an invalid reference field value
response = self.patch( response = self.patch(
url, url,
{ {
'reference': '12345-a', 'reference': 'SO-12345-a',
},
expected_code=400
)
response = self.patch(
url,
{
'reference': 'SO-12346',
}, },
expected_code=200 expected_code=200
) )
# Reference should have changed # 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 # Now, let's try to delete this SalesOrder
# Initially, we do not have the required permission # 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.""" """Test that we can create a new SalesOrder via the API."""
self.assignRole('sales_order.add') self.assignRole('sales_order.add')
self.post( url = reverse('api-so-list')
reverse('api-so-list'),
# Will fail due to invalid reference field
response = self.post(
url,
{ {
'reference': '1234566778', 'reference': '1234566778',
'customer': 4, 'customer': 4,
'description': 'A test sales order', '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): def test_so_cancel(self):

View File

@ -40,19 +40,27 @@ class SalesOrderTest(TestCase):
# Create a SalesOrder to ship against # Create a SalesOrder to ship against
self.order = SalesOrder.objects.create( self.order = SalesOrder.objects.create(
customer=self.customer, customer=self.customer,
reference='1234', reference='SO-1234',
customer_reference='ABC 55555' customer_reference='ABC 55555'
) )
# Create a Shipment against this SalesOrder # Create a Shipment against this SalesOrder
self.shipment = SalesOrderShipment.objects.create( self.shipment = SalesOrderShipment.objects.create(
order=self.order, order=self.order,
reference='001', reference='SO-001',
) )
# Create a line item # Create a line item
self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part) 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): def test_rebuild_reference(self):
"""Test that the 'reference_int' field gets rebuilt when the model is saved""" """Test that the 'reference_int' field gets rebuilt when the model is saved"""

View File

@ -35,15 +35,17 @@ class OrderTest(TestCase):
def test_basics(self): def test_basics(self):
"""Basic tests e.g. repr functions etc.""" """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) line = PurchaseOrderLineItem.objects.get(pk=1)
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO-0001 - ACME)")
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO0001 - ACME)")
def test_rebuild_reference(self): def test_rebuild_reference(self):
"""Test that the reference_int field is correctly updated when the model is saved""" """Test that the reference_int field is correctly updated when the model is saved"""

View File

@ -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)

View File

@ -1514,6 +1514,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
part=Part.objects.get(pk=101), part=Part.objects.get(pk=101),
quantity=10, quantity=10,
title='Making some assemblies', title='Making some assemblies',
reference='BO-9999',
status=BuildStatus.PRODUCTION, status=BuildStatus.PRODUCTION,
) )

View File

@ -425,7 +425,6 @@ class PurchaseOrderReport(ReportTemplateBase):
'order': order, 'order': order,
'reference': order.reference, 'reference': order.reference,
'supplier': order.supplier, 'supplier': order.supplier,
'prefix': common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_PREFIX'),
'title': str(order), 'title': str(order),
} }
@ -463,7 +462,6 @@ class SalesOrderReport(ReportTemplateBase):
'lines': order.lines, 'lines': order.lines,
'extra_lines': order.extra_lines, 'extra_lines': order.extra_lines,
'order': order, 'order': order,
'prefix': common.models.InvenTreeSetting.get_setting('SALESORDER_REFERENCE_PREFIX'),
'reference': order.reference, 'reference': order.reference,
'title': str(order), 'title': str(order),
} }

View File

@ -30,8 +30,7 @@ import report.models
from company import models as CompanyModels from company import models as CompanyModels
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField, from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
InvenTreeURLField) InvenTreeURLField)
from InvenTree.models import InvenTreeAttachment, InvenTreeTree from InvenTree.models import InvenTreeAttachment, InvenTreeTree, extract_int
from InvenTree.serializers import extract_int
from InvenTree.status_codes import StockHistoryCode, StockStatus from InvenTree.status_codes import StockHistoryCode, StockStatus
from part import models as PartModels from part import models as PartModels
from plugin.events import trigger_event from plugin.events import trigger_event
@ -1708,8 +1707,7 @@ class StockItem(MetadataMixin, MPTTModel):
s += ' @ {loc}'.format(loc=self.location.name) s += ' @ {loc}'.format(loc=self.location.name)
if self.purchase_order: if self.purchase_order:
s += " ({pre}{po})".format( s += " ({po})".format(
pre=InvenTree.helpers.getSetting("PURCHASEORDER_REFERENCE_PREFIX"),
po=self.purchase_order, po=self.purchase_order,
) )

View File

@ -20,7 +20,8 @@ import InvenTree.serializers
import part.models as part_models import part.models as part_models
from common.settings import currency_code_default, currency_code_mappings from common.settings import currency_code_default, currency_code_mappings
from company.serializers import SupplierPartSerializer 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 part.serializers import PartBriefSerializer
from .models import (StockItem, StockItemAttachment, StockItemTestResult, from .models import (StockItem, StockItemAttachment, StockItemTestResult,
@ -67,8 +68,8 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
def validate_serial(self, value): def validate_serial(self, value):
"""Make sure serial is not to big.""" """Make sure serial is not to big."""
if extract_int(value) > 2147483647: if abs(extract_int(value)) > 0x7fffffff:
raise serializers.ValidationError('serial is to to big') raise serializers.ValidationError(_("Serial number is too large"))
return value return value

View File

@ -87,7 +87,7 @@ class StockTest(InvenTreeTestCase):
# And there should be *no* items being build # And there should be *no* items being build
self.assertEqual(part.quantity_being_built, 0) 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" # Add some stock items which are "building"
for _ in range(10): for _ in range(10):
@ -395,13 +395,14 @@ class StockTest(InvenTreeTestCase):
item.serial = "-123" item.serial = "-123"
item.save() item.save()
# Negative number should map to zero # Negative number should map to positive value
self.assertEqual(item.serial_int, 0) self.assertEqual(item.serial_int, 123)
# Test a very very large value # Test a very very large value
item.serial = '99999999999999999999999999999999999999999999999999999' item.serial = '99999999999999999999999999999999999999999999999999999'
item.save() item.save()
# The 'integer' portion has been clipped to a maximum value
self.assertEqual(item.serial_int, 0x7fffffff) self.assertEqual(item.serial_int, 0x7fffffff)
# Non-numeric values should encode to zero # Non-numeric values should encode to zero

View File

@ -12,8 +12,7 @@
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_PREFIX" %} {% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_PATTERN" %}
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_REGEX" %}
</tbody> </tbody>
</table> </table>

View File

@ -10,7 +10,7 @@
{% block content %} {% block content %}
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PREFIX" %} {% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PATTERN" %}
</tbody> </tbody>
</table> </table>
{% endblock %} {% endblock %}

View File

@ -11,7 +11,7 @@
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% 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" %} {% include "InvenTree/settings/setting.html" with key="SALESORDER_DEFAULT_SHIPMENT" icon="fa-truck-loading" %}
</tbody> </tbody>
</table> </table>

View File

@ -292,8 +292,8 @@ function exportBom(part_id, options={}) {
choices: exportFormatOptions(), choices: exportFormatOptions(),
}, },
cascade: { cascade: {
label: '{% trans "Cascading" %}', label: '{% trans "Multi Level BOM" %}',
help_text: '{% trans "Download cascading / multi-level BOM" %}', help_text: '{% trans "Include BOM data for subassemblies" %}',
type: 'boolean', type: 'boolean',
value: inventreeLoad('bom-export-cascading', true), 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)" %}', help_text: '{% trans "Select maximum number of BOM levels to export (0 = all levels)" %}',
type: 'integer', type: 'integer',
value: 0, value: 0,
required: true,
min_value: 0, min_value: 0,
}, },
parameter_data: { parameter_data: {

View File

@ -4,7 +4,6 @@
/* globals /* globals
buildStatusDisplay, buildStatusDisplay,
constructForm, constructForm,
global_settings,
imageHoverIcon, imageHoverIcon,
inventreeGet, inventreeGet,
launchModalForm, launchModalForm,
@ -36,7 +35,7 @@
function buildFormFields() { function buildFormFields() {
return { return {
reference: { reference: {
prefix: global_settings.BUILDORDER_REFERENCE_PREFIX, icon: 'fa-hashtag',
}, },
part: { part: {
filters: { filters: {
@ -731,9 +730,8 @@ function loadBuildOrderAllocationTable(table, options={}) {
switchable: false, switchable: false,
title: '{% trans "Build Order" %}', title: '{% trans "Build Order" %}',
formatter: function(value, row) { 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}/`); return renderLink(ref, `/build/${row.build}/`);
} }
@ -2372,7 +2370,6 @@ function loadBuildTable(table, options) {
filters, filters,
{ {
success: function(response) { success: function(response) {
var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX;
for (var idx = 0; idx < response.length; idx++) { for (var idx = 0; idx < response.length; idx++) {
@ -2386,7 +2383,7 @@ function loadBuildTable(table, options) {
date = order.target_date; date = order.target_date;
} }
var title = `${prefix}${order.reference}`; var title = `${order.reference}`;
var color = '#4c68f5'; var color = '#4c68f5';
@ -2460,12 +2457,6 @@ function loadBuildTable(table, options) {
switchable: true, switchable: true,
formatter: function(value, row) { formatter: function(value, row) {
var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX;
if (prefix) {
value = `${prefix}${value}`;
}
var html = renderLink(value, '/build/' + row.pk + '/'); var html = renderLink(value, '/build/' + row.pk + '/');
if (row.overdue) { if (row.overdue) {

View File

@ -255,8 +255,7 @@ function renderOwner(name, data, parameters={}, options={}) {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
function renderPurchaseOrder(name, data, parameters={}, options={}) { function renderPurchaseOrder(name, data, parameters={}, options={}) {
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX; var html = `<span>${data.reference}</span>`;
var html = `<span>${prefix}${data.reference}</span>`;
var thumbnail = null; var thumbnail = null;
@ -281,8 +280,7 @@ function renderPurchaseOrder(name, data, parameters={}, options={}) {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
function renderSalesOrder(name, data, parameters={}, options={}) { function renderSalesOrder(name, data, parameters={}, options={}) {
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX; var html = `<span>${data.reference}</span>`;
var html = `<span>${prefix}${data.reference}</span>`;
var thumbnail = null; var thumbnail = null;
@ -307,10 +305,8 @@ function renderSalesOrder(name, data, parameters={}, options={}) {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
function renderSalesOrderShipment(name, data, parameters={}, options={}) { function renderSalesOrderShipment(name, data, parameters={}, options={}) {
var so_prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
var html = ` var html = `
<span>${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference}</span> <span>${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference}</span>
<span class='float-right'> <span class='float-right'>
<small>{% trans "Shipment ID" %}: ${data.pk}</small> <small>{% trans "Shipment ID" %}: ${data.pk}</small>
</span> </span>

View File

@ -431,7 +431,7 @@ function createSalesOrderShipment(options={}) {
var fields = salesOrderShipmentFields(options); var fields = salesOrderShipmentFields(options);
fields.reference.value = ref; 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" %}', { constructForm('{% url "api-so-shipment-list" %}', {
method: 'POST', method: 'POST',
@ -456,7 +456,7 @@ function createSalesOrder(options={}) {
method: 'POST', method: 'POST',
fields: { fields: {
reference: { reference: {
prefix: global_settings.SALESORDER_REFERENCE_PREFIX, icon: 'fa-hashtag',
}, },
customer: { customer: {
value: options.customer, value: options.customer,
@ -497,7 +497,7 @@ function createPurchaseOrder(options={}) {
method: 'POST', method: 'POST',
fields: { fields: {
reference: { reference: {
prefix: global_settings.PURCHASEORDER_REFERENCE_PREFIX, icon: 'fa-hashtag',
}, },
supplier: { supplier: {
icon: 'fa-building', icon: 'fa-building',
@ -1081,9 +1081,7 @@ function newPurchaseOrderFromOrderWizard(e) {
}, },
{ {
success: function(response) { success: function(response) {
var text = global_settings.PURCHASEORDER_REFERENCE_PREFIX || ''; var text = response.reference;
text += response.reference;
if (response.supplier_detail) { if (response.supplier_detail) {
text += ` ${response.supplier_detail.name}`; text += ` ${response.supplier_detail.name}`;
@ -1545,8 +1543,6 @@ function loadPurchaseOrderTable(table, options) {
filters, filters,
{ {
success: function(response) { success: function(response) {
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
for (var idx = 0; idx < response.length; idx++) { for (var idx = 0; idx < response.length; idx++) {
var order = response[idx]; var order = response[idx];
@ -1559,7 +1555,7 @@ function loadPurchaseOrderTable(table, options) {
date = order.target_date; date = order.target_date;
} }
var title = `${prefix}${order.reference} - ${order.supplier_detail.name}`; var title = `${order.reference} - ${order.supplier_detail.name}`;
var color = '#4c68f5'; var color = '#4c68f5';
@ -1623,12 +1619,6 @@ function loadPurchaseOrderTable(table, options) {
switchable: false, switchable: false,
formatter: function(value, row) { 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}/`); var html = renderLink(value, `/order/purchase-order/${row.pk}/`);
if (row.overdue) { if (row.overdue) {
@ -2336,8 +2326,6 @@ function loadSalesOrderTable(table, options) {
{ {
success: function(response) { success: function(response) {
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
for (var idx = 0; idx < response.length; idx++) { for (var idx = 0; idx < response.length; idx++) {
var order = response[idx]; var order = response[idx];
@ -2349,7 +2337,7 @@ function loadSalesOrderTable(table, options) {
date = order.target_date; 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 // Default color is blue
var color = '#4c68f5'; var color = '#4c68f5';
@ -2435,13 +2423,6 @@ function loadSalesOrderTable(table, options) {
field: 'reference', field: 'reference',
title: '{% trans "Sales Order" %}', title: '{% trans "Sales Order" %}',
formatter: function(value, row) { 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}/`); var html = renderLink(value, `/order/sales-order/${row.pk}/`);
if (row.overdue) { if (row.overdue) {
@ -2891,7 +2872,7 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) {
var fields = salesOrderShipmentFields(options); var fields = salesOrderShipmentFields(options);
fields.reference.value = ref; fields.reference.value = ref;
fields.reference.prefix = global_settings.SALESORDER_REFERENCE_PREFIX + options.reference; fields.reference.prefix = options.reference;
return fields; return fields;
} }
@ -3123,9 +3104,7 @@ function loadSalesOrderAllocationTable(table, options={}) {
title: '{% trans "Order" %}', title: '{% trans "Order" %}',
formatter: function(value, row) { formatter: function(value, row) {
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX; var ref = `${row.order_detail.reference}`;
var ref = `${prefix}${row.order_detail.reference}`;
return renderLink(ref, `/order/sales-order/${row.order}/`); return renderLink(ref, `/order/sales-order/${row.order}/`);
} }

View File

@ -974,9 +974,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
return '-'; return '-';
} }
var ref = global_settings.PURCHASEORDER_REFERENCE_PREFIX + order.reference; var html = renderLink(order.reference, `/order/purchase-order/${order.pk}/`);
var html = renderLink(ref, `/order/purchase-order/${order.pk}/`);
html += purchaseOrderStatusDisplay( html += purchaseOrderStatusDisplay(
order.status, order.status,

View File

@ -1916,10 +1916,7 @@ function loadStockTable(table, options) {
var text = `${row.purchase_order}`; var text = `${row.purchase_order}`;
if (row.purchase_order_reference) { if (row.purchase_order_reference) {
text = row.purchase_order_reference;
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
text = prefix + row.purchase_order_reference;
} }
return renderLink(text, link); return renderLink(text, link);