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

View File

@ -30,6 +30,51 @@ from .settings import MEDIA_URL, STATIC_URL
logger = logging.getLogger('inventree') 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): 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. """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 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: class CleanMixin:
"""Model mixin class which cleans inputs using the Mozilla bleach tools.""" """Model mixin class which cleans inputs using the Mozilla bleach tools."""

View File

@ -2,7 +2,6 @@
import logging import logging
import os import os
import re
from datetime import datetime from datetime import datetime
from io import BytesIO from io import BytesIO
@ -30,18 +29,90 @@ from InvenTree.sanitizer import sanitize_svg
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
def rename_attachment(instance, filename): class DiffMixin:
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class. """Mixin which can be used to determine which fields have changed, compared to the instance saved to the database."""
Args: def get_db_instance(self):
instance: Instance of a PartAttachment object """Return the instance of the object saved in the database.
filename: name of uploaded file
Returns: Returns:
path to store file, format: '<subdir>/<id>/filename' 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): class MetadataMixin(models.Model):
@ -377,7 +448,7 @@ class ReferenceIndexingMixin(models.Model):
except Exception: except Exception:
pass pass
reference_int = extract_int(reference) reference_int = InvenTree.helpers.extract_int(reference)
if validate: if validate:
if reference_int > models.BigIntegerField.MAX_BIGINT: if reference_int > models.BigIntegerField.MAX_BIGINT:
@ -388,52 +459,44 @@ class ReferenceIndexingMixin(models.Model):
reference_int = models.BigIntegerField(default=0) reference_int = models.BigIntegerField(default=0)
def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False): class InvenTreeModel(PluginValidationMixin, models.Model):
"""Extract an integer out of reference.""" """Base class for InvenTree models, which provides some common functionality.
# Default value if we cannot convert to an integer
ref_int = 0
reference = str(reference).strip() Includes the following mixins by default:
# Ignore empty string - PluginValidationMixin: Provides a hook for plugins to validate model instances
if len(reference) == 0: """
return 0
# Look at the start of the string - can it be "integerized"? class Meta:
result = re.match(r'^(\d+)', reference) """Metaclass options."""
if result and len(result.groups()) == 1: abstract = True
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
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. """Provides an abstracted class for managing file attachments.
An attachment can be either an uploaded file, or an external URL An attachment can be either an uploaded file, or an external URL
@ -615,7 +678,7 @@ class InvenTreeAttachment(models.Model):
return '' return ''
class InvenTreeTree(MPTTModel): class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
"""Provides an abstracted self-referencing tree model for data categories. """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). - 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'] plg = kwargs['plugin']
if issubclass(plg.__class__, InvenTreePlugin): 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( return plugin.models.PluginSetting.get_setting_object(
key, plugin=plg, cache=cache 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.fields
import InvenTree.helpers import InvenTree.helpers
import InvenTree.helpers_model import InvenTree.helpers_model
import InvenTree.mixins
import InvenTree.models import InvenTree.models
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
@ -45,7 +44,7 @@ import users.models
logger = logging.getLogger('inventree') 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. """A Build object organises the creation of new StockItem objects from other existing StockItem objects.
Attributes: Attributes:
@ -1247,7 +1246,7 @@ class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments') 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. """A BuildLine object links a BOMItem to a Build.
When a new Build is created, the BuildLine objects are created automatically. 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 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. """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. 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) super().__call__(value)
class ProjectCode(InvenTree.models.MetadataMixin, models.Model): class ProjectCode(InvenTree.models.InvenTreeMetadataModel):
"""A ProjectCode is a unique identifier for a project.""" """A ProjectCode is a unique identifier for a project."""
@staticmethod @staticmethod

View File

@ -24,17 +24,12 @@ import common.settings
import InvenTree.conversion import InvenTree.conversion
import InvenTree.fields import InvenTree.fields
import InvenTree.helpers import InvenTree.helpers
import InvenTree.models
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
import InvenTree.validators import InvenTree.validators
from common.settings import currency_code_default from common.settings import currency_code_default
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from InvenTree.models import (
InvenTreeAttachment,
InvenTreeBarcodeMixin,
InvenTreeNotesMixin,
MetadataMixin,
)
from InvenTree.status_codes import PurchaseOrderStatusGroups from InvenTree.status_codes import PurchaseOrderStatusGroups
@ -63,7 +58,9 @@ def rename_company_image(instance, filename):
return os.path.join(base, fn) 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. """A Company object represents an external company.
It may be a supplier or a customer or a manufacturer (or a combination) 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() ).distinct()
class CompanyAttachment(InvenTreeAttachment): class CompanyAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file or URL attachments against a Company object.""" """Model for storing file or URL attachments against a Company object."""
@staticmethod @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. """A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects.
Attributes: Attributes:
@ -299,7 +296,7 @@ class Contact(MetadataMixin, models.Model):
role = models.CharField(max_length=100, blank=True) 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. """An address represents a physical location where the company is located. It is possible for a company to have multiple locations.
Attributes: 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. """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: Attributes:
@ -555,7 +554,7 @@ class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model):
return s return s
class ManufacturerPartAttachment(InvenTreeAttachment): class ManufacturerPartAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a ManufacturerPart object.""" """Model for storing file attachments against a ManufacturerPart object."""
@staticmethod @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. """A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
This is used to represent parameters / properties for a particular manufacturer part. 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. """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: Attributes:

View File

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

View File

@ -24,6 +24,7 @@ from mptt.models import TreeForeignKey
import common.models as common_models import common.models as common_models
import InvenTree.helpers import InvenTree.helpers
import InvenTree.models
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
import InvenTree.validators import InvenTree.validators
@ -42,13 +43,6 @@ from InvenTree.fields import (
) )
from InvenTree.helpers import decimal2string from InvenTree.helpers import decimal2string
from InvenTree.helpers_model import getSetting, notify_responsible from InvenTree.helpers_model import getSetting, notify_responsible
from InvenTree.models import (
InvenTreeAttachment,
InvenTreeBarcodeMixin,
InvenTreeNotesMixin,
MetadataMixin,
ReferenceIndexingMixin,
)
from InvenTree.status_codes import ( from InvenTree.status_codes import (
PurchaseOrderStatus, PurchaseOrderStatus,
PurchaseOrderStatusGroups, PurchaseOrderStatusGroups,
@ -188,10 +182,11 @@ class TotalPriceMixin(models.Model):
class Order( class Order(
StateTransitionMixin, StateTransitionMixin,
InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
MetadataMixin, InvenTree.models.MetadataMixin,
ReferenceIndexingMixin, InvenTree.models.ReferenceIndexingMixin,
InvenTree.models.InvenTreeModel,
): ):
"""Abstract model for an order. """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) 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.""" """Model for storing file attachments against a PurchaseOrder object."""
@staticmethod @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.""" """Model for storing file attachments against a SalesOrder object."""
@staticmethod @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. """Abstract model for an order line item.
Attributes: Attributes:
@ -1587,7 +1582,11 @@ class SalesOrderLineItem(OrderLineItem):
return self.shipped >= self.quantity 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. """The SalesOrderShipment model represents a physical shipment made against a SalesOrder.
- Points to a single SalesOrder object - 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.""" """Model for storing file attachments against a ReturnOrder object."""
@staticmethod @staticmethod

View File

@ -37,6 +37,7 @@ import common.models
import common.settings import common.settings
import InvenTree.conversion import InvenTree.conversion
import InvenTree.fields import InvenTree.fields
import InvenTree.models
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
import part.helpers as part_helpers import part.helpers as part_helpers
@ -49,14 +50,6 @@ from company.models import SupplierPart
from InvenTree import helpers, validators from InvenTree import helpers, validators
from InvenTree.fields import InvenTreeURLField from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2money, decimal2string, normalize, str2bool from InvenTree.helpers import decimal2money, decimal2string, normalize, str2bool
from InvenTree.models import (
DataImportMixin,
InvenTreeAttachment,
InvenTreeBarcodeMixin,
InvenTreeNotesMixin,
InvenTreeTree,
MetadataMixin,
)
from InvenTree.status_codes import ( from InvenTree.status_codes import (
BuildStatusGroups, BuildStatusGroups,
PurchaseOrderStatus, PurchaseOrderStatus,
@ -70,7 +63,7 @@ from stock import models as StockModels
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
class PartCategory(MetadataMixin, InvenTreeTree): class PartCategory(InvenTree.models.InvenTreeTree):
"""PartCategory provides hierarchical organization of Part objects. """PartCategory provides hierarchical organization of Part objects.
Attributes: Attributes:
@ -341,7 +334,13 @@ class PartManager(TreeManager):
@cleanup.ignore @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. """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. 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.""" """Model for storing file attachments against a Part object."""
@staticmethod @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). """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 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.""" """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. """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. 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. """A PartParameter is a specific instance of a PartParameterTemplate. It assigns a particular parameter <key:value> pair to a part.
Attributes: Attributes:
@ -3778,7 +3777,7 @@ class PartParameter(MetadataMixin, models.Model):
return part_parameter 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. """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. 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 BomItem links a part to its component items.
A part can have a BOM (bill of materials) which defines 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) 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. """A BomItemSubstitute provides a specification for alternative parts, which can be used in a bill of materials.
Attributes: 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.).""" """Store and handle related parts (eg. mating connector, crimps, etc.)."""
class Meta: class Meta:

View File

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

View File

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

View File

@ -1,5 +1,7 @@
"""Validation mixin class definition.""" """Validation mixin class definition."""
from django.core.exceptions import ValidationError
import part.models import part.models
import stock.models import stock.models
@ -7,7 +9,10 @@ import stock.models
class ValidationMixin: class ValidationMixin:
"""Mixin class that allows custom validation for various parts of InvenTree. """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 names
- Part IPN (internal part number) values - Part IPN (internal part number) values
@ -40,6 +45,28 @@ class ValidationMixin:
super().__init__() super().__init__()
self.add_mixin('validation', True, __class__) 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): def validate_part_name(self, name: str, part: part.models.Part):
"""Perform validation on a proposed Part name. """Perform validation on a proposed Part name.

View File

@ -348,12 +348,16 @@ class PanelMixinTests(InvenTreeTestCase):
"""Test that the sample panel plugin is installed.""" """Test that the sample panel plugin is installed."""
plugins = registry.with_mixin('panel') 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]) 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) self.assertEqual(len(plugins), 0)
def test_disabled(self): def test_disabled(self):

View File

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

View File

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

View File

@ -7,6 +7,7 @@ from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.db.utils import IntegrityError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import common.models 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.""" """Extend save method to reload plugins if the 'active' status changes."""
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set 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(): if self.is_builtin():
# Force active if builtin # Force active if builtin
@ -134,8 +135,6 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
warnings.warn('A reload was triggered', stacklevel=2) warnings.warn('A reload was triggered', stacklevel=2)
registry.reload_plugins() registry.reload_plugins()
return ret
@admin.display(boolean=True, description=_('Installed')) @admin.display(boolean=True, description=_('Installed'))
def is_installed(self) -> bool: def is_installed(self) -> bool:
"""Simple check to determine if this plugin is installed. """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 pathlib import Path
from django.conf import settings from django.conf import settings
from django.db.utils import OperationalError, ProgrammingError
from django.urls.base import reverse from django.urls.base import reverse
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -96,24 +95,9 @@ class MetaBase:
def plugin_config(self): def plugin_config(self):
"""Return the PluginConfig object associated with this plugin.""" """Return the PluginConfig object associated with this plugin."""
import InvenTree.ready from plugin.registry import registry
# Database contains no information yet - return None return registry.get_plugin_config(self.plugin_slug())
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
def is_active(self): def is_active(self):
"""Return True if this plugin is currently active.""" """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 True if the plugin registry is currently loading."""
return self.loading_lock.locked() return self.loading_lock.locked()
def get_plugin(self, slug): def get_plugin(self, slug, active=None):
"""Lookup plugin by slug (unique key).""" """Lookup plugin by slug (unique key)."""
# Check if the registry needs to be reloaded # Check if the registry needs to be reloaded
self.check_reload() self.check_reload()
@ -98,7 +98,43 @@ class PluginsRegistry:
logger.warning("Plugin registry has no record of plugin '%s'", slug) logger.warning("Plugin registry has no record of plugin '%s'", slug)
return None 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): def set_plugin_state(self, slug, state):
"""Set the state(active/inactive) of a plugin. """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) logger.warning("Plugin registry has no record of plugin '%s'", slug)
return return
plugin = self.plugins_full[slug].db cfg = self.get_plugin_config(slug)
plugin.active = state cfg.active = state
plugin.save() cfg.save()
# Update the registry hash value
self.update_plugin_hash()
def call_plugin_function(self, slug, func, *args, **kwargs): def call_plugin_function(self, slug, func, *args, **kwargs):
"""Call a member function (named by 'func') of the plugin named by 'slug'. """Call a member function (named by 'func') of the plugin named by 'slug'.
@ -139,8 +178,14 @@ class PluginsRegistry:
return plugin_func(*args, **kwargs) return plugin_func(*args, **kwargs)
# region registry functions # region registry functions
def with_mixin(self, mixin: str, active=None, builtin=None): def with_mixin(self, mixin: str, active=True, builtin=None):
"""Returns reference to all plugins that have a specified mixin enabled.""" """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 # Check if the registry needs to be loaded
self.check_reload() self.check_reload()
@ -488,9 +533,7 @@ class PluginsRegistry:
plg_db = plugin_configs[plg_key] plg_db = plugin_configs[plg_key]
else: else:
# Configuration needs to be created # Configuration needs to be created
plg_db, _created = PluginConfig.objects.get_or_create( plg_db = self.get_plugin_config(plg_key, plg_name)
key=plg_key, name=plg_name
)
except (OperationalError, ProgrammingError) as error: except (OperationalError, ProgrammingError) as error:
# Exception if the database has not been migrated yet - check if test are running - raise if not # Exception if the database has not been migrated yet - check if test are running - raise if not
if not settings.PLUGIN_TESTING: if not settings.PLUGIN_TESTING:
@ -729,6 +772,7 @@ class PluginsRegistry:
# Hash for all loaded plugins # Hash for all loaded plugins
for slug, plug in self.plugins.items(): for slug, plug in self.plugins.items():
data.update(str(slug).encode()) data.update(str(slug).encode())
data.update(str(plug.name).encode())
data.update(str(plug.version).encode()) data.update(str(plug.version).encode())
data.update(str(plug.is_active()).encode()) data.update(str(plug.is_active()).encode())

View File

@ -9,9 +9,9 @@ from stock.views import StockLocationDetail
class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin): class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
"""A sample plugin which renders some custom panels.""" """A sample plugin which renders some custom panels."""
NAME = 'CustomPanelExample' NAME = 'SamplePanel'
SLUG = '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' DESCRIPTION = 'An example plugin demonstrating how custom panels can be added to the user interface'
VERSION = '0.1' 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.""" """An EXTREMELY simple action plugin which demonstrates the capability of the ActionMixin class."""
NAME = 'SimpleActionPlugin' NAME = 'SimpleActionPlugin'
SLUG = 'simpleaction'
ACTION_NAME = 'simple' ACTION_NAME = 'simple'
def perform_action(self, user=None, data=None): def perform_action(self, user=None, data=None):

View File

@ -1,29 +1,40 @@
"""Unit tests for action plugins.""" """Unit tests for action plugins."""
from InvenTree.unit_test import InvenTreeTestCase from InvenTree.unit_test import InvenTreeTestCase
from plugin.registry import registry
from plugin.samples.integration.simpleactionplugin import SimpleActionPlugin from plugin.samples.integration.simpleactionplugin import SimpleActionPlugin
class SimpleActionPluginTests(InvenTreeTestCase): class SimpleActionPluginTests(InvenTreeTestCase):
"""Tests for SampleIntegrationPlugin.""" """Tests for SampleIntegrationPlugin."""
def setUp(self):
"""Setup for tests."""
super().setUp()
self.plugin = SimpleActionPlugin()
def test_name(self): def test_name(self):
"""Check plugn names.""" """Check plugn names."""
self.assertEqual(self.plugin.plugin_name(), 'SimpleActionPlugin') plg = SimpleActionPlugin()
self.assertEqual(self.plugin.action_name(), 'simple') 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): def test_function(self):
"""Check if functions work.""" """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 # test functions
response = self.client.post( response = self.client.post('/api/action/', data=data)
'/api/action/', data={'action': 'simple', 'data': {'foo': 'bar'}}
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
str(response.content, encoding='utf8'), 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 datetime import datetime
from django.core.exceptions import ValidationError
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.mixins import SettingsMixin, ValidationMixin 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. """A sample plugin class for demonstrating custom validation functions.
Simple of examples of custom validator code. Simple of examples of custom validator code.
""" """
NAME = 'CustomValidator' NAME = 'SampleValidator'
SLUG = 'validator' SLUG = 'validator'
TITLE = 'Custom Validator Plugin' TITLE = 'Sample Validator Plugin'
DESCRIPTION = 'A sample plugin for demonstrating custom validation functionality' DESCRIPTION = 'A sample plugin for demonstrating custom validation functionality'
VERSION = '0.3.0' VERSION = '0.3.0'
@ -49,8 +47,44 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
'description': 'Required prefix for batch code', 'description': 'Required prefix for batch code',
'default': 'B', '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): def validate_part_name(self, name: str, part):
"""Custom validation for Part name field. """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. These examples are silly, but serve to demonstrate how the feature could be used.
""" """
if len(part.description) < len(name): 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') illegal_chars = self.get_setting('ILLEGAL_PART_CHARS')
for c in illegal_chars: for c in illegal_chars:
if c in name: 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): def validate_part_ipn(self, ipn: str, part):
"""Validate part IPN. """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 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: 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): def validate_part_parameter(self, parameter, data):
"""Validate part parameter data. """Validate part parameter data.
@ -85,7 +119,7 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
if parameter.template.name.lower() in ['length', 'width']: if parameter.template.name.lower() in ['length', 'width']:
d = int(data) d = int(data)
if d >= 100: 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): def validate_serial_number(self, serial: str, part):
"""Validate serial number for a given StockItem. """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 self.get_setting('SERIAL_MUST_BE_PALINDROME'):
if serial != serial[::-1]: 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'): if self.get_setting('SERIAL_MUST_MATCH_PART'):
# Serial must start with the same letter as the linked part, for some reason # Serial must start with the same letter as the linked part, for some reason
if serial[0] != part.name[0]: if serial[0] != part.name[0]:
raise ValidationError( self.raise_error('Serial number must start with same letter as part')
'Serial number must start with same letter as part'
)
def validate_batch_code(self, batch_code: str, item): def validate_batch_code(self, batch_code: str, item):
"""Ensure that a particular batch code meets specification. """Ensure that a particular batch code meets specification.
@ -112,7 +144,7 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
if len(batch_code) > 0: if len(batch_code) > 0:
if prefix and not batch_code.startswith(prefix): 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): def generate_batch_code(self):
"""Generate a new batch code.""" """Generate a new batch code."""

View File

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

View File

@ -26,20 +26,13 @@ from taggit.managers import TaggableManager
import common.models import common.models
import InvenTree.helpers import InvenTree.helpers
import InvenTree.models
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
import label.models import label.models
import report.models import report.models
from company import models as CompanyModels from company import models as CompanyModels
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
from InvenTree.models import (
InvenTreeAttachment,
InvenTreeBarcodeMixin,
InvenTreeNotesMixin,
InvenTreeTree,
MetadataMixin,
extract_int,
)
from InvenTree.status_codes import ( from InvenTree.status_codes import (
SalesOrderStatusGroups, SalesOrderStatusGroups,
StockHistoryCode, StockHistoryCode,
@ -53,7 +46,7 @@ from users.models import Owner
logger = logging.getLogger('inventree') 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. """A type of stock location like Warehouse, room, shelf, drawer.
Attributes: Attributes:
@ -111,7 +104,9 @@ class StockLocationManager(TreeManager):
return super().get_queryset() return super().get_queryset()
class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree): class StockLocation(
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeTree
):
"""Organization tree for StockItem objects. """Organization tree for StockItem objects.
A "StockLocation" can be considered a warehouse, or storage location A "StockLocation" can be considered a warehouse, or storage location
@ -352,9 +347,10 @@ def default_delete_on_deplete():
class StockItem( class StockItem(
InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
MetadataMixin, InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin,
common.models.MetaMixin, common.models.MetaMixin,
MPTTModel, MPTTModel,
): ):
@ -450,7 +446,7 @@ class StockItem(
serial_int = 0 serial_int = 0
if serial not in [None, '']: if serial not in [None, '']:
serial_int = extract_int(serial) serial_int = InvenTree.helpers.extract_int(serial)
self.serial_int = serial_int 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) instance.part.schedule_pricing_update(create=True)
class StockItemAttachment(InvenTreeAttachment): class StockItemAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a StockItem object.""" """Model for storing file attachments against a StockItem object."""
@staticmethod @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. """Stock tracking entry - used for tracking history of a particular StockItem.
Note: 2021-05-11 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. """A StockItemTestResult records results of custom tests against individual StockItem objects.
This is useful for tracking unit acceptance tests, and particularly useful when integrated 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 part.models as part_models
import stock.filters import stock.filters
from company.serializers import SupplierPartSerializer from company.serializers import SupplierPartSerializer
from InvenTree.models import extract_int
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
@ -114,7 +113,7 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
def validate_serial(self, value): def validate_serial(self, value):
"""Make sure serial is not to big.""" """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')) raise serializers.ValidationError(_('Serial number is too large'))
return value return value

View File

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

View File

@ -4,7 +4,7 @@ title: Validation Mixin
## ValidationMixin ## 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. 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" !!! 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. 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 ### 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. 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.