mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-05 13:10:57 +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:
@ -529,112 +529,126 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
|
||||
return result
|
||||
|
||||
def checkIfSerialNumberExists(self, sn, exclude_self=False):
|
||||
"""Check if a serial number exists for this Part.
|
||||
def validate_serial_number(self, serial: str, stock_item=None, check_duplicates=True, raise_error=False):
|
||||
"""Validate a serial number against this Part instance.
|
||||
|
||||
Note: Serial numbers must be unique across an entire Part "tree", so here we filter by the entire tree.
|
||||
Note: This function is exposed to any Validation plugins, and thus can be customized.
|
||||
|
||||
Any plugins which implement the 'validate_serial_number' method have three possible outcomes:
|
||||
|
||||
- Decide the serial is objectionable and raise a django.core.exceptions.ValidationError
|
||||
- Decide the serial is acceptable, and return None to proceed to other tests
|
||||
- Decide the serial is acceptable, and return True to skip any further tests
|
||||
|
||||
Arguments:
|
||||
serial: The proposed serial number
|
||||
stock_item: (optional) A StockItem instance which has this serial number assigned (e.g. testing for duplicates)
|
||||
raise_error: If False, and ValidationError(s) will be handled
|
||||
|
||||
Returns:
|
||||
True if serial number is 'valid' else False
|
||||
|
||||
Raises:
|
||||
ValidationError if serial number is invalid and raise_error = True
|
||||
"""
|
||||
parts = Part.objects.filter(tree_id=self.tree_id)
|
||||
|
||||
stock = StockModels.StockItem.objects.filter(part__in=parts, serial=sn)
|
||||
serial = str(serial).strip()
|
||||
|
||||
if exclude_self:
|
||||
stock = stock.exclude(pk=self.pk)
|
||||
# First, throw the serial number against each of the loaded validation plugins
|
||||
from plugin.registry import registry
|
||||
|
||||
return stock.exists()
|
||||
try:
|
||||
for plugin in registry.with_mixin('validation'):
|
||||
# Run the serial number through each custom validator
|
||||
# If the plugin returns 'True' we will skip any subsequent validation
|
||||
if plugin.validate_serial_number(serial):
|
||||
return True
|
||||
except ValidationError as exc:
|
||||
if raise_error:
|
||||
# Re-throw the error
|
||||
raise exc
|
||||
else:
|
||||
return False
|
||||
|
||||
def find_conflicting_serial_numbers(self, serials):
|
||||
"""
|
||||
If we are here, none of the loaded plugins (if any) threw an error or exited early
|
||||
|
||||
Now, we run the "default" serial number validation routine,
|
||||
which checks that the serial number is not duplicated
|
||||
"""
|
||||
|
||||
if not check_duplicates:
|
||||
return
|
||||
|
||||
from part.models import Part
|
||||
from stock.models import StockItem
|
||||
|
||||
if common.models.InvenTreeSetting.get_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False):
|
||||
# Serial number must be unique across *all* parts
|
||||
parts = Part.objects.all()
|
||||
else:
|
||||
# Serial number must only be unique across this part "tree"
|
||||
parts = Part.objects.filter(tree_id=self.tree_id)
|
||||
|
||||
stock = StockItem.objects.filter(part__in=parts, serial=serial)
|
||||
|
||||
if stock_item:
|
||||
# Exclude existing StockItem from query
|
||||
stock = stock.exclude(pk=stock_item.pk)
|
||||
|
||||
if stock.exists():
|
||||
if raise_error:
|
||||
raise ValidationError(_("Stock item with this serial number already exists") + ": " + serial)
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
# This serial number is perfectly valid
|
||||
return True
|
||||
|
||||
def find_conflicting_serial_numbers(self, serials: list):
|
||||
"""For a provided list of serials, return a list of those which are conflicting."""
|
||||
|
||||
conflicts = []
|
||||
|
||||
for serial in serials:
|
||||
if self.checkIfSerialNumberExists(serial, exclude_self=True):
|
||||
if not self.validate_serial_number(serial):
|
||||
conflicts.append(serial)
|
||||
|
||||
return conflicts
|
||||
|
||||
def getLatestSerialNumber(self):
|
||||
"""Return the "latest" serial number for this Part.
|
||||
def get_latest_serial_number(self):
|
||||
"""Find the 'latest' serial number for this Part.
|
||||
|
||||
If *all* the serial numbers are integers, then this will return the highest one.
|
||||
Otherwise, it will simply return the serial number most recently added.
|
||||
Here we attempt to find the "highest" serial number which exists for this Part.
|
||||
There are a number of edge cases where this method can fail,
|
||||
but this is accepted to keep database performance at a reasonable level.
|
||||
|
||||
Note: Serial numbers must be unique across an entire Part "tree",
|
||||
so we filter by the entire tree.
|
||||
"""
|
||||
parts = Part.objects.filter(tree_id=self.tree_id)
|
||||
stock = StockModels.StockItem.objects.filter(part__in=parts).exclude(serial=None)
|
||||
|
||||
# There are no matchin StockItem objects (skip further tests)
|
||||
Returns:
|
||||
The latest serial number specified for this part, or None
|
||||
"""
|
||||
|
||||
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 common.models.InvenTreeSetting.get_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False):
|
||||
# Serial numbers are unique across all parts
|
||||
pass
|
||||
else:
|
||||
# Serial numbers are unique acros part trees
|
||||
stock = stock.filter(part__tree_id=self.tree_id)
|
||||
|
||||
# There are no matching StockItem objects (skip further tests)
|
||||
if not stock.exists():
|
||||
return None
|
||||
|
||||
# Attempt to coerce the returned serial numbers to integers
|
||||
# If *any* are not integers, fail!
|
||||
try:
|
||||
ordered = sorted(stock.all(), reverse=True, key=lambda n: int(n.serial))
|
||||
# Sort in descending order
|
||||
stock = stock.order_by('-serial_int', '-serial', '-pk')
|
||||
|
||||
if len(ordered) > 0:
|
||||
return ordered[0].serial
|
||||
|
||||
# One or more of the serial numbers was non-numeric
|
||||
# In this case, the "best" we can do is return the most recent
|
||||
except ValueError:
|
||||
return stock.last().serial
|
||||
|
||||
# No serial numbers found
|
||||
return None
|
||||
|
||||
def getLatestSerialNumberInt(self):
|
||||
"""Return the "latest" serial number for this Part as a integer.
|
||||
|
||||
If it is not an integer the result is 0
|
||||
"""
|
||||
latest = self.getLatestSerialNumber()
|
||||
|
||||
# No serial number = > 0
|
||||
if latest is None:
|
||||
latest = 0
|
||||
|
||||
# Attempt to turn into an integer and return
|
||||
try:
|
||||
latest = int(latest)
|
||||
return latest
|
||||
except Exception:
|
||||
# not an integer so 0
|
||||
return 0
|
||||
|
||||
def getSerialNumberString(self, quantity=1):
|
||||
"""Return a formatted string representing the next available serial numbers, given a certain quantity of items."""
|
||||
latest = self.getLatestSerialNumber()
|
||||
|
||||
quantity = int(quantity)
|
||||
|
||||
# No serial numbers can be found, assume 1 as the first serial
|
||||
if latest is None:
|
||||
latest = 0
|
||||
|
||||
# Attempt to turn into an integer
|
||||
try:
|
||||
latest = int(latest)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if type(latest) is int:
|
||||
|
||||
if quantity >= 2:
|
||||
text = '{n} - {m}'.format(n=latest + 1, m=latest + 1 + quantity)
|
||||
|
||||
return _('Next available serial numbers are') + ' ' + text
|
||||
else:
|
||||
text = str(latest + 1)
|
||||
|
||||
return _('Next available serial number is') + ' ' + text
|
||||
|
||||
else:
|
||||
# Non-integer values, no option but to return latest
|
||||
|
||||
return _('Most recent serial number is') + ' ' + str(latest)
|
||||
# Return the first serial value
|
||||
return stock[0].serial
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
|
Reference in New Issue
Block a user