2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 13:05:42 +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:
Oliver
2023-03-07 22:43:12 +11:00
committed by GitHub
parent edae82caa5
commit abeb85cbb3
22 changed files with 193 additions and 137 deletions

View File

@ -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

View File

@ -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)),

View File

@ -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',

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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',

View File

@ -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',

View File

@ -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(

View File

@ -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,
)

View File

@ -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

View File

@ -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."""

View File

@ -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