Extend functionality of custom validation plugins (#4391)

* Pass "Part" instance to plugins when calling validate_serial_number

* Pass part instance through when validating IPN

* Improve custom part name validation

- Pass the Part instance through to the plugins
- Validation is performed at the model instance level
- Updates to sample plugin code

* Pass StockItem through when validating batch code

* Pass Part instance through when calling validate_serial_number

* Bug fix

* Update unit tests

* Unit test fixes

* Fixes for unit tests

* More unit test fixes

* More unit tests

* Furrther unit test fixes

* Simplify custom batch code validation

* Further improvements to unit tests

* Further unit test
This commit is contained in:
Oliver 2023-03-07 22:43:12 +11:00 committed by GitHub
parent edae82caa5
commit abeb85cbb3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 193 additions and 137 deletions

1
.gitignore vendored
View File

@ -43,6 +43,7 @@ _tmp.csv
inventree/label.pdf inventree/label.pdf
inventree/label.png inventree/label.png
inventree/my_special* inventree/my_special*
_tests*.txt
# Sphinx files # Sphinx files
docs/_build docs/_build

View File

@ -29,20 +29,12 @@ from stock.models import StockItem, StockLocation
from . import config, helpers, ready, status, version from . import config, helpers, ready, status, version
from .tasks import offload_task from .tasks import offload_task
from .validators import validate_overage, validate_part_name from .validators import validate_overage
class ValidatorTest(TestCase): class ValidatorTest(TestCase):
"""Simple tests for custom field validators.""" """Simple tests for custom field validators."""
def test_part_name(self):
"""Test part name validator."""
validate_part_name('hello world')
# Validate with some strange chars
with self.assertRaises(django_exceptions.ValidationError):
validate_part_name('### <> This | name is not } valid')
def test_overage(self): def test_overage(self):
"""Test overage validator.""" """Test overage validator."""
validate_overage("100%") validate_overage("100%")

View File

@ -11,8 +11,6 @@ from django.utils.translation import gettext_lazy as _
from jinja2 import Template from jinja2 import Template
from moneyed import CURRENCIES from moneyed import CURRENCIES
import common.models
def validate_currency_code(code): def validate_currency_code(code):
"""Check that a given code is a valid currency code.""" """Check that a given code is a valid currency code."""
@ -47,50 +45,6 @@ class AllowedURLValidator(validators.URLValidator):
super().__call__(value) super().__call__(value)
def validate_part_name(value):
"""Validate the name field for a Part instance
This function is exposed to any Validation plugins, and thus can be customized.
"""
from plugin.registry import registry
for plugin in registry.with_mixin('validation'):
# Run the name through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation
if plugin.validate_part_name(value):
return
def validate_part_ipn(value):
"""Validate the IPN field for a Part instance.
This function is exposed to any Validation plugins, and thus can be customized.
If no validation errors are raised, the IPN is also validated against a configurable regex pattern.
"""
from plugin.registry import registry
plugins = registry.with_mixin('validation')
for plugin in plugins:
# Run the IPN through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation
if plugin.validate_part_ipn(value):
return
# If we get to here, none of the plugins have raised an error
pattern = common.models.InvenTreeSetting.get_setting('PART_IPN_REGEX')
if pattern:
match = re.search(pattern, value)
if match is None:
raise ValidationError(_('IPN must match regex pattern {pat}').format(pat=pattern))
def validate_purchase_order_reference(value): def validate_purchase_order_reference(value):
"""Validate the 'reference' field of a PurchaseOrder.""" """Validate the 'reference' field of a PurchaseOrder."""

View File

@ -950,12 +950,12 @@ class PurchaseOrderReceiveTest(OrderTest):
{ {
'line_item': 1, 'line_item': 1,
'quantity': 10, 'quantity': 10,
'batch_code': 'abc-123', 'batch_code': 'B-abc-123',
}, },
{ {
'line_item': 2, 'line_item': 2,
'quantity': 10, 'quantity': 10,
'batch_code': 'xyz-789', 'batch_code': 'B-xyz-789',
} }
], ],
'location': 1, 'location': 1,
@ -975,8 +975,8 @@ class PurchaseOrderReceiveTest(OrderTest):
item_1 = StockItem.objects.filter(supplier_part=line_1.part).first() item_1 = StockItem.objects.filter(supplier_part=line_1.part).first()
item_2 = StockItem.objects.filter(supplier_part=line_2.part).first() item_2 = StockItem.objects.filter(supplier_part=line_2.part).first()
self.assertEqual(item_1.batch, 'abc-123') self.assertEqual(item_1.batch, 'B-abc-123')
self.assertEqual(item_2.batch, 'xyz-789') self.assertEqual(item_2.batch, 'B-xyz-789')
def test_serial_numbers(self): def test_serial_numbers(self):
"""Test that we can supply a 'serial number' when receiving items.""" """Test that we can supply a 'serial number' when receiving items."""
@ -991,13 +991,13 @@ class PurchaseOrderReceiveTest(OrderTest):
{ {
'line_item': 1, 'line_item': 1,
'quantity': 10, 'quantity': 10,
'batch_code': 'abc-123', 'batch_code': 'B-abc-123',
'serial_numbers': '100+', 'serial_numbers': '100+',
}, },
{ {
'line_item': 2, 'line_item': 2,
'quantity': 10, 'quantity': 10,
'batch_code': 'xyz-789', 'batch_code': 'B-xyz-789',
} }
], ],
'location': 1, 'location': 1,
@ -1022,7 +1022,7 @@ class PurchaseOrderReceiveTest(OrderTest):
item = StockItem.objects.get(serial_int=i) item = StockItem.objects.get(serial_int=i)
self.assertEqual(item.serial, str(i)) self.assertEqual(item.serial, str(i))
self.assertEqual(item.quantity, 1) self.assertEqual(item.quantity, 1)
self.assertEqual(item.batch, 'abc-123') self.assertEqual(item.batch, 'B-abc-123')
# A single stock item (quantity 10) created for the second line item # A single stock item (quantity 10) created for the second line item
items = StockItem.objects.filter(supplier_part=line_2.part) items = StockItem.objects.filter(supplier_part=line_2.part)
@ -1031,7 +1031,7 @@ class PurchaseOrderReceiveTest(OrderTest):
item = items.first() item = items.first()
self.assertEqual(item.quantity, 10) self.assertEqual(item.quantity, 10)
self.assertEqual(item.batch, 'xyz-789') self.assertEqual(item.batch, 'B-xyz-789')
class SalesOrderTest(OrderTest): class SalesOrderTest(OrderTest):
@ -1437,7 +1437,7 @@ class SalesOrderLineItemTest(OrderTest):
n_parts = Part.objects.filter(salable=True).count() n_parts = Part.objects.filter(salable=True).count()
# List by part # List by part
for part in Part.objects.filter(salable=True): for part in Part.objects.filter(salable=True)[:3]:
response = self.get( response = self.get(
self.url, self.url,
{ {
@ -1449,7 +1449,7 @@ class SalesOrderLineItemTest(OrderTest):
self.assertEqual(response.data['count'], n_orders) self.assertEqual(response.data['count'], n_orders)
# List by order # List by order
for order in models.SalesOrder.objects.all(): for order in models.SalesOrder.objects.all()[:3]:
response = self.get( response = self.get(
self.url, self.url,
{ {

View File

@ -95,7 +95,7 @@
pk: 100 pk: 100
fields: fields:
name: 'Bob' name: 'Bob'
description: 'Can we build it?' description: 'Can we build it? Yes we can!'
assembly: true assembly: true
salable: true salable: true
purchaseable: false purchaseable: false
@ -112,7 +112,7 @@
pk: 101 pk: 101
fields: fields:
name: 'Assembly' name: 'Assembly'
description: 'A high level assembly' description: 'A high level assembly part'
salable: true salable: true
active: True active: True
tree_id: 0 tree_id: 0
@ -125,7 +125,7 @@
pk: 10000 pk: 10000
fields: fields:
name: 'Chair Template' name: 'Chair Template'
description: 'A chair' description: 'A chair, which is actually just a template part'
is_template: True is_template: True
trackable: true trackable: true
salable: true salable: true
@ -139,6 +139,7 @@
pk: 10001 pk: 10001
fields: fields:
name: 'Blue Chair' name: 'Blue Chair'
description: 'A variant chair part which is blue'
variant_of: 10000 variant_of: 10000
trackable: true trackable: true
category: 7 category: 7
@ -151,6 +152,7 @@
pk: 10002 pk: 10002
fields: fields:
name: 'Red chair' name: 'Red chair'
description: 'A variant chair part which is red'
variant_of: 10000 variant_of: 10000
IPN: "R.CH" IPN: "R.CH"
trackable: true trackable: true
@ -164,6 +166,7 @@
pk: 10003 pk: 10003
fields: fields:
name: 'Green chair' name: 'Green chair'
description: 'A template chair part which is green'
variant_of: 10000 variant_of: 10000
category: 7 category: 7
trackable: true trackable: true
@ -176,6 +179,7 @@
pk: 10004 pk: 10004
fields: fields:
name: 'Green chair variant' name: 'Green chair variant'
description: 'A green chair, which is a variant of the chair template'
variant_of: 10003 variant_of: 10003
is_template: true is_template: true
category: 7 category: 7

View File

@ -49,7 +49,7 @@ class Migration(migrations.Migration):
name='Part', name='Part',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Part name', max_length=100, validators=[InvenTree.validators.validate_part_name])), ('name', models.CharField(help_text='Part name', max_length=100)),
('variant', models.CharField(blank=True, help_text='Part variant or revision code', max_length=32)), ('variant', models.CharField(blank=True, help_text='Part variant or revision code', max_length=32)),
('description', models.CharField(help_text='Part description', max_length=250)), ('description', models.CharField(help_text='Part description', max_length=250)),
('keywords', models.CharField(blank=True, help_text='Part keywords to improve visibility in search results', max_length=250)), ('keywords', models.CharField(blank=True, help_text='Part keywords to improve visibility in search results', max_length=250)),

View File

@ -1,6 +1,5 @@
# Generated by Django 2.2 on 2019-05-26 02:15 # Generated by Django 2.2 on 2019-05-26 02:15
import InvenTree.validators
from django.db import migrations, models from django.db import migrations, models
@ -14,7 +13,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='part', model_name='part',
name='name', name='name',
field=models.CharField(help_text='Part name (must be unique)', max_length=100, unique=True, validators=[InvenTree.validators.validate_part_name]), field=models.CharField(help_text='Part name (must be unique)', max_length=100, unique=True),
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='part', name='part',

View File

@ -1,6 +1,5 @@
# Generated by Django 2.2.2 on 2019-06-20 11:35 # Generated by Django 2.2.2 on 2019-06-20 11:35
import InvenTree.validators
from django.db import migrations, models from django.db import migrations, models
@ -14,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='part', model_name='part',
name='name', name='name',
field=models.CharField(help_text='Part name', max_length=100, validators=[InvenTree.validators.validate_part_name]), field=models.CharField(help_text='Part name', max_length=100),
), ),
] ]

View File

@ -1,6 +1,5 @@
# Generated by Django 2.2.9 on 2020-02-03 10:07 # Generated by Django 2.2.9 on 2020-02-03 10:07
import InvenTree.validators
from django.db import migrations, models from django.db import migrations, models
@ -14,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='part', model_name='part',
name='IPN', name='IPN',
field=models.CharField(blank=True, help_text='Internal Part Number', max_length=100, validators=[InvenTree.validators.validate_part_ipn]), field=models.CharField(blank=True, help_text='Internal Part Number', max_length=100),
), ),
] ]

View File

@ -1,7 +1,6 @@
# Generated by Django 3.0.7 on 2020-09-02 14:04 # Generated by Django 3.0.7 on 2020-09-02 14:04
import InvenTree.fields import InvenTree.fields
import InvenTree.validators
from django.db import migrations, models from django.db import migrations, models
@ -16,7 +15,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='part', model_name='part',
name='IPN', name='IPN',
field=models.CharField(blank=True, help_text='Internal Part Number', max_length=100, null=True, validators=[InvenTree.validators.validate_part_ipn]), field=models.CharField(blank=True, help_text='Internal Part Number', max_length=100, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='part', model_name='part',

View File

@ -1,7 +1,6 @@
# Generated by Django 3.0.7 on 2021-01-03 12:13 # Generated by Django 3.0.7 on 2021-01-03 12:13
import InvenTree.fields import InvenTree.fields
import InvenTree.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import mptt.fields import mptt.fields
@ -19,7 +18,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='part', model_name='part',
name='IPN', name='IPN',
field=models.CharField(blank=True, help_text='Internal Part Number', max_length=100, null=True, validators=[InvenTree.validators.validate_part_ipn], verbose_name='IPN'), field=models.CharField(blank=True, help_text='Internal Part Number', max_length=100, null=True, verbose_name='IPN'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='part', model_name='part',
@ -59,7 +58,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='part', model_name='part',
name='name', name='name',
field=models.CharField(help_text='Part name', max_length=100, validators=[InvenTree.validators.validate_part_name], verbose_name='Name'), field=models.CharField(help_text='Part name', max_length=100, verbose_name='Name'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='part', model_name='part',

View File

@ -6,6 +6,7 @@ import decimal
import hashlib import hashlib
import logging import logging
import os import os
import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
@ -538,7 +539,60 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
return result return result
def validate_serial_number(self, serial: str, stock_item=None, check_duplicates=True, raise_error=False): def validate_name(self, raise_error=True):
"""Validate the name field for this Part instance
This function is exposed to any Validation plugins, and thus can be customized.
"""
from plugin.registry import registry
for plugin in registry.with_mixin('validation'):
# Run the name through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation
try:
result = plugin.validate_part_name(self.name, self)
if result:
return
except ValidationError as exc:
if raise_error:
raise ValidationError({
'name': exc.message,
})
def validate_ipn(self, raise_error=True):
"""Ensure that the IPN (internal part number) is valid for this Part"
- Validation is handled by custom plugins
- By default, no validation checks are perfomed
"""
from plugin.registry import registry
for plugin in registry.with_mixin('validation'):
try:
result = plugin.validate_part_ipn(self.IPN, self)
if result:
# A "true" result force skips any subsequent checks
break
except ValidationError as exc:
if raise_error:
raise ValidationError({
'IPN': exc.message
})
# If we get to here, none of the plugins have raised an error
pattern = common.models.InvenTreeSetting.get_setting('PART_IPN_REGEX', '', create=False).strip()
if pattern:
match = re.search(pattern, self.IPN)
if match is None:
raise ValidationError(_('IPN must match regex pattern {pat}').format(pat=pattern))
def validate_serial_number(self, serial: str, stock_item=None, check_duplicates=True, raise_error=False, **kwargs):
"""Validate a serial number against this Part instance. """Validate a serial number against this Part instance.
Note: This function is exposed to any Validation plugins, and thus can be customized. Note: This function is exposed to any Validation plugins, and thus can be customized.
@ -570,7 +624,7 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
for plugin in registry.with_mixin('validation'): for plugin in registry.with_mixin('validation'):
# Run the serial number through each custom validator # Run the serial number through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation # If the plugin returns 'True' we will skip any subsequent validation
if plugin.validate_serial_number(serial): if plugin.validate_serial_number(serial, self):
return True return True
except ValidationError as exc: except ValidationError as exc:
if raise_error: if raise_error:
@ -620,7 +674,7 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
conflicts = [] conflicts = []
for serial in serials: for serial in serials:
if not self.validate_serial_number(serial): if not self.validate_serial_number(serial, part=self):
conflicts.append(serial) conflicts.append(serial)
return conflicts return conflicts
@ -765,6 +819,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
if type(self.IPN) is str: if type(self.IPN) is str:
self.IPN = self.IPN.strip() self.IPN = self.IPN.strip()
# Run custom validation for the IPN field
self.validate_ipn()
# Run custom validation for the name field
self.validate_name()
if self.trackable: if self.trackable:
for part in self.get_used_in().all(): for part in self.get_used_in().all():
@ -777,7 +837,6 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
max_length=100, blank=False, max_length=100, blank=False,
help_text=_('Part name'), help_text=_('Part name'),
verbose_name=_('Name'), verbose_name=_('Name'),
validators=[validators.validate_part_name]
) )
is_template = models.BooleanField( is_template = models.BooleanField(
@ -821,7 +880,6 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
max_length=100, blank=True, null=True, max_length=100, blank=True, null=True,
verbose_name=_('IPN'), verbose_name=_('IPN'),
help_text=_('Internal Part Number'), help_text=_('Internal Part Number'),
validators=[validators.validate_part_ipn]
) )
revision = models.CharField( revision = models.CharField(

View File

@ -130,7 +130,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
for jj in range(10): for jj in range(10):
Part.objects.create( Part.objects.create(
name=f"Part xyz {jj}_{ii}", name=f"Part xyz {jj}_{ii}",
description="A test part", description="A test part with a description",
category=child category=child
) )
@ -428,8 +428,8 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
# Make sure that we get an error if we try to create part in the structural category # Make sure that we get an error if we try to create part in the structural category
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
part = Part.objects.create( part = Part.objects.create(
name="Part which shall not be created", name="-",
description="-", description="Part which shall not be created",
category=structural_category category=structural_category
) )
@ -446,8 +446,8 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
# Create the test part assigned to a non-structural category # Create the test part assigned to a non-structural category
part = Part.objects.create( part = Part.objects.create(
name="Part which category will be changed to structural", name="-",
description="-", description="Part which category will be changed to structural",
category=non_structural_category category=non_structural_category
) )
@ -743,7 +743,7 @@ class PartAPITest(PartAPITestBase):
# First, construct a set of template / variant parts # First, construct a set of template / variant parts
master_part = Part.objects.create( master_part = Part.objects.create(
name='Master', description='Master part', name='Master', description='Master part which has some variants',
category=category, category=category,
is_template=True, is_template=True,
) )
@ -1323,7 +1323,7 @@ class PartCreationTests(PartAPITestBase):
url = reverse('api-part-list') url = reverse('api-part-list')
name = "Kaltgerätestecker" name = "Kaltgerätestecker"
description = "Gerät" description = "Gerät Kaltgerätestecker strange chars should get through"
data = { data = {
"name": name, "name": name,
@ -1347,7 +1347,7 @@ class PartCreationTests(PartAPITestBase):
reverse('api-part-list'), reverse('api-part-list'),
{ {
'name': f'thing_{bom}{img}{params}', 'name': f'thing_{bom}{img}{params}',
'description': 'Some description', 'description': 'Some long description text for this part',
'category': 1, 'category': 1,
'duplicate': { 'duplicate': {
'part': 100, 'part': 100,
@ -2474,7 +2474,7 @@ class BomItemTest(InvenTreeAPITestCase):
# Create a variant part! # Create a variant part!
variant = Part.objects.create( variant = Part.objects.create(
name=f"Variant_{ii}", name=f"Variant_{ii}",
description="A variant part", description="A variant part, with a description",
component=True, component=True,
variant_of=sub_part variant_of=sub_part
) )
@ -2672,7 +2672,7 @@ class BomItemTest(InvenTreeAPITestCase):
# Create a variant part # Create a variant part
vp = Part.objects.create( vp = Part.objects.create(
name=f"Var {i}", name=f"Var {i}",
description="Variant part", description="Variant part description field",
variant_of=bom_item.sub_part, variant_of=bom_item.sub_part,
) )

View File

@ -66,7 +66,7 @@ class BomItemTest(TestCase):
def test_integer_quantity(self): def test_integer_quantity(self):
"""Test integer validation for BomItem.""" """Test integer validation for BomItem."""
p = Part.objects.create(name="test", description="d", component=True, trackable=True) p = Part.objects.create(name="test", description="part description", component=True, trackable=True)
# Creation of a BOMItem with a non-integer quantity of a trackable Part should fail # Creation of a BOMItem with a non-integer quantity of a trackable Part should fail
with self.assertRaises(django_exceptions.ValidationError): with self.assertRaises(django_exceptions.ValidationError):
@ -210,10 +210,10 @@ class BomItemTest(TestCase):
self.assertEqual(assembly.can_build, 0) self.assertEqual(assembly.can_build, 0)
# Create some component items # Create some component items
c1 = Part.objects.create(name="C1", description="C1") c1 = Part.objects.create(name="C1", description="Part C1 - this is just the part description")
c2 = Part.objects.create(name="C2", description="C2") c2 = Part.objects.create(name="C2", description="Part C2 - this is just the part description")
c3 = Part.objects.create(name="C3", description="C3") c3 = Part.objects.create(name="C3", description="Part C3 - this is just the part description")
c4 = Part.objects.create(name="C4", description="C4") c4 = Part.objects.create(name="C4", description="Part C4 - this is just the part description")
for p in [c1, c2, c3, c4]: for p in [c1, c2, c3, c4]:
# Ensure we have stock # Ensure we have stock

View File

@ -169,7 +169,7 @@ class PartTest(TestCase):
def test_str(self): def test_str(self):
"""Test string representation of a Part""" """Test string representation of a Part"""
p = Part.objects.get(pk=100) p = Part.objects.get(pk=100)
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?") self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it? Yes we can!")
def test_duplicate(self): def test_duplicate(self):
"""Test that we cannot create a "duplicate" Part.""" """Test that we cannot create a "duplicate" Part."""

View File

@ -215,7 +215,7 @@ class PartPricingTests(InvenTreeTestCase):
# Create a part # Create a part
p = part.models.Part.objects.create( p = part.models.Part.objects.create(
name='Test part for pricing', name='Test part for pricing',
description='hello world', description='hello world, this is a part description',
) )
# Create some stock items # Create some stock items

View File

@ -9,6 +9,8 @@ from django.urls import include, re_path
import requests import requests
import InvenTree.helpers import InvenTree.helpers
import part.models
import stock.models
from plugin.helpers import (MixinImplementationError, MixinNotImplementedError, from plugin.helpers import (MixinImplementationError, MixinNotImplementedError,
render_template, render_text) render_template, render_text)
from plugin.models import PluginConfig, PluginSetting from plugin.models import PluginConfig, PluginSetting
@ -245,42 +247,45 @@ class ValidationMixin:
super().__init__() super().__init__()
self.add_mixin('validation', True, __class__) self.add_mixin('validation', True, __class__)
def validate_part_name(self, name: str): def validate_part_name(self, name: str, part: part.models.Part):
"""Perform validation on a proposed Part name """Perform validation on a proposed Part name
Arguments: Arguments:
name: The proposed part name name: The proposed part name
part: The part instance we are validating against
Returns: Returns:
None or True None or True (refer to class docstring)
Raises: Raises:
ValidationError if the proposed name is objectionable ValidationError if the proposed name is objectionable
""" """
return None return None
def validate_part_ipn(self, ipn: str): def validate_part_ipn(self, ipn: str, part: part.models.Part):
"""Perform validation on a proposed Part IPN (internal part number) """Perform validation on a proposed Part IPN (internal part number)
Arguments: Arguments:
ipn: The proposed part IPN ipn: The proposed part IPN
part: The Part instance we are validating against
Returns: Returns:
None or True None or True (refer to class docstring)
Raises: Raises:
ValidationError if the proposed IPN is objectionable ValidationError if the proposed IPN is objectionable
""" """
return None return None
def validate_batch_code(self, batch_code: str): def validate_batch_code(self, batch_code: str, item: stock.models.StockItem):
"""Validate the supplied batch code """Validate the supplied batch code
Arguments: Arguments:
batch_code: The proposed batch code (string) batch_code: The proposed batch code (string)
item: The StockItem instance we are validating against
Returns: Returns:
None or True None or True (refer to class docstring)
Raises: Raises:
ValidationError if the proposed batch code is objectionable ValidationError if the proposed batch code is objectionable
@ -295,14 +300,15 @@ class ValidationMixin:
""" """
return None return None
def validate_serial_number(self, serial: str): def validate_serial_number(self, serial: str, part: part.models.Part):
"""Validate the supplied serial number """Validate the supplied serial number.
Arguments: Arguments:
serial: The proposed serial number (string) serial: The proposed serial number (string)
part: The Part instance for which this serial number is being validated
Returns: Returns:
None or True None or True (refer to class docstring)
Raises: Raises:
ValidationError if the proposed serial is objectionable ValidationError if the proposed serial is objectionable

View File

@ -9,13 +9,16 @@ from plugin.mixins import SettingsMixin, ValidationMixin
class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin): class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
"""A sample plugin class for demonstrating custom validation functions""" """A sample plugin class for demonstrating custom validation functions.
Simple of examples of custom validator code.
"""
NAME = "CustomValidator" NAME = "CustomValidator"
SLUG = "validator" SLUG = "validator"
TITLE = "Custom Validator Plugin" TITLE = "Custom Validator Plugin"
DESCRIPTION = "A sample plugin for demonstrating custom validation functionality" DESCRIPTION = "A sample plugin for demonstrating custom validation functionality"
VERSION = "0.1" VERSION = "0.2"
SETTINGS = { SETTINGS = {
'ILLEGAL_PART_CHARS': { 'ILLEGAL_PART_CHARS': {
@ -35,15 +38,30 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
'default': False, 'default': False,
'validator': bool, 'validator': bool,
}, },
'SERIAL_MUST_MATCH_PART': {
'name': 'Serial must match part',
'description': 'First letter of serial number must match first letter of part name',
'default': False,
'validator': bool,
},
'BATCH_CODE_PREFIX': { 'BATCH_CODE_PREFIX': {
'name': 'Batch prefix', 'name': 'Batch prefix',
'description': 'Required prefix for batch code', 'description': 'Required prefix for batch code',
'default': '', 'default': 'B',
} },
} }
def validate_part_name(self, name: str): def validate_part_name(self, name: str, part):
"""Validate part name""" """Custom validation for Part name field:
- Name must be shorter than the description field
- Name cannot contain illegal characters
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")
illegal_chars = self.get_setting('ILLEGAL_PART_CHARS') illegal_chars = self.get_setting('ILLEGAL_PART_CHARS')
@ -51,26 +69,41 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
if c in name: if c in name:
raise ValidationError(f"Illegal character in part name: '{c}'") raise ValidationError(f"Illegal character in part name: '{c}'")
def validate_part_ipn(self, ipn: str): def validate_part_ipn(self, ipn: str, part):
"""Validate part IPN""" """Validate part IPN
These examples are silly, but serve to demonstrate how the feature could be used
"""
if self.get_setting('IPN_MUST_CONTAIN_Q') and 'Q' not in ipn: if self.get_setting('IPN_MUST_CONTAIN_Q') and 'Q' not in ipn:
raise ValidationError("IPN must contain 'Q'") raise ValidationError("IPN must contain 'Q'")
def validate_serial_number(self, serial: str): def validate_serial_number(self, serial: str, part):
"""Validate serial number for a given StockItem""" """Validate serial number for a given StockItem
These examples are silly, but serve to demonstrate how the feature could be used
"""
if self.get_setting('SERIAL_MUST_BE_PALINDROME'): if self.get_setting('SERIAL_MUST_BE_PALINDROME'):
if serial != serial[::-1]: if serial != serial[::-1]:
raise ValidationError("Serial must be a palindrome") raise ValidationError("Serial must be a palindrome")
def validate_batch_code(self, batch_code: str): if self.get_setting('SERIAL_MUST_MATCH_PART'):
"""Ensure that a particular batch code meets specification""" # 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")
def validate_batch_code(self, batch_code: str, item):
"""Ensure that a particular batch code meets specification.
These examples are silly, but serve to demonstrate how the feature could be used
"""
prefix = self.get_setting('BATCH_CODE_PREFIX') prefix = self.get_setting('BATCH_CODE_PREFIX')
if not batch_code.startswith(prefix): if len(batch_code) > 0:
raise ValidationError(f"Batch code must start with '{prefix}'") if prefix and not batch_code.startswith(prefix):
raise ValidationError(f"Batch code must start with '{prefix}'")
def generate_batch_code(self): def generate_batch_code(self):
"""Generate a new batch code.""" """Generate a new batch code."""

View File

@ -81,7 +81,7 @@
pk: 102 pk: 102
fields: fields:
part: 25 part: 25
batch: 'ABCDE' batch: 'BCDE'
location: 7 location: 7
quantity: 0 quantity: 0
level: 0 level: 0
@ -109,7 +109,7 @@
part: 10001 part: 10001
location: 7 location: 7
quantity: 5 quantity: 5
batch: "AAA" batch: "BBAAA"
level: 0 level: 0
tree_id: 0 tree_id: 0
lft: 0 lft: 0

View File

@ -529,7 +529,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M
for plugin in registry.with_mixin('validation'): for plugin in registry.with_mixin('validation'):
try: try:
plugin.validate_batch_code(self.batch) plugin.validate_batch_code(self.batch, self)
except ValidationError as exc: except ValidationError as exc:
raise ValidationError({ raise ValidationError({
'batch': exc.message 'batch': exc.message
@ -560,6 +560,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M
if type(self.batch) is str: if type(self.batch) is str:
self.batch = self.batch.strip() self.batch = self.batch.strip()
# Custom validation of batch code
self.validate_batch_code() self.validate_batch_code()
try: try:

View File

@ -161,7 +161,7 @@ class StockLocationTest(StockAPITestCase):
# Create stock items in the location to be deleted # Create stock items in the location to be deleted
for jj in range(3): for jj in range(3):
stock_items.append(StockItem.objects.create( stock_items.append(StockItem.objects.create(
batch=f"Stock Item xyz {jj}", batch=f"Batch xyz {jj}",
location=stock_location_to_delete, location=stock_location_to_delete,
part=part part=part
)) ))
@ -180,7 +180,7 @@ class StockLocationTest(StockAPITestCase):
# Create stock items in the sub locations # Create stock items in the sub locations
for jj in range(3): for jj in range(3):
child_stock_locations_items.append(StockItem.objects.create( child_stock_locations_items.append(StockItem.objects.create(
batch=f"Stock item in sub location xyz {jj}", batch=f"B xyz {jj}",
part=part, part=part,
location=child location=child
)) ))
@ -272,7 +272,7 @@ class StockLocationTest(StockAPITestCase):
# Create the test stock item located to a non-structural category # Create the test stock item located to a non-structural category
item = StockItem.objects.create( item = StockItem.objects.create(
batch="Item which will be tried to relocated to a structural location", batch="BBB",
location=non_structural_location, location=non_structural_location,
part=part part=part
) )
@ -951,7 +951,7 @@ class StockItemTest(StockAPITestCase):
# First, construct a set of template / variant parts # First, construct a set of template / variant parts
master_part = part.models.Part.objects.create( master_part = part.models.Part.objects.create(
name='Master', description='Master part', name='Master', description='Master part which has variants',
category=category, category=category,
is_template=True, is_template=True,
) )

View File

@ -181,8 +181,8 @@ class StockTest(StockTestBase):
# Ensure that 'global uniqueness' setting is enabled # Ensure that 'global uniqueness' setting is enabled
InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', True, self.user) InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', True, self.user)
part_a = Part.objects.create(name='A', description='A', trackable=True) part_a = Part.objects.create(name='A', description='A part with a description', trackable=True)
part_b = Part.objects.create(name='B', description='B', trackable=True) part_b = Part.objects.create(name='B', description='B part with a description', trackable=True)
# Create a StockItem for part_a # Create a StockItem for part_a
StockItem.objects.create( StockItem.objects.create(
@ -577,10 +577,13 @@ class StockTest(StockTestBase):
"""Tests for stock serialization.""" """Tests for stock serialization."""
p = Part.objects.create( p = Part.objects.create(
name='trackable part', name='trackable part',
description='trackable part', description='A trackable part which can be tracked',
trackable=True, trackable=True,
) )
# Ensure we do not have unique serials enabled
InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False, None)
item = StockItem.objects.create( item = StockItem.objects.create(
part=p, part=p,
quantity=1, quantity=1,
@ -608,7 +611,7 @@ class StockTest(StockTestBase):
"""Unit tests for "large" serial numbers which exceed integer encoding.""" """Unit tests for "large" serial numbers which exceed integer encoding."""
p = Part.objects.create( p = Part.objects.create(
name='trackable part', name='trackable part',
description='trackable part', description='A trackable part with really big serial numbers',
trackable=True, trackable=True,
) )
@ -721,6 +724,9 @@ class StockTest(StockTestBase):
self.assertEqual(item.quantity, 10) self.assertEqual(item.quantity, 10)
# Ensure we do not have unique serials enabled
InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False, None)
item.serializeStock(3, [1, 2, 3], self.user) item.serializeStock(3, [1, 2, 3], self.user)
self.assertEqual(item.quantity, 7) self.assertEqual(item.quantity, 7)
@ -1087,8 +1093,14 @@ class TestResultTest(StockTestBase):
item.pk = None item.pk = None
item.serial = None item.serial = None
item.quantity = 50 item.quantity = 50
item.batch = "B344"
# Try with an invalid batch code (according to sample validatoin plugin)
item.batch = "X234"
with self.assertRaises(ValidationError):
item.save()
item.batch = "B123"
item.save() item.save()
# Do some tests! # Do some tests!