mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[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:
parent
dce2954466
commit
2b9816d1a3
@ -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:
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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).
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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'
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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."""
|
||||
|
@ -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())
|
||||
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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'),
|
||||
|
115
InvenTree/plugin/samples/integration/test_validation_sample.py
Normal file
115
InvenTree/plugin/samples/integration/test_validation_sample.py
Normal 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()
|
@ -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."""
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user