mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +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:
parent
c9a4e6bf7d
commit
17bf7bb5e3
@ -2,6 +2,7 @@
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import inspect
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
@ -432,7 +433,7 @@ def DownloadFile(
|
||||
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.
|
||||
|
||||
Note: This method is exposed to custom plugins.
|
||||
@ -443,6 +444,7 @@ def increment_serial_number(serial):
|
||||
Returns:
|
||||
incremented value, or None if incrementing could not be performed.
|
||||
"""
|
||||
from InvenTree.exceptions import log_error
|
||||
from plugin.registry import registry
|
||||
|
||||
# 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
|
||||
for plugin in registry.with_mixin('validation'):
|
||||
result = plugin.increment_serial_number(serial)
|
||||
if result is not None:
|
||||
return str(result)
|
||||
try:
|
||||
if not hasattr(plugin, 'increment_serial_number'):
|
||||
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
|
||||
# Attempt to perform increment according to some basic rules
|
||||
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.
|
||||
|
||||
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)
|
||||
"""
|
||||
if starting_value is None:
|
||||
starting_value = increment_serial_number(None)
|
||||
starting_value = increment_serial_number(None, part=part)
|
||||
|
||||
try:
|
||||
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:
|
||||
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
|
||||
while '~' in input_string and next_value:
|
||||
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
|
||||
groups = re.split(r'[\s,]+', input_string)
|
||||
|
@ -396,7 +396,8 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
||||
self.serials = InvenTree.helpers.extract_serial_numbers(
|
||||
serial_numbers,
|
||||
quantity,
|
||||
part.get_latest_serial_number()
|
||||
part.get_latest_serial_number(),
|
||||
part=part
|
||||
)
|
||||
except DjangoValidationError as e:
|
||||
raise ValidationError({
|
||||
|
@ -835,7 +835,10 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||
try:
|
||||
# Pass the serial numbers through to the parent serializer once validated
|
||||
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:
|
||||
raise ValidationError({'serial_numbers': e.messages})
|
||||
@ -1602,7 +1605,7 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
|
||||
|
||||
try:
|
||||
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:
|
||||
raise ValidationError({'serial_numbers': e.messages})
|
||||
|
@ -29,7 +29,7 @@ from InvenTree.filters import (
|
||||
InvenTreeDateFilter,
|
||||
InvenTreeSearchFilter,
|
||||
)
|
||||
from InvenTree.helpers import increment_serial_number, isNull, str2bool
|
||||
from InvenTree.helpers import isNull, str2bool
|
||||
from InvenTree.mixins import (
|
||||
CreateAPI,
|
||||
CustomRetrieveUpdateDestroyAPI,
|
||||
@ -811,15 +811,10 @@ class PartSerialNumberDetail(RetrieveAPI):
|
||||
part = self.get_object()
|
||||
|
||||
# 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}
|
||||
|
||||
if latest is not None:
|
||||
next_serial = increment_serial_number(latest)
|
||||
|
||||
if next_serial != latest:
|
||||
data['next'] = next_serial
|
||||
data = {'latest': latest_serial, 'next': next_serial}
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
@ -55,6 +55,7 @@ from common.icons import validate_icon
|
||||
from common.settings import get_global_setting
|
||||
from company.models import SupplierPart
|
||||
from InvenTree import helpers, validators
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.fields import InvenTreeURLField
|
||||
from InvenTree.helpers import decimal2money, decimal2string, normalize, str2bool
|
||||
from order import models as OrderModels
|
||||
@ -659,6 +660,8 @@ class Part(
|
||||
except ValidationError as exc:
|
||||
if raise_error:
|
||||
raise ValidationError({'name': exc.message})
|
||||
except Exception:
|
||||
log_error(f'{plugin.slug}.validate_part_name')
|
||||
|
||||
def validate_ipn(self, raise_error=True):
|
||||
"""Ensure that the IPN (internal part number) is valid for this Part".
|
||||
@ -678,6 +681,8 @@ class Part(
|
||||
except ValidationError as exc:
|
||||
if raise_error:
|
||||
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
|
||||
pattern = get_global_setting('PART_IPN_REGEX', '', create=False).strip()
|
||||
@ -792,6 +797,8 @@ class Part(
|
||||
raise exc
|
||||
else:
|
||||
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
|
||||
@ -868,7 +875,7 @@ class Part(
|
||||
|
||||
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.
|
||||
|
||||
Here we attempt to find the "highest" serial number which exists for this Part.
|
||||
@ -881,15 +888,26 @@ class Part(
|
||||
Returns:
|
||||
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 = (
|
||||
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
|
||||
if get_global_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False):
|
||||
# Serial numbers are unique across all parts
|
||||
pass
|
||||
else:
|
||||
if not get_global_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False):
|
||||
# Serial numbers are unique acros part trees
|
||||
stock = stock.filter(part__tree_id=self.tree_id)
|
||||
|
||||
@ -903,6 +921,12 @@ class Part(
|
||||
# Return the first serial value
|
||||
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
|
||||
def full_name(self) -> str:
|
||||
"""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:
|
||||
# Re-throw the ValidationError against the 'data' field
|
||||
raise ValidationError({'data': exc.message})
|
||||
except Exception:
|
||||
log_error(f'{plugin.slug}.validate_part_parameter')
|
||||
|
||||
def calculate_numeric_value(self):
|
||||
"""Calculate a numeric value for the parameter data.
|
||||
|
@ -191,7 +191,25 @@ class ValidationMixin:
|
||||
"""
|
||||
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.
|
||||
|
||||
A plugin which implements this method can either return:
|
||||
@ -201,6 +219,7 @@ class ValidationMixin:
|
||||
|
||||
Arguments:
|
||||
serial: Current serial value (string)
|
||||
part: The Part instance for which this serial number is being incremented
|
||||
|
||||
Returns:
|
||||
The next serial number in the sequence (string), or None
|
||||
|
@ -14,7 +14,7 @@ from django.urls.base import reverse
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from InvenTree.helpers import pui_url
|
||||
import InvenTree.helpers
|
||||
from plugin.helpers import get_git_log
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@ -380,9 +380,9 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
return f'{reverse("settings")}#select-plugin-{self.slug}'
|
||||
config = self.plugin_config()
|
||||
if config:
|
||||
return pui_url(f'/settings/admin/plugin/{config.pk}/')
|
||||
return InvenTree.helpers.pui_url(f'/settings/admin/plugin/{config.pk}/')
|
||||
else:
|
||||
return pui_url('/settings/admin/plugin/')
|
||||
return InvenTree.helpers.pui_url('/settings/admin/plugin/')
|
||||
|
||||
# region package info
|
||||
def _get_package_commit(self):
|
||||
|
@ -135,6 +135,34 @@ class SampleValidatorPlugin(SettingsMixin, ValidationMixin, InvenTreePlugin):
|
||||
if serial[0] != part.name[0]:
|
||||
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):
|
||||
"""Ensure that a particular batch code meets specification.
|
||||
|
||||
|
@ -997,7 +997,7 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
|
||||
# If serial numbers are specified, check that they match!
|
||||
try:
|
||||
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
|
||||
|
@ -99,7 +99,7 @@ def generate_serial_number(part=None, quantity=1, **kwargs) -> str:
|
||||
# Generate the required quantity of serial numbers
|
||||
# Note that this call gets passed through to the plugin system
|
||||
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
|
||||
if not sn or sn in serials:
|
||||
|
@ -1629,8 +1629,9 @@ class StockItem(
|
||||
existing = self.part.find_conflicting_serial_numbers(serials)
|
||||
|
||||
if len(existing) > 0:
|
||||
exists = ','.join([str(x) for x in existing])
|
||||
msg = _('Serial numbers already exist') + f': {exists}'
|
||||
msg = _('The following serial numbers already exist or are invalid')
|
||||
msg += ' : '
|
||||
msg += ','.join([str(x) for x in existing])
|
||||
raise ValidationError({'serial_numbers': msg})
|
||||
|
||||
# Serialize this StockItem
|
||||
|
@ -728,7 +728,10 @@ class SerializeStockItemSerializer(serializers.Serializer):
|
||||
|
||||
try:
|
||||
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:
|
||||
raise ValidationError({'serial_numbers': e.messages})
|
||||
@ -755,6 +758,7 @@ class SerializeStockItemSerializer(serializers.Serializer):
|
||||
data['serial_numbers'],
|
||||
data['quantity'],
|
||||
item.part.get_latest_serial_number(),
|
||||
part=item.part,
|
||||
)
|
||||
|
||||
item.serializeStock(
|
||||
|
@ -1229,14 +1229,20 @@ class TestResultTest(StockTestBase):
|
||||
self.assertEqual(item2.test_results.count(), 4)
|
||||
|
||||
# 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
|
||||
item2.add_test_result(test_name='abcde')
|
||||
|
||||
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)
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user