mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +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:
		@@ -25,8 +25,8 @@ from company.models import Company, ManufacturerPart, SupplierPart
 | 
			
		||||
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
 | 
			
		||||
                           ListCreateDestroyAPIView)
 | 
			
		||||
from InvenTree.filters import InvenTreeOrderingFilter
 | 
			
		||||
from InvenTree.helpers import (DownloadFile, increment, isNull, str2bool,
 | 
			
		||||
                               str2int)
 | 
			
		||||
from InvenTree.helpers import (DownloadFile, increment_serial_number, isNull,
 | 
			
		||||
                               str2bool, str2int)
 | 
			
		||||
from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, RetrieveAPI,
 | 
			
		||||
                              RetrieveUpdateAPI, RetrieveUpdateDestroyAPI,
 | 
			
		||||
                              UpdateAPI)
 | 
			
		||||
@@ -717,16 +717,16 @@ class PartSerialNumberDetail(RetrieveAPI):
 | 
			
		||||
        part = self.get_object()
 | 
			
		||||
 | 
			
		||||
        # Calculate the "latest" serial number
 | 
			
		||||
        latest = part.getLatestSerialNumber()
 | 
			
		||||
        latest = part.get_latest_serial_number()
 | 
			
		||||
 | 
			
		||||
        data = {
 | 
			
		||||
            'latest': latest,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if latest is not None:
 | 
			
		||||
            next_serial = increment(latest)
 | 
			
		||||
            next_serial = increment_serial_number(latest)
 | 
			
		||||
 | 
			
		||||
            if next_serial != increment:
 | 
			
		||||
            if next_serial != latest:
 | 
			
		||||
                data['next'] = next_serial
 | 
			
		||||
 | 
			
		||||
        return Response(data)
 | 
			
		||||
 
 | 
			
		||||
@@ -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):
 | 
			
		||||
 
 | 
			
		||||
@@ -323,12 +323,13 @@
 | 
			
		||||
                        {% endif %}
 | 
			
		||||
                    </td>
 | 
			
		||||
                </tr>
 | 
			
		||||
                {% if part.trackable and part.getLatestSerialNumber %}
 | 
			
		||||
                {% with part.get_latest_serial_number as sn %}
 | 
			
		||||
                {% if part.trackable and sn %}
 | 
			
		||||
                <tr>
 | 
			
		||||
                    <td><span class='fas fa-hashtag'></span></td>
 | 
			
		||||
                    <td>{% trans "Latest Serial Number" %}</td>
 | 
			
		||||
                    <td>
 | 
			
		||||
                        {{ part.getLatestSerialNumber }}
 | 
			
		||||
                        {{ sn }}
 | 
			
		||||
                        <div class='btn-group float-right' role='group'>
 | 
			
		||||
                            <a class='btn btn-small btn-outline-secondary text-sm' href='#' id='serial-number-search' title='{% trans "Search for serial number" %}'>
 | 
			
		||||
                                <span class='fas fa-search'></span>
 | 
			
		||||
@@ -337,6 +338,7 @@
 | 
			
		||||
                    </td>
 | 
			
		||||
                </tr>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                {% endwith %}
 | 
			
		||||
                {% if part.default_location %}
 | 
			
		||||
                <tr>
 | 
			
		||||
                    <td><span class='fas fa-search-location'></span></td>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user