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:
@ -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
|
||||
|
Reference in New Issue
Block a user