[Plugin] Enhanced custom validation (#6410)

* Use registry.get_plugin()

- Instead of registry.plugins.get()
- get_plugin checks registry hash
- performs registry reload if necessary

* Add PluginValidationMixin class

- Allows the entire model to be validated via plugins
- Called on model.full_clean()
- Called on model.save()

* Update Validation sample plugin

* Fix for InvenTreeTree models

* Refactor build.models

- Expose models to plugin validation

* Update stock.models

* Update more models

- common.models
- company.models

* Update more models

- label.models
- order.models
- part.models

* More model updates

* Update docs

* Fix for potential plugin edge case

- plugin slug is globally unique
- do not use get_or_create with two lookup fields
- will throw an IntegrityError if you change the name of a plugin

* Inherit DiffMixin into PluginValidationMixin

- Allows us to pass model diffs through to validation
- Plugins can validate based on what has *changed*

* Update documentation

* Add get_plugin_config helper function

* Bug fix

* Bug fix

* Update plugin hash when calling set_plugin_state

* Working on unit testing

* More unit testing

* Move get_plugin_config into registry.py

* Move extract_int into InvenTree.helpers

* Fix log formatting

* Update model definitions

- Ensure there are no changes to the migrations

* Comment out format line

* Fix access to get_plugin_config

* Fix tests for SimpleActionPlugin

* More unit test fixes
This commit is contained in:
Oliver 2024-02-06 22:00:22 +11:00 committed by GitHub
parent dce2954466
commit 2b9816d1a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 618 additions and 253 deletions

View File

@ -16,7 +16,7 @@ repos:
- id: check-yaml
- id: mixed-line-ending
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.11
rev: v0.2.1
hooks:
- id: ruff-format
args: [--preview]
@ -37,7 +37,7 @@ repos:
args: [requirements.in, -o, requirements.txt]
files: ^requirements\.(in|txt)$
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.34.0
rev: v1.34.1
hooks:
- id: djlint-django
- repo: https://github.com/codespell-project/codespell
@ -52,7 +52,7 @@ repos:
src/frontend/src/locales/.* |
)$
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v3.0.3"
rev: "v4.0.0-alpha.8"
hooks:
- id: prettier
files: ^src/frontend/.*\.(js|jsx|ts|tsx)$
@ -60,7 +60,7 @@ repos:
- "prettier@^2.4.1"
- "@trivago/prettier-plugin-sort-imports"
- repo: https://github.com/pre-commit/mirrors-eslint
rev: "v8.51.0"
rev: "v9.0.0-alpha.2"
hooks:
- id: eslint
additional_dependencies:

View File

@ -30,6 +30,51 @@ from .settings import MEDIA_URL, STATIC_URL
logger = logging.getLogger('inventree')
def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False):
"""Extract an integer out of reference."""
# Default value if we cannot convert to an integer
ref_int = 0
reference = str(reference).strip()
# Ignore empty string
if len(reference) == 0:
return 0
# Look at the start of the string - can it be "integerized"?
result = re.match(r'^(\d+)', reference)
if result and len(result.groups()) == 1:
ref = result.groups()[0]
try:
ref_int = int(ref)
except Exception:
ref_int = 0
else:
# Look at the "end" of the string
result = re.search(r'(\d+)$', reference)
if result and len(result.groups()) == 1:
ref = result.groups()[0]
try:
ref_int = int(ref)
except Exception:
ref_int = 0
# Ensure that the returned values are within the range that can be stored in an IntegerField
# Note: This will result in large values being "clipped"
if clip is not None:
if ref_int > clip:
ref_int = clip
elif ref_int < -clip:
ref_int = -clip
if not allow_negative and ref_int < 0:
ref_int = abs(ref_int)
return ref_int
def generateTestKey(test_name):
"""Generate a test 'key' for a given test name. This must not have illegal chars as it will be used for dict lookup in a template.

View File

@ -9,56 +9,6 @@ from InvenTree.fields import InvenTreeNotesField
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
class DiffMixin:
"""Mixin which can be used to determine which fields have changed, compared to the instance saved to the database."""
def get_db_instance(self):
"""Return the instance of the object saved in the database.
Returns:
object: Instance of the object saved in the database
"""
if self.pk:
try:
return self.__class__.objects.get(pk=self.pk)
except self.__class__.DoesNotExist:
pass
return None
def get_field_deltas(self):
"""Return a dict of field deltas.
Compares the current instance with the instance saved in the database,
and returns a dict of fields which have changed.
Returns:
dict: Dict of field deltas
"""
db_instance = self.get_db_instance()
if db_instance is None:
return {}
deltas = {}
for field in self._meta.fields:
if field.name == 'id':
continue
if getattr(self, field.name) != getattr(db_instance, field.name):
deltas[field.name] = {
'old': getattr(db_instance, field.name),
'new': getattr(self, field.name),
}
return deltas
def has_field_changed(self, field_name):
"""Determine if a particular field has changed."""
return field_name in self.get_field_deltas()
class CleanMixin:
"""Model mixin class which cleans inputs using the Mozilla bleach tools."""

View File

@ -2,7 +2,6 @@
import logging
import os
import re
from datetime import datetime
from io import BytesIO
@ -30,18 +29,90 @@ from InvenTree.sanitizer import sanitize_svg
logger = logging.getLogger('inventree')
def rename_attachment(instance, filename):
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.
class DiffMixin:
"""Mixin which can be used to determine which fields have changed, compared to the instance saved to the database."""
Args:
instance: Instance of a PartAttachment object
filename: name of uploaded file
def get_db_instance(self):
"""Return the instance of the object saved in the database.
Returns:
path to store file, format: '<subdir>/<id>/filename'
Returns:
object: Instance of the object saved in the database
"""
if self.pk:
try:
return self.__class__.objects.get(pk=self.pk)
except self.__class__.DoesNotExist:
pass
return None
def get_field_deltas(self):
"""Return a dict of field deltas.
Compares the current instance with the instance saved in the database,
and returns a dict of fields which have changed.
Returns:
dict: Dict of field deltas
"""
db_instance = self.get_db_instance()
if db_instance is None:
return {}
deltas = {}
for field in self._meta.fields:
if field.name == 'id':
continue
if getattr(self, field.name) != getattr(db_instance, field.name):
deltas[field.name] = {
'old': getattr(db_instance, field.name),
'new': getattr(self, field.name),
}
return deltas
def has_field_changed(self, field_name):
"""Determine if a particular field has changed."""
return field_name in self.get_field_deltas()
class PluginValidationMixin(DiffMixin):
"""Mixin class which exposes the model instance to plugin validation.
Any model class which inherits from this mixin will be exposed to the plugin validation system.
"""
# Construct a path to store a file attachment for a given model type
return os.path.join(instance.getSubdir(), filename)
def run_plugin_validation(self):
"""Throw this model against the plugin validation interface."""
from plugin.registry import registry
deltas = self.get_field_deltas()
for plugin in registry.with_mixin('validation'):
try:
if plugin.validate_model_instance(self, deltas=deltas) is True:
return
except ValidationError as exc:
raise exc
def full_clean(self):
"""Run plugin validation on full model clean.
Note that plugin validation is performed *after* super.full_clean()
"""
super().full_clean()
self.run_plugin_validation()
def save(self, *args, **kwargs):
"""Run plugin validation on model save.
Note that plugin validation is performed *before* super.save()
"""
self.run_plugin_validation()
super().save(*args, **kwargs)
class MetadataMixin(models.Model):
@ -377,7 +448,7 @@ class ReferenceIndexingMixin(models.Model):
except Exception:
pass
reference_int = extract_int(reference)
reference_int = InvenTree.helpers.extract_int(reference)
if validate:
if reference_int > models.BigIntegerField.MAX_BIGINT:
@ -388,52 +459,44 @@ class ReferenceIndexingMixin(models.Model):
reference_int = models.BigIntegerField(default=0)
def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False):
"""Extract an integer out of reference."""
# Default value if we cannot convert to an integer
ref_int = 0
class InvenTreeModel(PluginValidationMixin, models.Model):
"""Base class for InvenTree models, which provides some common functionality.
reference = str(reference).strip()
Includes the following mixins by default:
# Ignore empty string
if len(reference) == 0:
return 0
- PluginValidationMixin: Provides a hook for plugins to validate model instances
"""
# Look at the start of the string - can it be "integerized"?
result = re.match(r'^(\d+)', reference)
class Meta:
"""Metaclass options."""
if result and len(result.groups()) == 1:
ref = result.groups()[0]
try:
ref_int = int(ref)
except Exception:
ref_int = 0
else:
# Look at the "end" of the string
result = re.search(r'(\d+)$', reference)
if result and len(result.groups()) == 1:
ref = result.groups()[0]
try:
ref_int = int(ref)
except Exception:
ref_int = 0
# Ensure that the returned values are within the range that can be stored in an IntegerField
# Note: This will result in large values being "clipped"
if clip is not None:
if ref_int > clip:
ref_int = clip
elif ref_int < -clip:
ref_int = -clip
if not allow_negative and ref_int < 0:
ref_int = abs(ref_int)
return ref_int
abstract = True
class InvenTreeAttachment(models.Model):
class InvenTreeMetadataModel(MetadataMixin, InvenTreeModel):
"""Base class for an InvenTree model which includes a metadata field."""
class Meta:
"""Metaclass options."""
abstract = True
def rename_attachment(instance, filename):
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.
Args:
instance: Instance of a PartAttachment object
filename: name of uploaded file
Returns:
path to store file, format: '<subdir>/<id>/filename'
"""
# Construct a path to store a file attachment for a given model type
return os.path.join(instance.getSubdir(), filename)
class InvenTreeAttachment(InvenTreeModel):
"""Provides an abstracted class for managing file attachments.
An attachment can be either an uploaded file, or an external URL
@ -615,7 +678,7 @@ class InvenTreeAttachment(models.Model):
return ''
class InvenTreeTree(MPTTModel):
class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
"""Provides an abstracted self-referencing tree model for data categories.
- Each Category has one parent Category, which can be blank (for a top-level Category).

View File

@ -334,7 +334,10 @@ def setting_object(key, *args, **kwargs):
plg = kwargs['plugin']
if issubclass(plg.__class__, InvenTreePlugin):
plg = plg.plugin_config()
try:
plg = plg.plugin_config()
except plugin.models.PluginConfig.DoesNotExist:
return None
return plugin.models.PluginSetting.get_setting_object(
key, plugin=plg, cache=cache

View File

@ -28,7 +28,6 @@ from build.validators import generate_next_build_reference, validate_build_order
import InvenTree.fields
import InvenTree.helpers
import InvenTree.helpers_model
import InvenTree.mixins
import InvenTree.models
import InvenTree.ready
import InvenTree.tasks
@ -45,7 +44,7 @@ import users.models
logger = logging.getLogger('inventree')
class Build(MPTTModel, InvenTree.mixins.DiffMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.ReferenceIndexingMixin):
class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.PluginValidationMixin, InvenTree.models.ReferenceIndexingMixin, MPTTModel):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
Attributes:
@ -1247,7 +1246,7 @@ class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
class BuildLine(models.Model):
class BuildLine(InvenTree.models.InvenTreeModel):
"""A BuildLine object links a BOMItem to a Build.
When a new Build is created, the BuildLine objects are created automatically.
@ -1326,7 +1325,7 @@ class BuildLine(models.Model):
return self.allocated_quantity() > self.quantity
class BuildItem(InvenTree.models.MetadataMixin, models.Model):
class BuildItem(InvenTree.models.InvenTreeMetadataModel):
"""A BuildItem links multiple StockItem objects to a Build.
These are used to allocate part stock to a build. Once the Build is completed, the parts are removed from stock and the BuildItemAllocation objects are removed.

View File

@ -111,7 +111,7 @@ class BaseURLValidator(URLValidator):
super().__call__(value)
class ProjectCode(InvenTree.models.MetadataMixin, models.Model):
class ProjectCode(InvenTree.models.InvenTreeMetadataModel):
"""A ProjectCode is a unique identifier for a project."""
@staticmethod

View File

@ -24,17 +24,12 @@ import common.settings
import InvenTree.conversion
import InvenTree.fields
import InvenTree.helpers
import InvenTree.models
import InvenTree.ready
import InvenTree.tasks
import InvenTree.validators
from common.settings import currency_code_default
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from InvenTree.models import (
InvenTreeAttachment,
InvenTreeBarcodeMixin,
InvenTreeNotesMixin,
MetadataMixin,
)
from InvenTree.status_codes import PurchaseOrderStatusGroups
@ -63,7 +58,9 @@ def rename_company_image(instance, filename):
return os.path.join(base, fn)
class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
class Company(
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeMetadataModel
):
"""A Company object represents an external company.
It may be a supplier or a customer or a manufacturer (or a combination)
@ -250,7 +247,7 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model):
).distinct()
class CompanyAttachment(InvenTreeAttachment):
class CompanyAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file or URL attachments against a Company object."""
@staticmethod
@ -270,7 +267,7 @@ class CompanyAttachment(InvenTreeAttachment):
)
class Contact(MetadataMixin, models.Model):
class Contact(InvenTree.models.InvenTreeMetadataModel):
"""A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects.
Attributes:
@ -299,7 +296,7 @@ class Contact(MetadataMixin, models.Model):
role = models.CharField(max_length=100, blank=True)
class Address(models.Model):
class Address(InvenTree.models.InvenTreeModel):
"""An address represents a physical location where the company is located. It is possible for a company to have multiple locations.
Attributes:
@ -454,7 +451,9 @@ class Address(models.Model):
)
class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model):
class ManufacturerPart(
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeMetadataModel
):
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
Attributes:
@ -555,7 +554,7 @@ class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model):
return s
class ManufacturerPartAttachment(InvenTreeAttachment):
class ManufacturerPartAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a ManufacturerPart object."""
@staticmethod
@ -575,7 +574,7 @@ class ManufacturerPartAttachment(InvenTreeAttachment):
)
class ManufacturerPartParameter(models.Model):
class ManufacturerPartParameter(InvenTree.models.InvenTreeModel):
"""A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
This is used to represent parameters / properties for a particular manufacturer part.
@ -640,7 +639,12 @@ class SupplierPartManager(models.Manager):
)
class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin):
class SupplierPart(
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeBarcodeMixin,
common.models.MetaMixin,
InvenTree.models.InvenTreeModel,
):
"""Represents a unique part as provided by a Supplier Each SupplierPart is identified by a SKU (Supplier Part Number) Each SupplierPart is also linked to a Part or ManufacturerPart object. A Part may be available from multiple suppliers.
Attributes:

View File

@ -15,11 +15,11 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import build.models
import InvenTree.models
import part.models
import stock.models
from InvenTree.helpers import normalize, validateFilterString
from InvenTree.helpers_model import get_base_url
from InvenTree.models import MetadataMixin
from plugin.registry import registry
try:
@ -88,7 +88,7 @@ class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
self.pdf_filename = kwargs.get('filename', 'label.pdf')
class LabelTemplate(MetadataMixin, models.Model):
class LabelTemplate(InvenTree.models.InvenTreeMetadataModel):
"""Base class for generic, filterable labels."""
class Meta:

View File

@ -24,6 +24,7 @@ from mptt.models import TreeForeignKey
import common.models as common_models
import InvenTree.helpers
import InvenTree.models
import InvenTree.ready
import InvenTree.tasks
import InvenTree.validators
@ -42,13 +43,6 @@ from InvenTree.fields import (
)
from InvenTree.helpers import decimal2string
from InvenTree.helpers_model import getSetting, notify_responsible
from InvenTree.models import (
InvenTreeAttachment,
InvenTreeBarcodeMixin,
InvenTreeNotesMixin,
MetadataMixin,
ReferenceIndexingMixin,
)
from InvenTree.status_codes import (
PurchaseOrderStatus,
PurchaseOrderStatusGroups,
@ -188,10 +182,11 @@ class TotalPriceMixin(models.Model):
class Order(
StateTransitionMixin,
InvenTreeBarcodeMixin,
InvenTreeNotesMixin,
MetadataMixin,
ReferenceIndexingMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.ReferenceIndexingMixin,
InvenTree.models.InvenTreeModel,
):
"""Abstract model for an order.
@ -1178,7 +1173,7 @@ def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs
notify_responsible(instance, sender, exclude=instance.created_by)
class PurchaseOrderAttachment(InvenTreeAttachment):
class PurchaseOrderAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a PurchaseOrder object."""
@staticmethod
@ -1195,7 +1190,7 @@ class PurchaseOrderAttachment(InvenTreeAttachment):
)
class SalesOrderAttachment(InvenTreeAttachment):
class SalesOrderAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a SalesOrder object."""
@staticmethod
@ -1212,7 +1207,7 @@ class SalesOrderAttachment(InvenTreeAttachment):
)
class OrderLineItem(MetadataMixin, models.Model):
class OrderLineItem(InvenTree.models.InvenTreeMetadataModel):
"""Abstract model for an order line item.
Attributes:
@ -1587,7 +1582,11 @@ class SalesOrderLineItem(OrderLineItem):
return self.shipped >= self.quantity
class SalesOrderShipment(InvenTreeNotesMixin, MetadataMixin, models.Model):
class SalesOrderShipment(
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeModel,
):
"""The SalesOrderShipment model represents a physical shipment made against a SalesOrder.
- Points to a single SalesOrder object
@ -2228,7 +2227,7 @@ class ReturnOrderExtraLine(OrderExtraLine):
)
class ReturnOrderAttachment(InvenTreeAttachment):
class ReturnOrderAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a ReturnOrder object."""
@staticmethod

View File

@ -37,6 +37,7 @@ import common.models
import common.settings
import InvenTree.conversion
import InvenTree.fields
import InvenTree.models
import InvenTree.ready
import InvenTree.tasks
import part.helpers as part_helpers
@ -49,14 +50,6 @@ from company.models import SupplierPart
from InvenTree import helpers, validators
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2money, decimal2string, normalize, str2bool
from InvenTree.models import (
DataImportMixin,
InvenTreeAttachment,
InvenTreeBarcodeMixin,
InvenTreeNotesMixin,
InvenTreeTree,
MetadataMixin,
)
from InvenTree.status_codes import (
BuildStatusGroups,
PurchaseOrderStatus,
@ -70,7 +63,7 @@ from stock import models as StockModels
logger = logging.getLogger('inventree')
class PartCategory(MetadataMixin, InvenTreeTree):
class PartCategory(InvenTree.models.InvenTreeTree):
"""PartCategory provides hierarchical organization of Part objects.
Attributes:
@ -341,7 +334,13 @@ class PartManager(TreeManager):
@cleanup.ignore
class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel):
class Part(
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin,
MPTTModel,
):
"""The Part object represents an abstract part, the 'concept' of an actual entity.
An actual physical instance of a Part is a StockItem which is treated separately.
@ -3260,7 +3259,7 @@ class PartStocktakeReport(models.Model):
)
class PartAttachment(InvenTreeAttachment):
class PartAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a Part object."""
@staticmethod
@ -3381,7 +3380,7 @@ class PartCategoryStar(models.Model):
)
class PartTestTemplate(MetadataMixin, models.Model):
class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
"""A PartTestTemplate defines a 'template' for a test which is required to be run against a StockItem (an instance of the Part).
The test template applies "recursively" to part variants, allowing tests to be
@ -3491,7 +3490,7 @@ def validate_template_name(name):
"""Placeholder for legacy function used in migrations."""
class PartParameterTemplate(MetadataMixin, models.Model):
class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
"""A PartParameterTemplate provides a template for key:value pairs for extra parameters fields/values to be added to a Part.
This allows users to arbitrarily assign data fields to a Part beyond the built-in attributes.
@ -3636,7 +3635,7 @@ def post_save_part_parameter_template(sender, instance, created, **kwargs):
)
class PartParameter(MetadataMixin, models.Model):
class PartParameter(InvenTree.models.InvenTreeMetadataModel):
"""A PartParameter is a specific instance of a PartParameterTemplate. It assigns a particular parameter <key:value> pair to a part.
Attributes:
@ -3778,7 +3777,7 @@ class PartParameter(MetadataMixin, models.Model):
return part_parameter
class PartCategoryParameterTemplate(MetadataMixin, models.Model):
class PartCategoryParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
"""A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a PartParameterTemplate.
Multiple PartParameterTemplate instances can be associated to a PartCategory to drive a default list of parameter templates attached to a Part instance upon creation.
@ -3830,7 +3829,11 @@ class PartCategoryParameterTemplate(MetadataMixin, models.Model):
)
class BomItem(DataImportMixin, MetadataMixin, models.Model):
class BomItem(
InvenTree.models.DataImportMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeModel,
):
"""A BomItem links a part to its component items.
A part can have a BOM (bill of materials) which defines
@ -4262,7 +4265,7 @@ def update_pricing_after_delete(sender, instance, **kwargs):
instance.part.schedule_pricing_update(create=False)
class BomItemSubstitute(MetadataMixin, models.Model):
class BomItemSubstitute(InvenTree.models.InvenTreeMetadataModel):
"""A BomItemSubstitute provides a specification for alternative parts, which can be used in a bill of materials.
Attributes:
@ -4320,7 +4323,7 @@ class BomItemSubstitute(MetadataMixin, models.Model):
)
class PartRelated(MetadataMixin, models.Model):
class PartRelated(InvenTree.models.InvenTreeMetadataModel):
"""Store and handle related parts (eg. mating connector, crimps, etc.)."""
class Meta:

View File

@ -1373,7 +1373,7 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
sub_part_detail = kwargs.pop('sub_part_detail', False)
pricing = kwargs.pop('pricing', True)
super(BomItemSerializer, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
if not part_detail:
self.fields.pop('part_detail')

View File

@ -75,12 +75,11 @@ class SettingsMixin:
def set_setting(self, key, value, user=None):
"""Set plugin setting value by key."""
from plugin.models import PluginConfig, PluginSetting
from plugin.models import PluginSetting
from plugin.registry import registry
try:
plugin, _ = PluginConfig.objects.get_or_create(
key=self.plugin_slug(), name=self.plugin_name()
)
plugin = registry.get_plugin_config(self.plugin_slug(), self.plugin_name())
except (OperationalError, ProgrammingError): # pragma: no cover
plugin = None

View File

@ -1,5 +1,7 @@
"""Validation mixin class definition."""
from django.core.exceptions import ValidationError
import part.models
import stock.models
@ -7,7 +9,10 @@ import stock.models
class ValidationMixin:
"""Mixin class that allows custom validation for various parts of InvenTree.
Custom generation and validation functionality can be provided for:
Any model which inherits from the PluginValidationMixin class is exposed here,
via the 'validate_model_instance' method (see below).
Additionally, custom generation and validation functionality is provided for:
- Part names
- Part IPN (internal part number) values
@ -40,6 +45,28 @@ class ValidationMixin:
super().__init__()
self.add_mixin('validation', True, __class__)
def raise_error(self, message):
"""Raise a ValidationError with the given message."""
raise ValidationError(message)
def validate_model_instance(self, instance, deltas=None):
"""Run custom validation on a database model instance.
This method is called when a model instance is being validated.
It allows the plugin to raise a ValidationError on any field in the model.
Arguments:
instance: The model instance to validate
deltas: A dictionary of field names and updated values (if the instance is being updated)
Returns:
None or True (refer to class docstring)
Raises:
ValidationError if the instance is invalid
"""
return None
def validate_part_name(self, name: str, part: part.models.Part):
"""Perform validation on a proposed Part name.

View File

@ -348,12 +348,16 @@ class PanelMixinTests(InvenTreeTestCase):
"""Test that the sample panel plugin is installed."""
plugins = registry.with_mixin('panel')
self.assertTrue(len(plugins) > 0)
self.assertTrue(len(plugins) == 0)
# Now enable the plugin
registry.set_plugin_state('samplepanel', True)
plugins = registry.with_mixin('panel')
self.assertIn('samplepanel', [p.slug for p in plugins])
plugins = registry.with_mixin('panel', active=True)
# Find 'inactive' plugins (should be None)
plugins = registry.with_mixin('panel', active=False)
self.assertEqual(len(plugins), 0)
def test_disabled(self):

View File

@ -81,7 +81,7 @@ class LabelMixinTests(InvenTreeAPITestCase):
def test_installed(self):
"""Test that the sample printing plugin is installed."""
# Get all label plugins
plugins = registry.with_mixin('labels')
plugins = registry.with_mixin('labels', active=None)
self.assertEqual(len(plugins), 3)
# But, it is not 'active'

View File

@ -12,7 +12,7 @@ from importlib.metadata import entry_points
from django import template
from django.conf import settings
from django.core.exceptions import AppRegistryNotReady
from django.db.utils import IntegrityError
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
logger = logging.getLogger('inventree')

View File

@ -7,6 +7,7 @@ from django.conf import settings
from django.contrib import admin
from django.contrib.auth.models import User
from django.db import models
from django.db.utils import IntegrityError
from django.utils.translation import gettext_lazy as _
import common.models
@ -122,7 +123,7 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
"""Extend save method to reload plugins if the 'active' status changes."""
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
ret = super().save(force_insert, force_update, *args, **kwargs)
super().save(force_insert, force_update, *args, **kwargs)
if self.is_builtin():
# Force active if builtin
@ -134,8 +135,6 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
warnings.warn('A reload was triggered', stacklevel=2)
registry.reload_plugins()
return ret
@admin.display(boolean=True, description=_('Installed'))
def is_installed(self) -> bool:
"""Simple check to determine if this plugin is installed.

View File

@ -9,7 +9,6 @@ from importlib.metadata import PackageNotFoundError, metadata
from pathlib import Path
from django.conf import settings
from django.db.utils import OperationalError, ProgrammingError
from django.urls.base import reverse
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
@ -96,24 +95,9 @@ class MetaBase:
def plugin_config(self):
"""Return the PluginConfig object associated with this plugin."""
import InvenTree.ready
from plugin.registry import registry
# Database contains no information yet - return None
if InvenTree.ready.isImportingData():
return None
try:
import plugin.models
cfg, _ = plugin.models.PluginConfig.objects.get_or_create(
key=self.plugin_slug(), name=self.plugin_name()
)
except (OperationalError, ProgrammingError):
cfg = None
except plugin.models.PluginConfig.DoesNotExist:
cfg = None
return cfg
return registry.get_plugin_config(self.plugin_slug())
def is_active(self):
"""Return True if this plugin is currently active."""

View File

@ -89,7 +89,7 @@ class PluginsRegistry:
"""Return True if the plugin registry is currently loading."""
return self.loading_lock.locked()
def get_plugin(self, slug):
def get_plugin(self, slug, active=None):
"""Lookup plugin by slug (unique key)."""
# Check if the registry needs to be reloaded
self.check_reload()
@ -98,7 +98,43 @@ class PluginsRegistry:
logger.warning("Plugin registry has no record of plugin '%s'", slug)
return None
return self.plugins[slug]
plg = self.plugins[slug]
if active is not None:
if active != plg.is_active():
return None
return plg
def get_plugin_config(self, slug: str, name: [str, None] = None):
"""Return the matching PluginConfig instance for a given plugin.
Args:
slug: The plugin slug
name: The plugin name (optional)
"""
import InvenTree.ready
from plugin.models import PluginConfig
if InvenTree.ready.isImportingData():
return None
try:
cfg, _created = PluginConfig.objects.get_or_create(key=slug)
except PluginConfig.DoesNotExist:
return None
except (IntegrityError, OperationalError, ProgrammingError): # pragma: no cover
return None
if name and cfg.name != name:
# Update the name if it has changed
try:
cfg.name = name
cfg.save()
except Exception as e:
logger.exception('Failed to update plugin name')
return cfg
def set_plugin_state(self, slug, state):
"""Set the state(active/inactive) of a plugin.
@ -114,9 +150,12 @@ class PluginsRegistry:
logger.warning("Plugin registry has no record of plugin '%s'", slug)
return
plugin = self.plugins_full[slug].db
plugin.active = state
plugin.save()
cfg = self.get_plugin_config(slug)
cfg.active = state
cfg.save()
# Update the registry hash value
self.update_plugin_hash()
def call_plugin_function(self, slug, func, *args, **kwargs):
"""Call a member function (named by 'func') of the plugin named by 'slug'.
@ -139,8 +178,14 @@ class PluginsRegistry:
return plugin_func(*args, **kwargs)
# region registry functions
def with_mixin(self, mixin: str, active=None, builtin=None):
"""Returns reference to all plugins that have a specified mixin enabled."""
def with_mixin(self, mixin: str, active=True, builtin=None):
"""Returns reference to all plugins that have a specified mixin enabled.
Args:
mixin (str): Mixin name
active (bool, optional): Filter by 'active' status of plugin. Defaults to True.
builtin (bool, optional): Filter by 'builtin' status of plugin. Defaults to None.
"""
# Check if the registry needs to be loaded
self.check_reload()
@ -488,9 +533,7 @@ class PluginsRegistry:
plg_db = plugin_configs[plg_key]
else:
# Configuration needs to be created
plg_db, _created = PluginConfig.objects.get_or_create(
key=plg_key, name=plg_name
)
plg_db = self.get_plugin_config(plg_key, plg_name)
except (OperationalError, ProgrammingError) as error:
# Exception if the database has not been migrated yet - check if test are running - raise if not
if not settings.PLUGIN_TESTING:
@ -729,6 +772,7 @@ class PluginsRegistry:
# Hash for all loaded plugins
for slug, plug in self.plugins.items():
data.update(str(slug).encode())
data.update(str(plug.name).encode())
data.update(str(plug.version).encode())
data.update(str(plug.is_active()).encode())

View File

@ -9,9 +9,9 @@ from stock.views import StockLocationDetail
class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
"""A sample plugin which renders some custom panels."""
NAME = 'CustomPanelExample'
NAME = 'SamplePanel'
SLUG = 'samplepanel'
TITLE = 'Custom Panel Example'
TITLE = 'Sample Panel Example'
DESCRIPTION = 'An example plugin demonstrating how custom panels can be added to the user interface'
VERSION = '0.1'

View File

@ -8,6 +8,7 @@ class SimpleActionPlugin(ActionMixin, InvenTreePlugin):
"""An EXTREMELY simple action plugin which demonstrates the capability of the ActionMixin class."""
NAME = 'SimpleActionPlugin'
SLUG = 'simpleaction'
ACTION_NAME = 'simple'
def perform_action(self, user=None, data=None):

View File

@ -1,29 +1,40 @@
"""Unit tests for action plugins."""
from InvenTree.unit_test import InvenTreeTestCase
from plugin.registry import registry
from plugin.samples.integration.simpleactionplugin import SimpleActionPlugin
class SimpleActionPluginTests(InvenTreeTestCase):
"""Tests for SampleIntegrationPlugin."""
def setUp(self):
"""Setup for tests."""
super().setUp()
self.plugin = SimpleActionPlugin()
def test_name(self):
"""Check plugn names."""
self.assertEqual(self.plugin.plugin_name(), 'SimpleActionPlugin')
self.assertEqual(self.plugin.action_name(), 'simple')
plg = SimpleActionPlugin()
self.assertEqual(plg.plugin_name(), 'SimpleActionPlugin')
self.assertEqual(plg.action_name(), 'simple')
def set_plugin_state(self, state: bool):
"""Set the enabled state of the SimpleActionPlugin."""
cfg = registry.get_plugin_config('simpleaction')
cfg.active = state
cfg.save()
def test_function(self):
"""Check if functions work."""
data = {'action': 'simple', 'data': {'foo': 'bar'}}
self.set_plugin_state(False)
response = self.client.post('/api/action/', data=data)
self.assertEqual(response.status_code, 200)
self.assertIn('error', response.data)
# Now enable the plugin
self.set_plugin_state(True)
# test functions
response = self.client.post(
'/api/action/', data={'action': 'simple', 'data': {'foo': 'bar'}}
)
response = self.client.post('/api/action/', data=data)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
str(response.content, encoding='utf8'),

View File

@ -0,0 +1,115 @@
"""Unit tests for the SampleValidatorPlugin class."""
from django.core.exceptions import ValidationError
import part.models
from InvenTree.unit_test import InvenTreeTestCase
from plugin.registry import registry
class SampleValidatorPluginTest(InvenTreeTestCase):
"""Tests for the SampleValidatonPlugin class."""
fixtures = ['category', 'location']
def setUp(self):
"""Set up the test environment."""
cat = part.models.PartCategory.objects.first()
self.part = part.models.Part.objects.create(
name='TestPart', category=cat, description='A test part', component=True
)
self.assembly = part.models.Part.objects.create(
name='TestAssembly',
category=cat,
description='A test assembly',
component=False,
assembly=True,
)
self.bom_item = part.models.BomItem.objects.create(
part=self.assembly, sub_part=self.part, quantity=1
)
def get_plugin(self):
"""Return the SampleValidatorPlugin instance."""
return registry.get_plugin('validator', active=True)
def enable_plugin(self, en: bool):
"""Enable or disable the SampleValidatorPlugin."""
registry.set_plugin_state('validator', en)
def test_validate_model_instance(self):
"""Test the validate_model_instance function."""
# First, ensure that the plugin is disabled
self.enable_plugin(False)
plg = self.get_plugin()
self.assertIsNone(plg)
# Set the BomItem quantity to a non-integer value
# This should pass, as the plugin is currently disabled
self.bom_item.quantity = 3.14159
self.bom_item.save()
# Next, check that we can make a part instance description shorter
prt = part.models.Part.objects.first()
prt.description = prt.description[:-1]
prt.save()
# Now, enable the plugin
self.enable_plugin(True)
plg = self.get_plugin()
self.assertIsNotNone(plg)
plg.set_setting('BOM_ITEM_INTEGER', True)
self.bom_item.quantity = 3.14159
with self.assertRaises(ValidationError):
self.bom_item.save()
# Now, disable the plugin setting
plg.set_setting('BOM_ITEM_INTEGER', False)
self.bom_item.quantity = 3.14159
self.bom_item.save()
# Test that we *cannot* set a part description to a shorter value
prt.description = prt.description[:-1]
with self.assertRaises(ValidationError):
prt.save()
self.enable_plugin(False)
def test_validate_part_name(self):
"""Test the validate_part_name function."""
self.enable_plugin(True)
plg = self.get_plugin()
self.assertIsNotNone(plg)
# Set the part description short
self.part.description = 'x'
with self.assertRaises(ValidationError):
self.part.save()
self.enable_plugin(False)
self.part.save()
def test_validate_ipn(self):
"""Test the validate_ipn function."""
self.enable_plugin(True)
plg = self.get_plugin()
self.assertIsNotNone(plg)
self.part.IPN = 'LMNOP'
plg.set_setting('IPN_MUST_CONTAIN_Q', False)
self.part.save()
plg.set_setting('IPN_MUST_CONTAIN_Q', True)
with self.assertRaises(ValidationError):
self.part.save()
self.part.IPN = 'LMNOPQ'
self.part.save()

View File

@ -2,21 +2,19 @@
from datetime import datetime
from django.core.exceptions import ValidationError
from plugin import InvenTreePlugin
from plugin.mixins import SettingsMixin, ValidationMixin
class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
class SampleValidatorPlugin(SettingsMixin, ValidationMixin, InvenTreePlugin):
"""A sample plugin class for demonstrating custom validation functions.
Simple of examples of custom validator code.
"""
NAME = 'CustomValidator'
NAME = 'SampleValidator'
SLUG = 'validator'
TITLE = 'Custom Validator Plugin'
TITLE = 'Sample Validator Plugin'
DESCRIPTION = 'A sample plugin for demonstrating custom validation functionality'
VERSION = '0.3.0'
@ -49,8 +47,44 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
'description': 'Required prefix for batch code',
'default': 'B',
},
'BOM_ITEM_INTEGER': {
'name': 'Integer Bom Quantity',
'description': 'Bom item quantity must be an integer',
'default': False,
'validator': bool,
},
}
def validate_model_instance(self, instance, deltas=None):
"""Run validation against any saved model.
- Check if the instance is a BomItem object
- Test if the quantity is an integer
"""
import part.models
# Print debug message to console (intentional)
print('Validating model instance:', instance.__class__, f'<{instance.pk}>')
if isinstance(instance, part.models.BomItem):
if self.get_setting('BOM_ITEM_INTEGER'):
if float(instance.quantity) != int(instance.quantity):
self.raise_error({
'quantity': 'Bom item quantity must be an integer'
})
if isinstance(instance, part.models.Part):
# If the part description is being updated, prevent it from being reduced in length
if deltas and 'description' in deltas:
old_desc = deltas['description']['old']
new_desc = deltas['description']['new']
if len(new_desc) < len(old_desc):
self.raise_error({
'description': 'Part description cannot be shortened'
})
def validate_part_name(self, name: str, part):
"""Custom validation for Part name field.
@ -61,13 +95,13 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
These examples are silly, but serve to demonstrate how the feature could be used.
"""
if len(part.description) < len(name):
raise ValidationError('Part description cannot be shorter than the name')
self.raise_error('Part description cannot be shorter than the name')
illegal_chars = self.get_setting('ILLEGAL_PART_CHARS')
for c in illegal_chars:
if c in name:
raise ValidationError(f"Illegal character in part name: '{c}'")
self.raise_error(f"Illegal character in part name: '{c}'")
def validate_part_ipn(self, ipn: str, part):
"""Validate part IPN.
@ -75,7 +109,7 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
These examples are silly, but serve to demonstrate how the feature could be used
"""
if self.get_setting('IPN_MUST_CONTAIN_Q') and 'Q' not in ipn:
raise ValidationError("IPN must contain 'Q'")
self.raise_error("IPN must contain 'Q'")
def validate_part_parameter(self, parameter, data):
"""Validate part parameter data.
@ -85,7 +119,7 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
if parameter.template.name.lower() in ['length', 'width']:
d = int(data)
if d >= 100:
raise ValidationError('Value must be less than 100')
self.raise_error('Value must be less than 100')
def validate_serial_number(self, serial: str, part):
"""Validate serial number for a given StockItem.
@ -94,14 +128,12 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
"""
if self.get_setting('SERIAL_MUST_BE_PALINDROME'):
if serial != serial[::-1]:
raise ValidationError('Serial must be a palindrome')
self.raise_error('Serial must be a palindrome')
if self.get_setting('SERIAL_MUST_MATCH_PART'):
# Serial must start with the same letter as the linked part, for some reason
if serial[0] != part.name[0]:
raise ValidationError(
'Serial number must start with same letter as part'
)
self.raise_error('Serial number must start with same letter as part')
def validate_batch_code(self, batch_code: str, item):
"""Ensure that a particular batch code meets specification.
@ -112,7 +144,7 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
if len(batch_code) > 0:
if prefix and not batch_code.startswith(prefix):
raise ValidationError(f"Batch code must start with '{prefix}'")
self.raise_error(f"Batch code must start with '{prefix}'")
def generate_batch_code(self):
"""Generate a new batch code."""

View File

@ -16,6 +16,7 @@ from django.utils.translation import gettext_lazy as _
import build.models
import common.models
import InvenTree.models
import order.models
import part.models
import report.helpers
@ -93,7 +94,7 @@ class WeasyprintReportMixin(WeasyTemplateResponseMixin):
self.pdf_filename = kwargs.get('filename', 'report.pdf')
class ReportBase(models.Model):
class ReportBase(InvenTree.models.InvenTreeModel):
"""Base class for uploading html templates."""
class Meta:

View File

@ -26,20 +26,13 @@ from taggit.managers import TaggableManager
import common.models
import InvenTree.helpers
import InvenTree.models
import InvenTree.ready
import InvenTree.tasks
import label.models
import report.models
from company import models as CompanyModels
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
from InvenTree.models import (
InvenTreeAttachment,
InvenTreeBarcodeMixin,
InvenTreeNotesMixin,
InvenTreeTree,
MetadataMixin,
extract_int,
)
from InvenTree.status_codes import (
SalesOrderStatusGroups,
StockHistoryCode,
@ -53,7 +46,7 @@ from users.models import Owner
logger = logging.getLogger('inventree')
class StockLocationType(MetadataMixin, models.Model):
class StockLocationType(InvenTree.models.MetadataMixin, models.Model):
"""A type of stock location like Warehouse, room, shelf, drawer.
Attributes:
@ -111,7 +104,9 @@ class StockLocationManager(TreeManager):
return super().get_queryset()
class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
class StockLocation(
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeTree
):
"""Organization tree for StockItem objects.
A "StockLocation" can be considered a warehouse, or storage location
@ -352,9 +347,10 @@ def default_delete_on_deplete():
class StockItem(
InvenTreeBarcodeMixin,
InvenTreeNotesMixin,
MetadataMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin,
common.models.MetaMixin,
MPTTModel,
):
@ -450,7 +446,7 @@ class StockItem(
serial_int = 0
if serial not in [None, '']:
serial_int = extract_int(serial)
serial_int = InvenTree.helpers.extract_int(serial)
self.serial_int = serial_int
@ -2193,7 +2189,7 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
instance.part.schedule_pricing_update(create=True)
class StockItemAttachment(InvenTreeAttachment):
class StockItemAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a StockItem object."""
@staticmethod
@ -2210,7 +2206,7 @@ class StockItemAttachment(InvenTreeAttachment):
)
class StockItemTracking(models.Model):
class StockItemTracking(InvenTree.models.InvenTreeModel):
"""Stock tracking entry - used for tracking history of a particular StockItem.
Note: 2021-05-11
@ -2274,7 +2270,7 @@ def rename_stock_item_test_result_attachment(instance, filename):
)
class StockItemTestResult(MetadataMixin, models.Model):
class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
"""A StockItemTestResult records results of custom tests against individual StockItem objects.
This is useful for tracking unit acceptance tests, and particularly useful when integrated

View File

@ -22,7 +22,6 @@ import InvenTree.status_codes
import part.models as part_models
import stock.filters
from company.serializers import SupplierPartSerializer
from InvenTree.models import extract_int
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
from part.serializers import PartBriefSerializer
@ -114,7 +113,7 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
def validate_serial(self, value):
"""Make sure serial is not to big."""
if abs(extract_int(value)) > 0x7FFFFFFF:
if abs(InvenTree.helpers.extract_int(value)) > 0x7FFFFFFF:
raise serializers.ValidationError(_('Serial number is too large'))
return value

View File

@ -1117,6 +1117,8 @@ class TestResultTest(StockTestBase):
"""Test duplicate item behaviour."""
# Create an example stock item by copying one from the database (because we are lazy)
from plugin.registry import registry
StockItem.objects.rebuild()
item = StockItem.objects.get(pk=522)
@ -1125,9 +1127,12 @@ class TestResultTest(StockTestBase):
item.serial = None
item.quantity = 50
# Try with an invalid batch code (according to sample validatoin plugin)
# Try with an invalid batch code (according to sample validation plugin)
item.batch = 'X234'
# Ensure that the sample validation plugin is activated
registry.set_plugin_state('validator', True)
with self.assertRaises(ValidationError):
item.save()

View File

@ -4,7 +4,7 @@ title: Validation Mixin
## ValidationMixin
The `ValidationMixin` class enables plugins to perform custom validation of various fields.
The `ValidationMixin` class enables plugins to perform custom validation of objects within the database.
Any of the methods described below can be implemented in a custom plugin to provide functionality as required.
@ -14,6 +14,88 @@ Any of the methods described below can be implemented in a custom plugin to prov
!!! info "Multi Plugin Support"
It is possible to have multiple plugins loaded simultaneously which support validation methods. For example when validating a field, if one plugin returns a null value (`None`) then the *next* plugin (if available) will be queried.
## Model Validation
Any model which inherits the `PluginValidationMixin` mixin class is exposed to the plugin system for custom validation. Before the model is saved to the database (either when created, or updated), it is first passed to the plugin ecosystem for validation.
Any plugin which inherits the `ValidationMixin` can implement the `validate_model_instance` method, and run a custom validation routine.
The `validate_model_instance` method is passed the following arguments:
| Argument | Description |
| --- | --- |
| `instance` | The model instance to be validated |
| `deltas` | A dict of field deltas (if the instance is being updated) |
```python
def validate_model_instance(self, instance, deltas=None):
"""Validate the supplied model instance.
Arguments:
instance: The model instance to be validated
deltas: A dict of field deltas (if the instance is being updated)
"""
...
```
### Error Messages
Any error messages must be raised as a `ValidationError`. The `ValidationMixin` class provides the `raise_error` method, which is a simple wrapper method which raises a `ValidationError`
#### Instance Errors
To indicate an *instance* validation error (i.e. the validation error applies to the entire model instance), the body of the error should be either a string, or a list of strings.
#### Field Errors
To indicate a *field* validation error (i.e. the validation error applies only to a single field on the model instance), the body of the error should be a dict, where the key(s) of the dict correspond to the model fields.
Note that an error can be which corresponds to multiple model instance fields.
### Example
Presented below is a simple working example for a plugin which implements the `validate_model_instance` method:
```python
from plugin import InvenTreePlugin
from plugin.mixins import ValidationMixin
import part.models
class MyValidationMixin(Validationixin, InvenTreePlugin):
"""Custom validation plugin."""
def validate_model_instance(self, instance, deltas=None):
"""Custom model validation example.
- A part name and category name must have the same starting letter
- A PartCategory description field cannot be shortened after it has been created
"""
if isinstance(instance, part.models.Part):
if category := instance.category:
if category.name[0] != part.name[0]:
self.raise_error({
"name": "Part name and category name must start with the same letter"
})
if isinstance(instance, part.models.PartCategory):
if deltas and 'description' in deltas:
d_new = deltas['description']['new']
d_old = deltas['description']['old']
if len(d_new) < len(d_old):
self.raise_error({
"description": "Description cannot be shortened"
})
```
## Field Validation
In addition to the general purpose model instance validation routine provided above, the following fields support custom validation routines:
### Part Name
By default, part names are not subject to any particular naming conventions or requirements. However if custom validation is required, the `validate_part_name` method can be implemented to ensure that a part name conforms to a required convention.