mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 11:10:54 +00:00
[Plugin] Allow custom plugins for running validation routines (#3776)
* Adds new plugin mixin for performing custom validation steps * Adds simple test plugin for custom validation * Run part name and IPN validators checks through loaded plugins * Expose more validation functions to plugins: - SalesOrder reference - PurchaseOrder reference - BuildOrder reference * Remove custom validation of reference fields - For now, this is too complex to consider given the current incrementing-reference implementation - Might revisit this at a later stage. * Custom validation of serial numbers: - Replace "checkIfSerialNumberExists" method with "validate_serial_number" - Pass serial number through to custom plugins - General code / docstring improvements * Update unit tests * Update InvenTree/stock/tests.py Co-authored-by: Matthias Mair <code@mjmair.com> * Adds global setting to specify whether serial numbers must be unique globally - Default is false to preserve behaviour * Improved error message when attempting to create stock item with invalid serial numbers * Add more detail to existing serial error message * Add unit testing for serial number uniqueness * Allow plugins to convert a serial number to an integer (for optimized sorting) * Add placeholder plugin methods for incrementing and decrementing serial numbers * Typo fix * Add improved method for determining the "latest" serial number * Remove calls to getLatestSerialNumber * Update validate_serial_number method - Add option to disable checking for duplicates - Don't pass optional StockItem through to plugins * Refactor serial number extraction methods - Expose the "incrementing" portion to external plugins * Bug fixes * Update unit tests * Fix for get_latest_serial_number * Ensure custom serial integer values are clipped * Adds a plugin for validating and generating hexadecimal serial numbers * Update unit tests * Add stub methods for batch code functionality * remove "hex serials" plugin - Was simply breaking unit tests * Allow custom plugins to generate and validate batch codes - Perform batch code validation when StockItem is saved - Improve display of error message in modal forms * Fix unit tests for stock app * Log message if plugin has a duplicate slug * Unit test fix Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@ -563,23 +563,35 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
|
||||
|
||||
# If serial numbers are specified, check that they match!
|
||||
try:
|
||||
serials = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt())
|
||||
serials = extract_serial_numbers(
|
||||
serial_numbers,
|
||||
quantity,
|
||||
part.get_latest_serial_number()
|
||||
)
|
||||
|
||||
# Determine if any of the specified serial numbers already exist!
|
||||
existing = []
|
||||
# Determine if any of the specified serial numbers are invalid
|
||||
# Note "invalid" means either they already exist, or do not pass custom rules
|
||||
invalid = []
|
||||
errors = []
|
||||
|
||||
for serial in serials:
|
||||
if part.checkIfSerialNumberExists(serial):
|
||||
existing.append(serial)
|
||||
try:
|
||||
part.validate_serial_number(serial, raise_error=True)
|
||||
except DjangoValidationError as exc:
|
||||
# Catch raised error to extract specific error information
|
||||
invalid.append(serial)
|
||||
|
||||
if len(existing) > 0:
|
||||
if exc.message not in errors:
|
||||
errors.append(exc.message)
|
||||
|
||||
msg = _("The following serial numbers already exist")
|
||||
if len(errors) > 0:
|
||||
|
||||
msg = _("The following serial numbers already exist or are invalid")
|
||||
msg += " : "
|
||||
msg += ",".join([str(e) for e in existing])
|
||||
msg += ",".join([str(e) for e in invalid])
|
||||
|
||||
raise ValidationError({
|
||||
'serial_numbers': [msg],
|
||||
'serial_numbers': errors + [msg]
|
||||
})
|
||||
|
||||
except DjangoValidationError as e:
|
||||
|
@ -96,6 +96,7 @@
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 1000
|
||||
serial_int: 1000
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
@ -121,6 +122,7 @@
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 1
|
||||
serial_int: 1
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
@ -133,6 +135,7 @@
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 2
|
||||
serial_int: 2
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
@ -145,6 +148,7 @@
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 3
|
||||
serial_int: 3
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
@ -157,6 +161,7 @@
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 4
|
||||
serial_int: 4
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
@ -169,6 +174,7 @@
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 5
|
||||
serial_int: 5
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
@ -181,6 +187,7 @@
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 10
|
||||
serial_int: 10
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
@ -193,6 +200,7 @@
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 11
|
||||
serial_int: 11
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
@ -205,6 +213,7 @@
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 12
|
||||
serial_int: 12
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
@ -217,6 +226,7 @@
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 20
|
||||
serial_int: 20
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
@ -231,6 +241,7 @@
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 21
|
||||
serial_int: 21
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
@ -245,6 +256,7 @@
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 22
|
||||
serial_int: 22
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
|
@ -180,9 +180,24 @@ class StockItemManager(TreeManager):
|
||||
def generate_batch_code():
|
||||
"""Generate a default 'batch code' for a new StockItem.
|
||||
|
||||
This uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured),
|
||||
By default, this uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured),
|
||||
which can be passed through a simple template.
|
||||
|
||||
Also, this function is exposed to the ValidationMixin plugin class,
|
||||
allowing custom plugins to be used to generate new batch code values
|
||||
"""
|
||||
|
||||
# First, check if any plugins can generate batch codes
|
||||
from plugin.registry import registry
|
||||
|
||||
for plugin in registry.with_mixin('validation'):
|
||||
batch = plugin.generate_batch_code()
|
||||
|
||||
if batch is not None:
|
||||
# Return the first non-null value generated by a plugin
|
||||
return batch
|
||||
|
||||
# If we get to this point, no plugin was able to generate a new batch code
|
||||
batch_template = common.models.InvenTreeSetting.get_setting('STOCK_BATCH_CODE_TEMPLATE', '')
|
||||
|
||||
now = datetime.now()
|
||||
@ -260,15 +275,38 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
|
||||
This is used for efficient numerical sorting
|
||||
"""
|
||||
serial = getattr(self, 'serial', '')
|
||||
|
||||
serial = str(getattr(self, 'serial', '')).strip()
|
||||
|
||||
from plugin.registry import registry
|
||||
|
||||
# First, let any plugins convert this serial number to an integer value
|
||||
# If a non-null value is returned (by any plugin) we will use that
|
||||
|
||||
serial_int = None
|
||||
|
||||
for plugin in registry.with_mixin('validation'):
|
||||
serial_int = plugin.convert_serial_to_int(serial)
|
||||
|
||||
if serial_int is not None:
|
||||
# Save the first returned result
|
||||
# Ensure that it is clipped within a range allowed in the database schema
|
||||
clip = 0x7fffffff
|
||||
|
||||
serial_int = abs(serial_int)
|
||||
|
||||
if serial_int > clip:
|
||||
serial_int = clip
|
||||
|
||||
self.serial_int = serial_int
|
||||
return
|
||||
|
||||
# If we get to this point, none of the available plugins provided an integer value
|
||||
|
||||
# Default value if we cannot convert to an integer
|
||||
serial_int = 0
|
||||
|
||||
if serial is not None:
|
||||
|
||||
serial = str(serial).strip()
|
||||
|
||||
if serial not in [None, '']:
|
||||
serial_int = extract_int(serial)
|
||||
|
||||
self.serial_int = serial_int
|
||||
@ -408,16 +446,32 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
|
||||
# If the serial number is set, make sure it is not a duplicate
|
||||
if self.serial:
|
||||
# Query to look for duplicate serial numbers
|
||||
parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id)
|
||||
stock = StockItem.objects.filter(part__in=parts, serial=self.serial)
|
||||
|
||||
# Exclude myself from the search
|
||||
if self.pk is not None:
|
||||
stock = stock.exclude(pk=self.pk)
|
||||
self.serial = str(self.serial).strip()
|
||||
|
||||
if stock.exists():
|
||||
raise ValidationError({"serial": _("StockItem with this serial number already exists")})
|
||||
try:
|
||||
self.part.validate_serial_number(self.serial, self, raise_error=True)
|
||||
except ValidationError as exc:
|
||||
raise ValidationError({
|
||||
'serial': exc.message,
|
||||
})
|
||||
|
||||
def validate_batch_code(self):
|
||||
"""Ensure that the batch code is valid for this StockItem.
|
||||
|
||||
- Validation is performed by custom plugins.
|
||||
- By default, no validation checks are performed
|
||||
"""
|
||||
|
||||
from plugin.registry import registry
|
||||
|
||||
for plugin in registry.with_mixin('validation'):
|
||||
try:
|
||||
plugin.validate_batch_code(self.batch)
|
||||
except ValidationError as exc:
|
||||
raise ValidationError({
|
||||
'batch': exc.message
|
||||
})
|
||||
|
||||
def clean(self):
|
||||
"""Validate the StockItem object (separate to field validation).
|
||||
@ -438,6 +492,8 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
if type(self.batch) is str:
|
||||
self.batch = self.batch.strip()
|
||||
|
||||
self.validate_batch_code()
|
||||
|
||||
try:
|
||||
# Trackable parts must have integer values for quantity field!
|
||||
if self.part.trackable:
|
||||
|
@ -342,7 +342,11 @@ class SerializeStockItemSerializer(serializers.Serializer):
|
||||
serial_numbers = data['serial_numbers']
|
||||
|
||||
try:
|
||||
serials = InvenTree.helpers.extract_serial_numbers(serial_numbers, quantity, item.part.getLatestSerialNumberInt())
|
||||
serials = InvenTree.helpers.extract_serial_numbers(
|
||||
serial_numbers,
|
||||
quantity,
|
||||
item.part.get_latest_serial_number()
|
||||
)
|
||||
except DjangoValidationError as e:
|
||||
raise ValidationError({
|
||||
'serial_numbers': e.messages,
|
||||
@ -371,7 +375,7 @@ class SerializeStockItemSerializer(serializers.Serializer):
|
||||
serials = InvenTree.helpers.extract_serial_numbers(
|
||||
data['serial_numbers'],
|
||||
data['quantity'],
|
||||
item.part.getLatestSerialNumberInt()
|
||||
item.part.get_latest_serial_number()
|
||||
)
|
||||
|
||||
item.serializeStock(
|
||||
|
@ -495,7 +495,7 @@ class StockItemTest(StockAPITestCase):
|
||||
|
||||
# Check that each serial number was created
|
||||
for i in range(1, 11):
|
||||
self.assertTrue(i in sn)
|
||||
self.assertTrue(str(i) in sn)
|
||||
|
||||
# Check the unique stock item has been created
|
||||
|
||||
|
@ -7,6 +7,7 @@ from django.db.models import Sum
|
||||
from django.test import override_settings
|
||||
|
||||
from build.models import Build
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.helpers import InvenTreeTestCase
|
||||
from InvenTree.status_codes import StockHistoryCode
|
||||
from part.models import Part
|
||||
@ -172,6 +173,39 @@ class StockTest(StockTestBase):
|
||||
item.save()
|
||||
item.full_clean()
|
||||
|
||||
def test_serial_numbers(self):
|
||||
"""Test serial number uniqueness"""
|
||||
|
||||
# Ensure that 'global uniqueness' setting is enabled
|
||||
InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', True, self.user)
|
||||
|
||||
part_a = Part.objects.create(name='A', description='A', trackable=True)
|
||||
part_b = Part.objects.create(name='B', description='B', trackable=True)
|
||||
|
||||
# Create a StockItem for part_a
|
||||
StockItem.objects.create(
|
||||
part=part_a,
|
||||
quantity=1,
|
||||
serial='ABCDE',
|
||||
)
|
||||
|
||||
# Create a StockItem for part_a (but, will error due to identical serial)
|
||||
with self.assertRaises(ValidationError):
|
||||
StockItem.objects.create(
|
||||
part=part_b,
|
||||
quantity=1,
|
||||
serial='ABCDE',
|
||||
)
|
||||
|
||||
# Now, allow serial numbers to be duplicated between different parts
|
||||
InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False, self.user)
|
||||
|
||||
StockItem.objects.create(
|
||||
part=part_b,
|
||||
quantity=1,
|
||||
serial='ABCDE',
|
||||
)
|
||||
|
||||
def test_expiry(self):
|
||||
"""Test expiry date functionality for StockItem model."""
|
||||
today = datetime.datetime.now().date()
|
||||
@ -857,22 +891,21 @@ class VariantTest(StockTestBase):
|
||||
|
||||
def test_serial_numbers(self):
|
||||
"""Test serial number functionality for variant / template parts."""
|
||||
|
||||
InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False, self.user)
|
||||
|
||||
chair = Part.objects.get(pk=10000)
|
||||
|
||||
# Operations on the top-level object
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(1))
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(2))
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(3))
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(4))
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(5))
|
||||
[self.assertFalse(chair.validate_serial_number(i)) for i in [1, 2, 3, 4, 5, 20, 21, 22]]
|
||||
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(20))
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(21))
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(22))
|
||||
self.assertFalse(chair.validate_serial_number(20))
|
||||
self.assertFalse(chair.validate_serial_number(21))
|
||||
self.assertFalse(chair.validate_serial_number(22))
|
||||
|
||||
self.assertFalse(chair.checkIfSerialNumberExists(30))
|
||||
self.assertTrue(chair.validate_serial_number(30))
|
||||
|
||||
self.assertEqual(chair.getLatestSerialNumber(), '22')
|
||||
self.assertEqual(chair.get_latest_serial_number(), '22')
|
||||
|
||||
# Check for conflicting serial numbers
|
||||
to_check = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
@ -883,10 +916,10 @@ class VariantTest(StockTestBase):
|
||||
|
||||
# Same operations on a sub-item
|
||||
variant = Part.objects.get(pk=10003)
|
||||
self.assertEqual(variant.getLatestSerialNumber(), '22')
|
||||
self.assertEqual(variant.get_latest_serial_number(), '22')
|
||||
|
||||
# Create a new serial number
|
||||
n = variant.getLatestSerialNumber()
|
||||
n = variant.get_latest_serial_number()
|
||||
|
||||
item = StockItem(
|
||||
part=variant,
|
||||
@ -898,12 +931,6 @@ class VariantTest(StockTestBase):
|
||||
with self.assertRaises(ValidationError):
|
||||
item.save()
|
||||
|
||||
# Verify items with a non-numeric serial don't offer a next serial.
|
||||
item.serial = "string"
|
||||
item.save()
|
||||
|
||||
self.assertEqual(variant.getLatestSerialNumber(), "string")
|
||||
|
||||
# This should pass, although not strictly an int field now.
|
||||
item.serial = int(n) + 1
|
||||
item.save()
|
||||
@ -915,7 +942,7 @@ class VariantTest(StockTestBase):
|
||||
with self.assertRaises(ValidationError):
|
||||
item.save()
|
||||
|
||||
item.serial += 1
|
||||
item.serial = int(n) + 2
|
||||
item.save()
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user