mirror of
https://github.com/inventree/InvenTree.git
synced 2025-08-13 07:10:53 +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:
.pre-commit-config.yaml
InvenTree
InvenTree
build
common
company
label
order
part
plugin
report
stock
docs/docs/extend/plugins
@@ -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()
|
||||
|
||||
|
Reference in New Issue
Block a user