mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
edae82caa5
commit
abeb85cbb3
1
.gitignore
vendored
1
.gitignore
vendored
@ -43,6 +43,7 @@ _tmp.csv
|
||||
inventree/label.pdf
|
||||
inventree/label.png
|
||||
inventree/my_special*
|
||||
_tests*.txt
|
||||
|
||||
# Sphinx files
|
||||
docs/_build
|
||||
|
@ -29,20 +29,12 @@ from stock.models import StockItem, StockLocation
|
||||
|
||||
from . import config, helpers, ready, status, version
|
||||
from .tasks import offload_task
|
||||
from .validators import validate_overage, validate_part_name
|
||||
from .validators import validate_overage
|
||||
|
||||
|
||||
class ValidatorTest(TestCase):
|
||||
"""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):
|
||||
"""Test overage validator."""
|
||||
validate_overage("100%")
|
||||
|
@ -11,8 +11,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
from jinja2 import Template
|
||||
from moneyed import CURRENCIES
|
||||
|
||||
import common.models
|
||||
|
||||
|
||||
def validate_currency_code(code):
|
||||
"""Check that a given code is a valid currency code."""
|
||||
@ -47,50 +45,6 @@ class AllowedURLValidator(validators.URLValidator):
|
||||
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):
|
||||
"""Validate the 'reference' field of a PurchaseOrder."""
|
||||
|
||||
|
@ -950,12 +950,12 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
{
|
||||
'line_item': 1,
|
||||
'quantity': 10,
|
||||
'batch_code': 'abc-123',
|
||||
'batch_code': 'B-abc-123',
|
||||
},
|
||||
{
|
||||
'line_item': 2,
|
||||
'quantity': 10,
|
||||
'batch_code': 'xyz-789',
|
||||
'batch_code': 'B-xyz-789',
|
||||
}
|
||||
],
|
||||
'location': 1,
|
||||
@ -975,8 +975,8 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
item_1 = StockItem.objects.filter(supplier_part=line_1.part).first()
|
||||
item_2 = StockItem.objects.filter(supplier_part=line_2.part).first()
|
||||
|
||||
self.assertEqual(item_1.batch, 'abc-123')
|
||||
self.assertEqual(item_2.batch, 'xyz-789')
|
||||
self.assertEqual(item_1.batch, 'B-abc-123')
|
||||
self.assertEqual(item_2.batch, 'B-xyz-789')
|
||||
|
||||
def test_serial_numbers(self):
|
||||
"""Test that we can supply a 'serial number' when receiving items."""
|
||||
@ -991,13 +991,13 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
{
|
||||
'line_item': 1,
|
||||
'quantity': 10,
|
||||
'batch_code': 'abc-123',
|
||||
'batch_code': 'B-abc-123',
|
||||
'serial_numbers': '100+',
|
||||
},
|
||||
{
|
||||
'line_item': 2,
|
||||
'quantity': 10,
|
||||
'batch_code': 'xyz-789',
|
||||
'batch_code': 'B-xyz-789',
|
||||
}
|
||||
],
|
||||
'location': 1,
|
||||
@ -1022,7 +1022,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
item = StockItem.objects.get(serial_int=i)
|
||||
self.assertEqual(item.serial, str(i))
|
||||
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
|
||||
items = StockItem.objects.filter(supplier_part=line_2.part)
|
||||
@ -1031,7 +1031,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
item = items.first()
|
||||
|
||||
self.assertEqual(item.quantity, 10)
|
||||
self.assertEqual(item.batch, 'xyz-789')
|
||||
self.assertEqual(item.batch, 'B-xyz-789')
|
||||
|
||||
|
||||
class SalesOrderTest(OrderTest):
|
||||
@ -1437,7 +1437,7 @@ class SalesOrderLineItemTest(OrderTest):
|
||||
n_parts = Part.objects.filter(salable=True).count()
|
||||
|
||||
# List by part
|
||||
for part in Part.objects.filter(salable=True):
|
||||
for part in Part.objects.filter(salable=True)[:3]:
|
||||
response = self.get(
|
||||
self.url,
|
||||
{
|
||||
@ -1449,7 +1449,7 @@ class SalesOrderLineItemTest(OrderTest):
|
||||
self.assertEqual(response.data['count'], n_orders)
|
||||
|
||||
# List by order
|
||||
for order in models.SalesOrder.objects.all():
|
||||
for order in models.SalesOrder.objects.all()[:3]:
|
||||
response = self.get(
|
||||
self.url,
|
||||
{
|
||||
|
@ -95,7 +95,7 @@
|
||||
pk: 100
|
||||
fields:
|
||||
name: 'Bob'
|
||||
description: 'Can we build it?'
|
||||
description: 'Can we build it? Yes we can!'
|
||||
assembly: true
|
||||
salable: true
|
||||
purchaseable: false
|
||||
@ -112,7 +112,7 @@
|
||||
pk: 101
|
||||
fields:
|
||||
name: 'Assembly'
|
||||
description: 'A high level assembly'
|
||||
description: 'A high level assembly part'
|
||||
salable: true
|
||||
active: True
|
||||
tree_id: 0
|
||||
@ -125,7 +125,7 @@
|
||||
pk: 10000
|
||||
fields:
|
||||
name: 'Chair Template'
|
||||
description: 'A chair'
|
||||
description: 'A chair, which is actually just a template part'
|
||||
is_template: True
|
||||
trackable: true
|
||||
salable: true
|
||||
@ -139,6 +139,7 @@
|
||||
pk: 10001
|
||||
fields:
|
||||
name: 'Blue Chair'
|
||||
description: 'A variant chair part which is blue'
|
||||
variant_of: 10000
|
||||
trackable: true
|
||||
category: 7
|
||||
@ -151,6 +152,7 @@
|
||||
pk: 10002
|
||||
fields:
|
||||
name: 'Red chair'
|
||||
description: 'A variant chair part which is red'
|
||||
variant_of: 10000
|
||||
IPN: "R.CH"
|
||||
trackable: true
|
||||
@ -164,6 +166,7 @@
|
||||
pk: 10003
|
||||
fields:
|
||||
name: 'Green chair'
|
||||
description: 'A template chair part which is green'
|
||||
variant_of: 10000
|
||||
category: 7
|
||||
trackable: true
|
||||
@ -176,6 +179,7 @@
|
||||
pk: 10004
|
||||
fields:
|
||||
name: 'Green chair variant'
|
||||
description: 'A green chair, which is a variant of the chair template'
|
||||
variant_of: 10003
|
||||
is_template: true
|
||||
category: 7
|
||||
|
@ -49,7 +49,7 @@ class Migration(migrations.Migration):
|
||||
name='Part',
|
||||
fields=[
|
||||
('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)),
|
||||
('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)),
|
||||
|
@ -1,6 +1,5 @@
|
||||
# Generated by Django 2.2 on 2019-05-26 02:15
|
||||
|
||||
import InvenTree.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@ -14,7 +13,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='part',
|
||||
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(
|
||||
name='part',
|
||||
|
@ -1,6 +1,5 @@
|
||||
# Generated by Django 2.2.2 on 2019-06-20 11:35
|
||||
|
||||
import InvenTree.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@ -14,6 +13,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='part',
|
||||
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),
|
||||
),
|
||||
]
|
||||
|
@ -1,6 +1,5 @@
|
||||
# Generated by Django 2.2.9 on 2020-02-03 10:07
|
||||
|
||||
import InvenTree.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@ -14,6 +13,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='part',
|
||||
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),
|
||||
),
|
||||
]
|
||||
|
@ -1,7 +1,6 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-02 14:04
|
||||
|
||||
import InvenTree.fields
|
||||
import InvenTree.validators
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -16,7 +15,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='part',
|
||||
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(
|
||||
model_name='part',
|
||||
|
@ -1,7 +1,6 @@
|
||||
# Generated by Django 3.0.7 on 2021-01-03 12:13
|
||||
|
||||
import InvenTree.fields
|
||||
import InvenTree.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import mptt.fields
|
||||
@ -19,7 +18,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='part',
|
||||
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(
|
||||
model_name='part',
|
||||
@ -59,7 +58,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='part',
|
||||
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(
|
||||
model_name='part',
|
||||
|
@ -6,6 +6,7 @@ import decimal
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
@ -538,7 +539,60 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
|
||||
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.
|
||||
|
||||
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'):
|
||||
# Run the serial number through each custom validator
|
||||
# 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
|
||||
except ValidationError as exc:
|
||||
if raise_error:
|
||||
@ -620,7 +674,7 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
conflicts = []
|
||||
|
||||
for serial in serials:
|
||||
if not self.validate_serial_number(serial):
|
||||
if not self.validate_serial_number(serial, part=self):
|
||||
conflicts.append(serial)
|
||||
|
||||
return conflicts
|
||||
@ -765,6 +819,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
if type(self.IPN) is str:
|
||||
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:
|
||||
for part in self.get_used_in().all():
|
||||
|
||||
@ -777,7 +837,6 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
max_length=100, blank=False,
|
||||
help_text=_('Part name'),
|
||||
verbose_name=_('Name'),
|
||||
validators=[validators.validate_part_name]
|
||||
)
|
||||
|
||||
is_template = models.BooleanField(
|
||||
@ -821,7 +880,6 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
max_length=100, blank=True, null=True,
|
||||
verbose_name=_('IPN'),
|
||||
help_text=_('Internal Part Number'),
|
||||
validators=[validators.validate_part_ipn]
|
||||
)
|
||||
|
||||
revision = models.CharField(
|
||||
|
@ -130,7 +130,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||
for jj in range(10):
|
||||
Part.objects.create(
|
||||
name=f"Part xyz {jj}_{ii}",
|
||||
description="A test part",
|
||||
description="A test part with a description",
|
||||
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
|
||||
with self.assertRaises(ValidationError):
|
||||
part = Part.objects.create(
|
||||
name="Part which shall not be created",
|
||||
description="-",
|
||||
name="-",
|
||||
description="Part which shall not be created",
|
||||
category=structural_category
|
||||
)
|
||||
|
||||
@ -446,8 +446,8 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||
|
||||
# Create the test part assigned to a non-structural category
|
||||
part = Part.objects.create(
|
||||
name="Part which category will be changed to structural",
|
||||
description="-",
|
||||
name="-",
|
||||
description="Part which category will be changed to structural",
|
||||
category=non_structural_category
|
||||
)
|
||||
|
||||
@ -743,7 +743,7 @@ class PartAPITest(PartAPITestBase):
|
||||
|
||||
# First, construct a set of template / variant parts
|
||||
master_part = Part.objects.create(
|
||||
name='Master', description='Master part',
|
||||
name='Master', description='Master part which has some variants',
|
||||
category=category,
|
||||
is_template=True,
|
||||
)
|
||||
@ -1323,7 +1323,7 @@ class PartCreationTests(PartAPITestBase):
|
||||
url = reverse('api-part-list')
|
||||
|
||||
name = "Kaltgerätestecker"
|
||||
description = "Gerät"
|
||||
description = "Gerät Kaltgerätestecker strange chars should get through"
|
||||
|
||||
data = {
|
||||
"name": name,
|
||||
@ -1347,7 +1347,7 @@ class PartCreationTests(PartAPITestBase):
|
||||
reverse('api-part-list'),
|
||||
{
|
||||
'name': f'thing_{bom}{img}{params}',
|
||||
'description': 'Some description',
|
||||
'description': 'Some long description text for this part',
|
||||
'category': 1,
|
||||
'duplicate': {
|
||||
'part': 100,
|
||||
@ -2474,7 +2474,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
# Create a variant part!
|
||||
variant = Part.objects.create(
|
||||
name=f"Variant_{ii}",
|
||||
description="A variant part",
|
||||
description="A variant part, with a description",
|
||||
component=True,
|
||||
variant_of=sub_part
|
||||
)
|
||||
@ -2672,7 +2672,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
# Create a variant part
|
||||
vp = Part.objects.create(
|
||||
name=f"Var {i}",
|
||||
description="Variant part",
|
||||
description="Variant part description field",
|
||||
variant_of=bom_item.sub_part,
|
||||
)
|
||||
|
||||
|
@ -66,7 +66,7 @@ class BomItemTest(TestCase):
|
||||
|
||||
def test_integer_quantity(self):
|
||||
"""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
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
@ -210,10 +210,10 @@ class BomItemTest(TestCase):
|
||||
self.assertEqual(assembly.can_build, 0)
|
||||
|
||||
# Create some component items
|
||||
c1 = Part.objects.create(name="C1", description="C1")
|
||||
c2 = Part.objects.create(name="C2", description="C2")
|
||||
c3 = Part.objects.create(name="C3", description="C3")
|
||||
c4 = Part.objects.create(name="C4", description="C4")
|
||||
c1 = Part.objects.create(name="C1", description="Part C1 - this is just the part description")
|
||||
c2 = Part.objects.create(name="C2", description="Part C2 - this is just the part description")
|
||||
c3 = Part.objects.create(name="C3", description="Part C3 - this is just the part description")
|
||||
c4 = Part.objects.create(name="C4", description="Part C4 - this is just the part description")
|
||||
|
||||
for p in [c1, c2, c3, c4]:
|
||||
# Ensure we have stock
|
||||
|
@ -169,7 +169,7 @@ class PartTest(TestCase):
|
||||
def test_str(self):
|
||||
"""Test string representation of a Part"""
|
||||
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):
|
||||
"""Test that we cannot create a "duplicate" Part."""
|
||||
|
@ -215,7 +215,7 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
# Create a part
|
||||
p = part.models.Part.objects.create(
|
||||
name='Test part for pricing',
|
||||
description='hello world',
|
||||
description='hello world, this is a part description',
|
||||
)
|
||||
|
||||
# Create some stock items
|
||||
|
@ -9,6 +9,8 @@ from django.urls import include, re_path
|
||||
import requests
|
||||
|
||||
import InvenTree.helpers
|
||||
import part.models
|
||||
import stock.models
|
||||
from plugin.helpers import (MixinImplementationError, MixinNotImplementedError,
|
||||
render_template, render_text)
|
||||
from plugin.models import PluginConfig, PluginSetting
|
||||
@ -245,42 +247,45 @@ class ValidationMixin:
|
||||
super().__init__()
|
||||
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
|
||||
|
||||
Arguments:
|
||||
name: The proposed part name
|
||||
part: The part instance we are validating against
|
||||
|
||||
Returns:
|
||||
None or True
|
||||
None or True (refer to class docstring)
|
||||
|
||||
Raises:
|
||||
ValidationError if the proposed name is objectionable
|
||||
"""
|
||||
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)
|
||||
|
||||
Arguments:
|
||||
ipn: The proposed part IPN
|
||||
part: The Part instance we are validating against
|
||||
|
||||
Returns:
|
||||
None or True
|
||||
None or True (refer to class docstring)
|
||||
|
||||
Raises:
|
||||
ValidationError if the proposed IPN is objectionable
|
||||
"""
|
||||
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
|
||||
|
||||
Arguments:
|
||||
batch_code: The proposed batch code (string)
|
||||
item: The StockItem instance we are validating against
|
||||
|
||||
Returns:
|
||||
None or True
|
||||
None or True (refer to class docstring)
|
||||
|
||||
Raises:
|
||||
ValidationError if the proposed batch code is objectionable
|
||||
@ -295,14 +300,15 @@ class ValidationMixin:
|
||||
"""
|
||||
return None
|
||||
|
||||
def validate_serial_number(self, serial: str):
|
||||
"""Validate the supplied serial number
|
||||
def validate_serial_number(self, serial: str, part: part.models.Part):
|
||||
"""Validate the supplied serial number.
|
||||
|
||||
Arguments:
|
||||
serial: The proposed serial number (string)
|
||||
part: The Part instance for which this serial number is being validated
|
||||
|
||||
Returns:
|
||||
None or True
|
||||
None or True (refer to class docstring)
|
||||
|
||||
Raises:
|
||||
ValidationError if the proposed serial is objectionable
|
||||
|
@ -9,13 +9,16 @@ from plugin.mixins import SettingsMixin, ValidationMixin
|
||||
|
||||
|
||||
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"
|
||||
SLUG = "validator"
|
||||
TITLE = "Custom Validator Plugin"
|
||||
DESCRIPTION = "A sample plugin for demonstrating custom validation functionality"
|
||||
VERSION = "0.1"
|
||||
VERSION = "0.2"
|
||||
|
||||
SETTINGS = {
|
||||
'ILLEGAL_PART_CHARS': {
|
||||
@ -35,15 +38,30 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
|
||||
'default': False,
|
||||
'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': {
|
||||
'name': 'Batch prefix',
|
||||
'description': 'Required prefix for batch code',
|
||||
'default': '',
|
||||
}
|
||||
'default': 'B',
|
||||
},
|
||||
}
|
||||
|
||||
def validate_part_name(self, name: str):
|
||||
"""Validate part name"""
|
||||
def validate_part_name(self, name: str, part):
|
||||
"""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')
|
||||
|
||||
@ -51,26 +69,41 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin):
|
||||
if c in name:
|
||||
raise ValidationError(f"Illegal character in part name: '{c}'")
|
||||
|
||||
def validate_part_ipn(self, ipn: str):
|
||||
"""Validate part IPN"""
|
||||
def validate_part_ipn(self, ipn: str, part):
|
||||
"""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:
|
||||
raise ValidationError("IPN must contain 'Q'")
|
||||
|
||||
def validate_serial_number(self, serial: str):
|
||||
"""Validate serial number for a given StockItem"""
|
||||
def validate_serial_number(self, serial: str, part):
|
||||
"""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 serial != serial[::-1]:
|
||||
raise ValidationError("Serial must be a palindrome")
|
||||
|
||||
def validate_batch_code(self, batch_code: str):
|
||||
"""Ensure that a particular batch code meets specification"""
|
||||
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")
|
||||
|
||||
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')
|
||||
|
||||
if not batch_code.startswith(prefix):
|
||||
raise ValidationError(f"Batch code must start with '{prefix}'")
|
||||
if len(batch_code) > 0:
|
||||
if prefix and not batch_code.startswith(prefix):
|
||||
raise ValidationError(f"Batch code must start with '{prefix}'")
|
||||
|
||||
def generate_batch_code(self):
|
||||
"""Generate a new batch code."""
|
||||
|
@ -81,7 +81,7 @@
|
||||
pk: 102
|
||||
fields:
|
||||
part: 25
|
||||
batch: 'ABCDE'
|
||||
batch: 'BCDE'
|
||||
location: 7
|
||||
quantity: 0
|
||||
level: 0
|
||||
@ -109,7 +109,7 @@
|
||||
part: 10001
|
||||
location: 7
|
||||
quantity: 5
|
||||
batch: "AAA"
|
||||
batch: "BBAAA"
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
|
@ -529,7 +529,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M
|
||||
|
||||
for plugin in registry.with_mixin('validation'):
|
||||
try:
|
||||
plugin.validate_batch_code(self.batch)
|
||||
plugin.validate_batch_code(self.batch, self)
|
||||
except ValidationError as exc:
|
||||
raise ValidationError({
|
||||
'batch': exc.message
|
||||
@ -560,6 +560,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M
|
||||
if type(self.batch) is str:
|
||||
self.batch = self.batch.strip()
|
||||
|
||||
# Custom validation of batch code
|
||||
self.validate_batch_code()
|
||||
|
||||
try:
|
||||
|
@ -161,7 +161,7 @@ class StockLocationTest(StockAPITestCase):
|
||||
# Create stock items in the location to be deleted
|
||||
for jj in range(3):
|
||||
stock_items.append(StockItem.objects.create(
|
||||
batch=f"Stock Item xyz {jj}",
|
||||
batch=f"Batch xyz {jj}",
|
||||
location=stock_location_to_delete,
|
||||
part=part
|
||||
))
|
||||
@ -180,7 +180,7 @@ class StockLocationTest(StockAPITestCase):
|
||||
# Create stock items in the sub locations
|
||||
for jj in range(3):
|
||||
child_stock_locations_items.append(StockItem.objects.create(
|
||||
batch=f"Stock item in sub location xyz {jj}",
|
||||
batch=f"B xyz {jj}",
|
||||
part=part,
|
||||
location=child
|
||||
))
|
||||
@ -272,7 +272,7 @@ class StockLocationTest(StockAPITestCase):
|
||||
|
||||
# Create the test stock item located to a non-structural category
|
||||
item = StockItem.objects.create(
|
||||
batch="Item which will be tried to relocated to a structural location",
|
||||
batch="BBB",
|
||||
location=non_structural_location,
|
||||
part=part
|
||||
)
|
||||
@ -951,7 +951,7 @@ class StockItemTest(StockAPITestCase):
|
||||
|
||||
# First, construct a set of template / variant parts
|
||||
master_part = part.models.Part.objects.create(
|
||||
name='Master', description='Master part',
|
||||
name='Master', description='Master part which has variants',
|
||||
category=category,
|
||||
is_template=True,
|
||||
)
|
||||
|
@ -181,8 +181,8 @@ class StockTest(StockTestBase):
|
||||
# Ensure that 'global uniqueness' setting is enabled
|
||||
InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', True, self.user)
|
||||
|
||||
part_a = Part.objects.create(name='A', description='A', trackable=True)
|
||||
part_b = Part.objects.create(name='B', description='B', 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 part with a description', trackable=True)
|
||||
|
||||
# Create a StockItem for part_a
|
||||
StockItem.objects.create(
|
||||
@ -577,10 +577,13 @@ class StockTest(StockTestBase):
|
||||
"""Tests for stock serialization."""
|
||||
p = Part.objects.create(
|
||||
name='trackable part',
|
||||
description='trackable part',
|
||||
description='A trackable part which can be tracked',
|
||||
trackable=True,
|
||||
)
|
||||
|
||||
# Ensure we do not have unique serials enabled
|
||||
InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False, None)
|
||||
|
||||
item = StockItem.objects.create(
|
||||
part=p,
|
||||
quantity=1,
|
||||
@ -608,7 +611,7 @@ class StockTest(StockTestBase):
|
||||
"""Unit tests for "large" serial numbers which exceed integer encoding."""
|
||||
p = Part.objects.create(
|
||||
name='trackable part',
|
||||
description='trackable part',
|
||||
description='A trackable part with really big serial numbers',
|
||||
trackable=True,
|
||||
)
|
||||
|
||||
@ -721,6 +724,9 @@ class StockTest(StockTestBase):
|
||||
|
||||
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)
|
||||
|
||||
self.assertEqual(item.quantity, 7)
|
||||
@ -1087,8 +1093,14 @@ class TestResultTest(StockTestBase):
|
||||
item.pk = None
|
||||
item.serial = None
|
||||
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()
|
||||
|
||||
# Do some tests!
|
||||
|
Loading…
Reference in New Issue
Block a user