2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 19:46:46 +00:00

[Plugin] Enhancements for serial number validation (#8311)

* Error handling for plugin.increment_serial_number

* Improve error handling for plugin validation functions

* Add Part.get_next_serial_number method

- Simplify call for incrementing

* ValidationMixin: add "part" to increment_serial_number func

* Pass part information through to serial number functions

* Fix circular imports

* Allow "get_latest_serial_number" to use plugin system

* Better working example for plugin sample

* Update SampleValidatorPlugin

* Fix indent

* Add code comment

* Cleanup code logic

* Revert previous commit

* Update unit tests
This commit is contained in:
Oliver 2024-10-18 13:45:35 +11:00 committed by GitHub
parent c9a4e6bf7d
commit 17bf7bb5e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 135 additions and 36 deletions

View File

@ -2,6 +2,7 @@
import datetime import datetime
import hashlib import hashlib
import inspect
import io import io
import logging import logging
import os import os
@ -432,7 +433,7 @@ def DownloadFile(
return response return response
def increment_serial_number(serial): def increment_serial_number(serial, part=None):
"""Given a serial number, (attempt to) generate the *next* serial number. """Given a serial number, (attempt to) generate the *next* serial number.
Note: This method is exposed to custom plugins. Note: This method is exposed to custom plugins.
@ -443,6 +444,7 @@ def increment_serial_number(serial):
Returns: Returns:
incremented value, or None if incrementing could not be performed. incremented value, or None if incrementing could not be performed.
""" """
from InvenTree.exceptions import log_error
from plugin.registry import registry from plugin.registry import registry
# Ensure we start with a string value # Ensure we start with a string value
@ -451,16 +453,30 @@ def increment_serial_number(serial):
# First, let any plugins attempt to increment the serial number # First, let any plugins attempt to increment the serial number
for plugin in registry.with_mixin('validation'): for plugin in registry.with_mixin('validation'):
result = plugin.increment_serial_number(serial) try:
if result is not None: if not hasattr(plugin, 'increment_serial_number'):
return str(result) continue
signature = inspect.signature(plugin.increment_serial_number)
# Note: 2024-08-21 - The 'part' parameter has been added to the signature
if 'part' in signature.parameters:
result = plugin.increment_serial_number(serial, part=part)
else:
result = plugin.increment_serial_number(serial)
if result is not None:
return str(result)
except Exception:
log_error(f'{plugin.slug}.increment_serial_number')
# If we get to here, no plugins were able to "increment" the provided serial value # If we get to here, no plugins were able to "increment" the provided serial value
# Attempt to perform increment according to some basic rules # Attempt to perform increment according to some basic rules
return increment(serial) return increment(serial)
def extract_serial_numbers(input_string, expected_quantity: int, starting_value=None): def extract_serial_numbers(
input_string, expected_quantity: int, starting_value=None, part=None
):
"""Extract a list of serial numbers from a provided input string. """Extract a list of serial numbers from a provided input string.
The input string can be specified using the following concepts: The input string can be specified using the following concepts:
@ -480,7 +496,7 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
starting_value: Provide a starting value for the sequence (or None) starting_value: Provide a starting value for the sequence (or None)
""" """
if starting_value is None: if starting_value is None:
starting_value = increment_serial_number(None) starting_value = increment_serial_number(None, part=part)
try: try:
expected_quantity = int(expected_quantity) expected_quantity = int(expected_quantity)
@ -497,12 +513,12 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
if len(input_string) == 0: if len(input_string) == 0:
raise ValidationError([_('Empty serial number string')]) raise ValidationError([_('Empty serial number string')])
next_value = increment_serial_number(starting_value) next_value = increment_serial_number(starting_value, part=part)
# Substitute ~ character with latest value # Substitute ~ character with latest value
while '~' in input_string and next_value: while '~' in input_string and next_value:
input_string = input_string.replace('~', str(next_value), 1) input_string = input_string.replace('~', str(next_value), 1)
next_value = increment_serial_number(next_value) next_value = increment_serial_number(next_value, part=part)
# Split input string by whitespace or comma (,) characters # Split input string by whitespace or comma (,) characters
groups = re.split(r'[\s,]+', input_string) groups = re.split(r'[\s,]+', input_string)

View File

@ -396,7 +396,8 @@ class BuildOutputCreateSerializer(serializers.Serializer):
self.serials = InvenTree.helpers.extract_serial_numbers( self.serials = InvenTree.helpers.extract_serial_numbers(
serial_numbers, serial_numbers,
quantity, quantity,
part.get_latest_serial_number() part.get_latest_serial_number(),
part=part
) )
except DjangoValidationError as e: except DjangoValidationError as e:
raise ValidationError({ raise ValidationError({

View File

@ -835,7 +835,10 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
try: try:
# Pass the serial numbers through to the parent serializer once validated # Pass the serial numbers through to the parent serializer once validated
data['serials'] = extract_serial_numbers( data['serials'] = extract_serial_numbers(
serial_numbers, base_quantity, base_part.get_latest_serial_number() serial_numbers,
base_quantity,
base_part.get_latest_serial_number(),
part=base_part,
) )
except DjangoValidationError as e: except DjangoValidationError as e:
raise ValidationError({'serial_numbers': e.messages}) raise ValidationError({'serial_numbers': e.messages})
@ -1602,7 +1605,7 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
try: try:
data['serials'] = extract_serial_numbers( data['serials'] = extract_serial_numbers(
serial_numbers, quantity, part.get_latest_serial_number() serial_numbers, quantity, part.get_latest_serial_number(), part=part
) )
except DjangoValidationError as e: except DjangoValidationError as e:
raise ValidationError({'serial_numbers': e.messages}) raise ValidationError({'serial_numbers': e.messages})

View File

@ -29,7 +29,7 @@ from InvenTree.filters import (
InvenTreeDateFilter, InvenTreeDateFilter,
InvenTreeSearchFilter, InvenTreeSearchFilter,
) )
from InvenTree.helpers import increment_serial_number, isNull, str2bool from InvenTree.helpers import isNull, str2bool
from InvenTree.mixins import ( from InvenTree.mixins import (
CreateAPI, CreateAPI,
CustomRetrieveUpdateDestroyAPI, CustomRetrieveUpdateDestroyAPI,
@ -811,15 +811,10 @@ class PartSerialNumberDetail(RetrieveAPI):
part = self.get_object() part = self.get_object()
# Calculate the "latest" serial number # Calculate the "latest" serial number
latest = part.get_latest_serial_number() latest_serial = part.get_latest_serial_number()
next_serial = part.get_next_serial_number()
data = {'latest': latest} data = {'latest': latest_serial, 'next': next_serial}
if latest is not None:
next_serial = increment_serial_number(latest)
if next_serial != latest:
data['next'] = next_serial
return Response(data) return Response(data)

View File

@ -55,6 +55,7 @@ from common.icons import validate_icon
from common.settings import get_global_setting from common.settings import get_global_setting
from company.models import SupplierPart from company.models import SupplierPart
from InvenTree import helpers, validators from InvenTree import helpers, validators
from InvenTree.exceptions import log_error
from InvenTree.fields import InvenTreeURLField from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2money, decimal2string, normalize, str2bool from InvenTree.helpers import decimal2money, decimal2string, normalize, str2bool
from order import models as OrderModels from order import models as OrderModels
@ -659,6 +660,8 @@ class Part(
except ValidationError as exc: except ValidationError as exc:
if raise_error: if raise_error:
raise ValidationError({'name': exc.message}) raise ValidationError({'name': exc.message})
except Exception:
log_error(f'{plugin.slug}.validate_part_name')
def validate_ipn(self, raise_error=True): def validate_ipn(self, raise_error=True):
"""Ensure that the IPN (internal part number) is valid for this Part". """Ensure that the IPN (internal part number) is valid for this Part".
@ -678,6 +681,8 @@ class Part(
except ValidationError as exc: except ValidationError as exc:
if raise_error: if raise_error:
raise ValidationError({'IPN': exc.message}) raise ValidationError({'IPN': exc.message})
except Exception:
log_error(f'{plugin.slug}.validate_part_ipn')
# If we get to here, none of the plugins have raised an error # If we get to here, none of the plugins have raised an error
pattern = get_global_setting('PART_IPN_REGEX', '', create=False).strip() pattern = get_global_setting('PART_IPN_REGEX', '', create=False).strip()
@ -792,6 +797,8 @@ class Part(
raise exc raise exc
else: else:
return False return False
except Exception:
log_error('part.validate_serial_number')
""" """
If we are here, none of the loaded plugins (if any) threw an error or exited early If we are here, none of the loaded plugins (if any) threw an error or exited early
@ -868,7 +875,7 @@ class Part(
return conflicts return conflicts
def get_latest_serial_number(self): def get_latest_serial_number(self, allow_plugins=True):
"""Find the 'latest' serial number for this Part. """Find the 'latest' serial number for this Part.
Here we attempt to find the "highest" serial number which exists for this Part. Here we attempt to find the "highest" serial number which exists for this Part.
@ -881,15 +888,26 @@ class Part(
Returns: Returns:
The latest serial number specified for this part, or None The latest serial number specified for this part, or None
""" """
from plugin.registry import registry
if allow_plugins:
# Check with plugin system
# If any plugin returns a non-null result, that takes priority
for plugin in registry.with_mixin('validation'):
try:
result = plugin.get_latest_serial_number(self)
if result is not None:
return str(result)
except Exception:
log_error(f'{plugin.slug}.get_latest_serial_number')
# No plugin returned a result, so we will run the default query
stock = ( stock = (
StockModels.StockItem.objects.all().exclude(serial=None).exclude(serial='') StockModels.StockItem.objects.all().exclude(serial=None).exclude(serial='')
) )
# Generate a query for any stock items for this part variant tree with non-empty serial numbers # Generate a query for any stock items for this part variant tree with non-empty serial numbers
if get_global_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False): if not get_global_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False):
# Serial numbers are unique across all parts
pass
else:
# Serial numbers are unique acros part trees # Serial numbers are unique acros part trees
stock = stock.filter(part__tree_id=self.tree_id) stock = stock.filter(part__tree_id=self.tree_id)
@ -903,6 +921,12 @@ class Part(
# Return the first serial value # Return the first serial value
return stock[0].serial return stock[0].serial
def get_next_serial_number(self):
"""Return the 'next' serial number in sequence."""
sn = self.get_latest_serial_number()
return InvenTree.helpers.increment_serial_number(sn, self)
@property @property
def full_name(self) -> str: def full_name(self) -> str:
"""Format a 'full name' for this Part based on the format PART_NAME_FORMAT defined in InvenTree settings.""" """Format a 'full name' for this Part based on the format PART_NAME_FORMAT defined in InvenTree settings."""
@ -3928,6 +3952,8 @@ class PartParameter(InvenTree.models.InvenTreeMetadataModel):
except ValidationError as exc: except ValidationError as exc:
# Re-throw the ValidationError against the 'data' field # Re-throw the ValidationError against the 'data' field
raise ValidationError({'data': exc.message}) raise ValidationError({'data': exc.message})
except Exception:
log_error(f'{plugin.slug}.validate_part_parameter')
def calculate_numeric_value(self): def calculate_numeric_value(self):
"""Calculate a numeric value for the parameter data. """Calculate a numeric value for the parameter data.

View File

@ -191,7 +191,25 @@ class ValidationMixin:
""" """
return None return None
def increment_serial_number(self, serial: str) -> str: def get_latest_serial_number(self, part, **kwargs):
"""Return the 'latest' serial number for a given Part instance.
A plugin which implements this method can either return:
- A string which represents the "latest" serial number
- None (null value) if the latest value could not be determined
Arguments:
part: The Part instance for which the latest serial number is being requested
Returns:
The latest serial number (string), or None
"""
# Default implementation returns None
return None
def increment_serial_number(
self, serial: str, part: part.models.Part = None, **kwargs
) -> str:
"""Return the next sequential serial based on the provided value. """Return the next sequential serial based on the provided value.
A plugin which implements this method can either return: A plugin which implements this method can either return:
@ -201,6 +219,7 @@ class ValidationMixin:
Arguments: Arguments:
serial: Current serial value (string) serial: Current serial value (string)
part: The Part instance for which this serial number is being incremented
Returns: Returns:
The next serial number in the sequence (string), or None The next serial number in the sequence (string), or None

View File

@ -14,7 +14,7 @@ from django.urls.base import reverse
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from InvenTree.helpers import pui_url import InvenTree.helpers
from plugin.helpers import get_git_log from plugin.helpers import get_git_log
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -380,9 +380,9 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
return f'{reverse("settings")}#select-plugin-{self.slug}' return f'{reverse("settings")}#select-plugin-{self.slug}'
config = self.plugin_config() config = self.plugin_config()
if config: if config:
return pui_url(f'/settings/admin/plugin/{config.pk}/') return InvenTree.helpers.pui_url(f'/settings/admin/plugin/{config.pk}/')
else: else:
return pui_url('/settings/admin/plugin/') return InvenTree.helpers.pui_url('/settings/admin/plugin/')
# region package info # region package info
def _get_package_commit(self): def _get_package_commit(self):

View File

@ -135,6 +135,34 @@ class SampleValidatorPlugin(SettingsMixin, ValidationMixin, InvenTreePlugin):
if serial[0] != part.name[0]: if serial[0] != part.name[0]:
self.raise_error('Serial number must start with same letter as part') self.raise_error('Serial number must start with same letter as part')
# Prevent serial numbers which are a multiple of 5
try:
sn = int(serial)
if sn % 5 == 0:
self.raise_error('Serial number cannot be a multiple of 5')
except ValueError:
pass
def increment_serial_number(self, serial: str, part=None, **kwargs):
"""Increment a serial number.
These examples are silly, but serve to demonstrate how the feature could be used
"""
try:
sn = int(serial)
sn += 1
# Skip any serial number which is a multiple of 5
if sn % 5 == 0:
sn += 1
return str(sn)
except ValueError:
pass
# Return "None" to defer to the next plugin or builtin functionality
return None
def validate_batch_code(self, batch_code: str, item): def validate_batch_code(self, batch_code: str, item):
"""Ensure that a particular batch code meets specification. """Ensure that a particular batch code meets specification.

View File

@ -997,7 +997,7 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
# If serial numbers are specified, check that they match! # If serial numbers are specified, check that they match!
try: try:
serials = extract_serial_numbers( serials = extract_serial_numbers(
serial_numbers, quantity, part.get_latest_serial_number() serial_numbers, quantity, part.get_latest_serial_number(), part=part
) )
# Determine if any of the specified serial numbers are invalid # Determine if any of the specified serial numbers are invalid

View File

@ -99,7 +99,7 @@ def generate_serial_number(part=None, quantity=1, **kwargs) -> str:
# Generate the required quantity of serial numbers # Generate the required quantity of serial numbers
# Note that this call gets passed through to the plugin system # Note that this call gets passed through to the plugin system
while quantity > 0: while quantity > 0:
sn = InvenTree.helpers.increment_serial_number(sn) sn = InvenTree.helpers.increment_serial_number(sn, part=part)
# Exit if an empty or duplicated serial is generated # Exit if an empty or duplicated serial is generated
if not sn or sn in serials: if not sn or sn in serials:

View File

@ -1629,8 +1629,9 @@ class StockItem(
existing = self.part.find_conflicting_serial_numbers(serials) existing = self.part.find_conflicting_serial_numbers(serials)
if len(existing) > 0: if len(existing) > 0:
exists = ','.join([str(x) for x in existing]) msg = _('The following serial numbers already exist or are invalid')
msg = _('Serial numbers already exist') + f': {exists}' msg += ' : '
msg += ','.join([str(x) for x in existing])
raise ValidationError({'serial_numbers': msg}) raise ValidationError({'serial_numbers': msg})
# Serialize this StockItem # Serialize this StockItem

View File

@ -728,7 +728,10 @@ class SerializeStockItemSerializer(serializers.Serializer):
try: try:
serials = InvenTree.helpers.extract_serial_numbers( serials = InvenTree.helpers.extract_serial_numbers(
serial_numbers, quantity, item.part.get_latest_serial_number() serial_numbers,
quantity,
item.part.get_latest_serial_number(),
part=item.part,
) )
except DjangoValidationError as e: except DjangoValidationError as e:
raise ValidationError({'serial_numbers': e.messages}) raise ValidationError({'serial_numbers': e.messages})
@ -755,6 +758,7 @@ class SerializeStockItemSerializer(serializers.Serializer):
data['serial_numbers'], data['serial_numbers'],
data['quantity'], data['quantity'],
item.part.get_latest_serial_number(), item.part.get_latest_serial_number(),
part=item.part,
) )
item.serializeStock( item.serializeStock(

View File

@ -1229,14 +1229,20 @@ class TestResultTest(StockTestBase):
self.assertEqual(item2.test_results.count(), 4) self.assertEqual(item2.test_results.count(), 4)
# Test StockItem serialization # Test StockItem serialization
item2.serializeStock(1, [100], self.user) # Note: This will create a new StockItem with a new serial number
with self.assertRaises(ValidationError):
# Serial number #100 will be rejected by the sample plugin
item2.serializeStock(1, [100], self.user)
item2.serializeStock(1, [101], self.user)
# Add a test result to the parent *after* serialization # Add a test result to the parent *after* serialization
item2.add_test_result(test_name='abcde') item2.add_test_result(test_name='abcde')
self.assertEqual(item2.test_results.count(), 5) self.assertEqual(item2.test_results.count(), 5)
item3 = StockItem.objects.get(serial=100, part=item2.part) item3 = StockItem.objects.get(serial=101, part=item2.part)
self.assertEqual(item3.test_results.count(), 4) self.assertEqual(item3.test_results.count(), 4)