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 response.status_code != expected_code:
print(f"Unexpected response at '{url}':")
print(response.data)
print(f"Unexpected response at '{url}': status code = {response.status_code}")
if hasattr(response, 'data'):
print(response.data)
else:
print(f"(response object {type(response)} has no 'data' attribute")
self.assertEqual(response.status_code, expected_code)

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 os
import re
from datetime import datetime
from django.conf import settings
from django.contrib.auth import get_user_model
@ -19,7 +20,9 @@ from error_report.models import Error
from mptt.exceptions import InvalidMove
from mptt.models import MPTTModel, TreeForeignKey
import InvenTree.format
import InvenTree.helpers
from common.models import InvenTreeSetting
from InvenTree.fields import InvenTreeURLField
from InvenTree.validators import validate_tree_name
@ -96,9 +99,6 @@ class DataImportMixin(object):
class ReferenceIndexingMixin(models.Model):
"""A mixin for keeping track of numerical copies of the "reference" field.
!!DANGER!! always add `ReferenceIndexingSerializerMixin`to all your models serializers to
ensure the reference field is not too big
Here, we attempt to convert a "reference" field value (char) to an integer,
for performing fast natural sorting.
@ -112,24 +112,216 @@ class ReferenceIndexingMixin(models.Model):
- Otherwise, we store zero
"""
# Name of the global setting which defines the required reference pattern for this model
REFERENCE_PATTERN_SETTING = None
@classmethod
def get_reference_pattern(cls):
"""Returns the reference pattern associated with this model.
This is defined by a global setting object, specified by the REFERENCE_PATTERN_SETTING attribute
"""
# By default, we return an empty string
if cls.REFERENCE_PATTERN_SETTING is None:
return ''
return InvenTreeSetting.get_setting(cls.REFERENCE_PATTERN_SETTING, create=False).strip()
@classmethod
def get_reference_context(cls):
"""Generate context data for generating the 'reference' field for this class.
- Returns a python dict object which contains the context data for formatting the reference string.
- The default implementation provides some default context information
"""
return {
'ref': cls.get_next_reference(),
'date': datetime.now(),
}
@classmethod
def get_most_recent_item(cls):
"""Return the item which is 'most recent'
In practice, this means the item with the highest reference value
"""
query = cls.objects.all().order_by('-reference_int', '-pk')
if query.exists():
return query.first()
else:
return None
@classmethod
def get_next_reference(cls):
"""Return the next available reference value for this particular class."""
# Find the "most recent" item
latest = cls.get_most_recent_item()
if not latest:
# No existing items
return 1
reference = latest.reference.strip
try:
reference = InvenTree.format.extract_named_group('ref', reference, cls.get_reference_pattern())
except Exception:
# If reference cannot be extracted using the pattern, try just the integer value
reference = str(latest.reference_int)
# Attempt to perform 'intelligent' incrementing of the reference field
incremented = InvenTree.helpers.increment(reference)
try:
incremented = int(incremented)
except ValueError:
pass
return incremented
@classmethod
def generate_reference(cls):
"""Generate the next 'reference' field based on specified pattern"""
fmt = cls.get_reference_pattern()
ctx = cls.get_reference_context()
reference = None
attempts = set()
while reference is None:
try:
ref = fmt.format(**ctx)
if ref in attempts:
# We are stuck in a loop!
reference = ref
break
else:
attempts.add(ref)
if cls.objects.filter(reference=ref).exists():
# Handle case where we have duplicated an existing reference
ctx['ref'] = InvenTree.helpers.increment(ctx['ref'])
else:
# We have found an 'unused' reference
reference = ref
break
except Exception:
# If anything goes wrong, return the most recent reference
recent = cls.get_most_recent_item()
if recent:
reference = recent.reference
else:
reference = ""
return reference
@classmethod
def validate_reference_pattern(cls, pattern):
"""Ensure that the provided pattern is valid"""
ctx = cls.get_reference_context()
try:
info = InvenTree.format.parse_format_string(pattern)
except Exception:
raise ValidationError({
"value": _("Improperly formatted pattern"),
})
# Check that only 'allowed' keys are provided
for key in info.keys():
if key not in ctx.keys():
raise ValidationError({
"value": _("Unknown format key specified") + f": '{key}'"
})
# Check that the 'ref' variable is specified
if 'ref' not in info.keys():
raise ValidationError({
'value': _("Missing required format key") + ": 'ref'"
})
@classmethod
def validate_reference_field(cls, value):
"""Check that the provided 'reference' value matches the requisite pattern"""
pattern = cls.get_reference_pattern()
value = str(value).strip()
if len(value) == 0:
raise ValidationError(_("Reference field cannot be empty"))
# An 'empty' pattern means no further validation is required
if not pattern:
return
if not InvenTree.format.validate_string(value, pattern):
raise ValidationError(_("Reference must match required pattern") + ": " + pattern)
# Check that the reference field can be rebuild
cls.rebuild_reference_field(value, validate=True)
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
abstract = True
def rebuild_reference_field(self):
"""Extract integer out of reference for sorting."""
reference = getattr(self, 'reference', '')
self.reference_int = extract_int(reference)
@classmethod
def rebuild_reference_field(cls, reference, validate=False):
"""Extract integer out of reference for sorting.
If the 'integer' portion is buried somewhere 'within' the reference,
we can first try to extract it using the pattern.
Example:
reference - BO-123-ABC
pattern - BO-{ref}-???
extracted - 123
If we cannot extract using the pattern for some reason, fallback to the entire reference
"""
try:
# Extract named group based on provided pattern
reference = InvenTree.format.extract_named_group('ref', reference, cls.get_reference_pattern())
except Exception:
pass
reference_int = extract_int(reference)
if validate:
if reference_int > models.BigIntegerField.MAX_BIGINT:
raise ValidationError({
"reference": _("Reference number is too large")
})
return reference_int
reference_int = models.BigIntegerField(default=0)
def extract_int(reference, clip=0x7fffffff):
"""Extract integer out of reference."""
def extract_int(reference, clip=0x7fffffff, allow_negative=False):
"""Extract an integer out of reference."""
# Default value if we cannot convert to an integer
ref_int = 0
reference = str(reference).strip()
# Ignore empty string
if len(reference) == 0:
return 0
# Look at the start of the string - can it be "integerized"?
result = re.match(r"^(\d+)", reference)
@ -139,6 +331,16 @@ def extract_int(reference, clip=0x7fffffff):
ref_int = int(ref)
except Exception:
ref_int = 0
else:
# Look at the "end" of the string
result = re.search(r'(\d+)$', reference)
if result and len(result.groups()) == 1:
ref = result.groups()[0]
try:
ref_int = int(ref)
except Exception:
ref_int = 0
# Ensure that the returned values are within the range that can be stored in an IntegerField
# Note: This will result in large values being "clipped"
@ -148,6 +350,9 @@ def extract_int(reference, clip=0x7fffffff):
elif ref_int < -clip:
ref_int = -clip
if not allow_negative and ref_int < 0:
ref_int = abs(ref_int)
return ref_int

View File

@ -7,7 +7,6 @@ from decimal import Decimal
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
import tablib
@ -20,8 +19,6 @@ from rest_framework.fields import empty
from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta
from .models import extract_int
class InvenTreeMoneySerializer(MoneyField):
"""Custom serializer for 'MoneyField', which ensures that passed values are numerically valid.
@ -211,16 +208,6 @@ class UserSerializer(InvenTreeModelSerializer):
]
class ReferenceIndexingSerializerMixin():
"""This serializer mixin ensures the the reference is not to big / small for the BigIntegerField."""
def validate_reference(self, value):
"""Ensures the reference is not to big / small for the BigIntegerField."""
if extract_int(value) > models.BigIntegerField.MAX_BIGINT:
raise serializers.ValidationError('reference is to to big')
return value
class InvenTreeAttachmentSerializerField(serializers.FileField):
"""Override the DRF native FileField serializer, to remove the leading server path.

View File

@ -17,6 +17,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import Rate, convert_money
from djmoney.money import Money
import InvenTree.format
import InvenTree.tasks
from common.models import InvenTreeSetting
from common.settings import currency_codes
@ -60,6 +61,137 @@ class ValidatorTest(TestCase):
validate_overage("aaaa")
class FormatTest(TestCase):
"""Unit tests for custom string formatting functionality"""
def test_parse(self):
"""Tests for the 'parse_format_string' function"""
# Extract data from a valid format string
fmt = "PO-{abc:02f}-{ref:04d}-{date}-???"
info = InvenTree.format.parse_format_string(fmt)
self.assertIn('abc', info)
self.assertIn('ref', info)
self.assertIn('date', info)
# Try with invalid strings
for fmt in [
'PO-{{xyz}',
'PO-{xyz}}',
'PO-{xyz}-{',
]:
with self.assertRaises(ValueError):
InvenTree.format.parse_format_string(fmt)
def test_create_regex(self):
"""Test function for creating a regex from a format string"""
tests = {
"PO-123-{ref:04f}": r"^PO\-123\-(?P<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):
"""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))
def validate_build_order_reference(value):
"""Validate the 'reference' field of a BuildOrder."""
pattern = common.models.InvenTreeSetting.get_setting('BUILDORDER_REFERENCE_REGEX')
if pattern:
match = re.search(pattern, value)
if match is None:
raise ValidationError(_('Reference must match pattern {pattern}').format(pattern=pattern))
def validate_purchase_order_reference(value):
"""Validate the 'reference' field of a PurchaseOrder."""
pattern = common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_REGEX')

View File

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

View File

@ -1,6 +1,6 @@
# Generated by Django 3.0.7 on 2020-10-19 13:02
import InvenTree.validators
import build.validators
from django.db import migrations, models
@ -18,6 +18,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='build',
name='reference',
field=models.CharField(help_text='Build Order Reference', max_length=64, unique=True, validators=[InvenTree.validators.validate_build_order_reference], verbose_name='Reference'),
field=models.CharField(help_text='Build Order Reference', max_length=64, unique=True, validators=[build.validators.validate_build_order_reference], verbose_name='Reference'),
),
]

View File

@ -1,6 +1,6 @@
# Generated by Django 3.2.4 on 2021-07-08 14:14
import InvenTree.validators
import build.validators
import build.models
from django.db import migrations, models
@ -15,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='build',
name='reference',
field=models.CharField(default=build.models.get_next_build_number, help_text='Build Order Reference', max_length=64, unique=True, validators=[InvenTree.validators.validate_build_order_reference], verbose_name='Reference'),
field=models.CharField(default=build.validators.generate_next_build_reference, help_text='Build Order Reference', max_length=64, unique=True, validators=[build.validators.validate_build_order_reference], verbose_name='Reference'),
),
]

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 InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode, notify_responsible
from InvenTree.helpers import increment, normalize, MakeBarcode, notify_responsible
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
from InvenTree.validators import validate_build_order_reference
from build.validators import generate_next_build_reference, validate_build_order_reference
import InvenTree.fields
import InvenTree.helpers
import InvenTree.ready
import InvenTree.tasks
from plugin.events import trigger_event
@ -38,32 +40,6 @@ from stock import models as StockModels
from users import models as UserModels
def get_next_build_number():
"""Returns the next available BuildOrder reference number."""
if Build.objects.count() == 0:
return '0001'
build = Build.objects.exclude(reference=None).last()
attempts = {build.reference}
reference = build.reference
while 1:
reference = increment(reference)
if reference in attempts:
# Escape infinite recursion
return reference
if Build.objects.filter(reference=reference).exists():
attempts.add(reference)
else:
break
return reference
class Build(MPTTModel, ReferenceIndexingMixin):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
@ -89,6 +65,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
# Global setting for specifying reference pattern
REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN'
@staticmethod
def get_api_url():
"""Return the API URL associated with the BuildOrder model"""
@ -106,7 +85,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
def api_defaults(cls, request):
"""Return default values for this model when issuing an API OPTIONS request."""
defaults = {
'reference': get_next_build_number(),
'reference': generate_next_build_reference(),
}
if request and request.user:
@ -116,7 +95,8 @@ class Build(MPTTModel, ReferenceIndexingMixin):
def save(self, *args, **kwargs):
"""Custom save method for the BuildOrder model"""
self.rebuild_reference_field()
self.validate_reference_field(self.reference)
self.reference_int = self.rebuild_reference_field(self.reference)
try:
super().save(*args, **kwargs)
@ -172,9 +152,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
def __str__(self):
"""String representation of a BuildOrder"""
prefix = getSetting("BUILDORDER_REFERENCE_PREFIX")
return f"{prefix}{self.reference}"
return self.reference
def get_absolute_url(self):
"""Return the web URL associated with this BuildOrder"""
@ -186,9 +164,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
blank=False,
help_text=_('Build Order Reference'),
verbose_name=_('Reference'),
default=get_next_build_number,
default=generate_next_build_reference,
validators=[
validate_build_order_reference
validate_build_order_reference,
]
)
@ -199,7 +177,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
help_text=_('Brief description of the build')
)
# TODO - Perhaps delete the build "tree"
parent = TreeForeignKey(
'self',
on_delete=models.SET_NULL,
@ -1092,6 +1069,10 @@ class Build(MPTTModel, ReferenceIndexingMixin):
@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log')
def after_save_build(sender, instance: Build, created: bool, **kwargs):
"""Callback function to be executed after a Build instance is saved."""
# Escape if we are importing data
if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True):
return
from . import tasks as build_tasks
if created:

View File

@ -11,7 +11,7 @@ from rest_framework import serializers
from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import ReferenceIndexingSerializerMixin, UserSerializer
from InvenTree.serializers import UserSerializer
import InvenTree.helpers
from InvenTree.helpers import extract_serial_numbers
@ -28,7 +28,7 @@ from users.serializers import OwnerSerializer
from .models import Build, BuildItem, BuildOrderAttachment
class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
class BuildSerializer(InvenTreeModelSerializer):
"""Serializes a Build object."""
url = serializers.CharField(source='get_absolute_url', read_only=True)
@ -74,6 +74,16 @@ class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer
if part_detail is not True:
self.fields.pop('part_detail')
reference = serializers.CharField(required=True)
def validate_reference(self, reference):
"""Custom validation for the Build reference field"""
# Ensure the reference matches the required pattern
Build.validate_reference_field(reference)
return reference
class Meta:
"""Serializer metaclass"""
model = Build

View File

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

View File

@ -12,7 +12,7 @@ from InvenTree import status_codes as status
import common.models
import build.tasks
from build.models import Build, BuildItem, get_next_build_number
from build.models import Build, BuildItem, generate_next_build_reference
from part.models import Part, BomItem, BomItemSubstitute
from stock.models import StockItem
from users.models import Owner
@ -88,7 +88,7 @@ class BuildTestBase(TestCase):
quantity=2
)
ref = get_next_build_number()
ref = generate_next_build_reference()
# Create a "Build" object to make 10x objects
self.build = Build.objects.create(
@ -133,20 +133,97 @@ class BuildTest(BuildTestBase):
def test_ref_int(self):
"""Test the "integer reference" field used for natural sorting"""
for ii in range(10):
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None)
refs = {
'BO-123-456': 123,
'BO-456-123': 456,
'BO-999-ABC': 999,
'BO-123ABC-ABC': 123,
'BO-ABC123-ABC': 123,
}
for ref, ref_int in refs.items():
build = Build(
reference=f"{ii}_abcde",
reference=ref,
quantity=1,
part=self.assembly,
title="Making some parts"
title='Making some parts',
)
self.assertEqual(build.reference_int, 0)
build.save()
self.assertEqual(build.reference_int, ref_int)
# After saving, the integer reference should have been updated
self.assertEqual(build.reference_int, ii)
def test_ref_validation(self):
"""Test that the reference field validation works as expected"""
# Default reference pattern = 'BO-{ref:04d}
# These patterns should fail
for ref in [
'BO-1234x',
'BO1234',
'OB-1234',
'BO--1234'
]:
with self.assertRaises(ValidationError):
Build.objects.create(
part=self.assembly,
quantity=10,
reference=ref,
title='Invalid reference',
)
for ref in [
'BO-1234',
'BO-9999',
'BO-123'
]:
Build.objects.create(
part=self.assembly,
quantity=10,
reference=ref,
title='Valid reference',
)
# Try a new validator pattern
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None)
for ref in [
'1234-BO',
'9999-BO'
]:
Build.objects.create(
part=self.assembly,
quantity=10,
reference=ref,
title='Valid reference',
)
def test_next_ref(self):
"""Test that the next reference is automatically generated"""
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None)
build = Build.objects.create(
part=self.assembly,
quantity=5,
reference='XYZ-987',
title='Some thing',
)
self.assertEqual(build.reference_int, 987)
# Now create one *without* specifying the reference
build = Build.objects.create(
part=self.assembly,
quantity=1,
title='Some new title',
)
self.assertEqual(build.reference, 'XYZ-000988')
self.assertEqual(build.reference_int, 988)
def test_init(self):
"""Perform some basic tests before we start the ball rolling"""
@ -404,7 +481,7 @@ class BuildTest(BuildTestBase):
"""Test that a notification is sent when a new build is created"""
Build.objects.create(
reference='IIIII',
reference='BO-9999',
title='Some new build',
part=self.assembly,
quantity=5,

View File

@ -104,3 +104,57 @@ class TestReferenceMigration(MigratorTestCase):
# Check that the build reference is properly assigned
for build in Build.objects.all():
self.assertEqual(str(build.reference), str(build.pk))
class TestReferencePatternMigration(MigratorTestCase):
"""Unit test for data migration which converts reference to new format.
Ref: https://github.com/inventree/InvenTree/pull/3267
"""
migrate_from = ('build', '0019_auto_20201019_1302')
migrate_to = ('build', helpers.getNewestMigrationFile('build'))
def prepare(self):
"""Create some initial data prior to migration"""
Setting = self.old_state.apps.get_model('common', 'inventreesetting')
# Create a custom existing prefix so we can confirm the operation is working
Setting.objects.create(
key='BUILDORDER_REFERENCE_PREFIX',
value='BuildOrder-',
)
Part = self.old_state.apps.get_model('part', 'part')
assembly = Part.objects.create(
name='Assy 1',
description='An assembly',
level=0, lft=0, rght=0, tree_id=0,
)
Build = self.old_state.apps.get_model('build', 'build')
for idx in range(1, 11):
Build.objects.create(
part=assembly,
title=f"Build {idx}",
quantity=idx,
reference=f"{idx + 100}",
level=0, lft=0, rght=0, tree_id=0,
)
def test_reference_migration(self):
"""Test that the reference fields have been correctly updated"""
Build = self.new_state.apps.get_model('build', 'build')
for build in Build.objects.all():
self.assertTrue(build.reference.startswith('BuildOrder-'))
Setting = self.new_state.apps.get_model('common', 'inventreesetting')
pattern = Setting.objects.get(key='BUILDORDER_REFERENCE_PATTERN')
self.assertEqual(pattern.value, 'BuildOrder-{ref:04d}')

View File

@ -35,7 +35,7 @@ class BuildTestSimple(InvenTreeTestCase):
self.assertEqual(b.batch, 'B2')
self.assertEqual(b.quantity, 21)
self.assertEqual(str(b), 'BO0002')
self.assertEqual(str(b), 'BO-0002')
def test_url(self):
"""Test URL lookup"""
@ -75,11 +75,6 @@ class BuildTestSimple(InvenTreeTestCase):
self.assertEqual(b1.is_active, True)
self.assertEqual(b2.is_active, False)
def test_required_parts(self):
"""Test set of required BOM items for the build"""
# TODO: Generate BOM for test part
...
def test_cancel_build(self):
"""Test build cancellation function."""
build = Build.objects.get(id=1)

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

View File

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

View File

@ -1,7 +1,6 @@
# Generated by Django 3.2.4 on 2021-07-02 13:21
from django.db import migrations, models
import order.models
class Migration(migrations.Migration):
@ -14,11 +13,11 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='purchaseorder',
name='reference',
field=models.CharField(default=order.models.get_next_po_number, help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'),
field=models.CharField(default="PO", help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'),
),
migrations.AlterField(
model_name='salesorder',
name='reference',
field=models.CharField(default=order.models.get_next_so_number, help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'),
field=models.CharField(default="SO", help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'),
),
]

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.ready
import order.validators
from common.notifications import InvenTreeNotificationBodies
from common.settings import currency_code_default
from company.models import Company, SupplierPart
from InvenTree.exceptions import log_error
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
RoundingDecimalField)
from InvenTree.helpers import (decimal2string, getSetting, increment,
notify_responsible)
from InvenTree.helpers import decimal2string, getSetting, notify_responsible
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
StockHistoryCode, StockStatus)
@ -44,58 +44,6 @@ from users import models as UserModels
logger = logging.getLogger('inventree')
def get_next_po_number():
"""Returns the next available PurchaseOrder reference number."""
if PurchaseOrder.objects.count() == 0:
return '0001'
order = PurchaseOrder.objects.exclude(reference=None).last()
attempts = {order.reference}
reference = order.reference
while 1:
reference = increment(reference)
if reference in attempts:
# Escape infinite recursion
return reference
if PurchaseOrder.objects.filter(reference=reference).exists():
attempts.add(reference)
else:
break
return reference
def get_next_so_number():
"""Returns the next available SalesOrder reference number."""
if SalesOrder.objects.count() == 0:
return '0001'
order = SalesOrder.objects.exclude(reference=None).last()
attempts = {order.reference}
reference = order.reference
while 1:
reference = increment(reference)
if reference in attempts:
# Escape infinite recursion
return reference
if SalesOrder.objects.filter(reference=reference).exists():
attempts.add(reference)
else:
break
return reference
class Order(MetadataMixin, ReferenceIndexingMixin):
"""Abstract model for an order.
@ -119,7 +67,7 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
Ensures that the reference field is rebuilt whenever the instance is saved.
"""
self.rebuild_reference_field()
self.reference_int = self.rebuild_reference_field(self.reference)
if not self.creation_date:
self.creation_date = datetime.now().date()
@ -230,8 +178,21 @@ class PurchaseOrder(Order):
"""Return the API URL associated with the PurchaseOrder model"""
return reverse('api-po-list')
@classmethod
def api_defaults(cls, request):
"""Return default values for thsi model when issuing an API OPTIONS request"""
defaults = {
'reference': order.validators.generate_next_purchase_order_reference(),
}
return defaults
OVERDUE_FILTER = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
# Global setting for specifying reference pattern
REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
@staticmethod
def filterByDate(queryset, min_date, max_date):
"""Filter by 'minimum and maximum date range'.
@ -269,9 +230,8 @@ class PurchaseOrder(Order):
def __str__(self):
"""Render a string representation of this PurchaseOrder"""
prefix = getSetting('PURCHASEORDER_REFERENCE_PREFIX')
return f"{prefix}{self.reference} - {self.supplier.name if self.supplier else _('deleted')}"
return f"{self.reference} - {self.supplier.name if self.supplier else _('deleted')}"
reference = models.CharField(
unique=True,
@ -279,7 +239,10 @@ class PurchaseOrder(Order):
blank=False,
verbose_name=_('Reference'),
help_text=_('Order reference'),
default=get_next_po_number,
default=order.validators.generate_next_purchase_order_reference,
validators=[
order.validators.validate_purchase_order_reference,
]
)
status = models.PositiveIntegerField(default=PurchaseOrderStatus.PENDING, choices=PurchaseOrderStatus.items(),
@ -595,8 +558,20 @@ class SalesOrder(Order):
"""Return the API URL associated with the SalesOrder model"""
return reverse('api-so-list')
@classmethod
def api_defaults(cls, request):
"""Return default values for this model when issuing an API OPTIONS request"""
defaults = {
'reference': order.validators.generate_next_sales_order_reference(),
}
return defaults
OVERDUE_FILTER = Q(status__in=SalesOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
# Global setting for specifying reference pattern
REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN'
@staticmethod
def filterByDate(queryset, min_date, max_date):
"""Filter by "minimum and maximum date range".
@ -634,9 +609,8 @@ class SalesOrder(Order):
def __str__(self):
"""Render a string representation of this SalesOrder"""
prefix = getSetting('SALESORDER_REFERENCE_PREFIX')
return f"{prefix}{self.reference} - {self.customer.name if self.customer else _('deleted')}"
return f"{self.reference} - {self.customer.name if self.customer else _('deleted')}"
def get_absolute_url(self):
"""Return the web URL for the detail view of this order"""
@ -648,7 +622,10 @@ class SalesOrder(Order):
blank=False,
verbose_name=_('Reference'),
help_text=_('Order reference'),
default=get_next_so_number,
default=order.validators.generate_next_sales_order_reference,
validators=[
order.validators.validate_sales_order_reference,
]
)
customer = models.ForeignKey(

View File

@ -23,8 +23,7 @@ from InvenTree.helpers import extract_serial_numbers, normalize
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
InvenTreeDecimalField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
ReferenceIndexingSerializerMixin)
InvenTreeMoneySerializer)
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
StockStatus)
from part.serializers import PartBriefSerializer
@ -86,7 +85,7 @@ class AbstractExtraLineMeta:
]
class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
"""Serializer for a PurchaseOrder object."""
def __init__(self, *args, **kwargs):
@ -130,6 +129,14 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializ
reference = serializers.CharField(required=True)
def validate_reference(self, reference):
"""Custom validation for the reference field"""
# Ensure that the reference matches the required pattern
order.models.PurchaseOrder.validate_reference_field(reference)
return reference
responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
class Meta:
@ -639,7 +646,7 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
]
class SalesOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
"""Serializers for the SalesOrder object."""
def __init__(self, *args, **kwargs):
@ -683,6 +690,14 @@ class SalesOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerM
reference = serializers.CharField(required=True)
def validate_reference(self, reference):
"""Custom validation for the reference field"""
# Ensure that the reference matches the required pattern
order.models.SalesOrder.validate_reference_field(reference)
return reference
class Meta:
"""Metaclass options."""

View File

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

View File

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

View File

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

View File

@ -40,19 +40,27 @@ class SalesOrderTest(TestCase):
# Create a SalesOrder to ship against
self.order = SalesOrder.objects.create(
customer=self.customer,
reference='1234',
reference='SO-1234',
customer_reference='ABC 55555'
)
# Create a Shipment against this SalesOrder
self.shipment = SalesOrderShipment.objects.create(
order=self.order,
reference='001',
reference='SO-001',
)
# Create a line item
self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part)
def test_so_reference(self):
"""Unit tests for sales order generation"""
# Test that a good reference is created when we have no existing orders
SalesOrder.objects.all().delete()
self.assertEqual(SalesOrder.generate_reference(), 'SO-0001')
def test_rebuild_reference(self):
"""Test that the 'reference_int' field gets rebuilt when the model is saved"""

View File

@ -35,15 +35,17 @@ class OrderTest(TestCase):
def test_basics(self):
"""Basic tests e.g. repr functions etc."""
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/')
for pk in range(1, 8):
self.assertEqual(str(order), 'PO0001 - ACME')
order = PurchaseOrder.objects.get(pk=pk)
self.assertEqual(order.get_absolute_url(), f'/order/purchase-order/{pk}/')
self.assertEqual(order.reference, f'PO-{pk:04d}')
line = PurchaseOrderLineItem.objects.get(pk=1)
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO0001 - ACME)")
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO-0001 - ACME)")
def test_rebuild_reference(self):
"""Test that the reference_int field is correctly updated when the model is saved"""

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),
quantity=10,
title='Making some assemblies',
reference='BO-9999',
status=BuildStatus.PRODUCTION,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@
<table class='table table-striped table-condensed'>
<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" %}
</tbody>
</table>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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