From abeb85cbb34cab1a1f6171ee8b93f980a2e42e81 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 7 Mar 2023 22:43:12 +1100 Subject: [PATCH] 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 --- .gitignore | 1 + InvenTree/InvenTree/tests.py | 10 +-- InvenTree/InvenTree/validators.py | 46 ------------- InvenTree/order/test_api.py | 20 +++--- InvenTree/part/fixtures/part.yaml | 10 ++- InvenTree/part/migrations/0001_initial.py | 2 +- .../migrations/0006_auto_20190526_1215.py | 3 +- .../migrations/0010_auto_20190620_2135.py | 3 +- .../migrations/0028_auto_20200203_1007.py | 3 +- .../migrations/0048_auto_20200902_1404.py | 3 +- .../migrations/0061_auto_20210103_2313.py | 5 +- InvenTree/part/models.py | 68 +++++++++++++++++-- InvenTree/part/test_api.py | 20 +++--- InvenTree/part/test_bom_item.py | 10 +-- InvenTree/part/test_part.py | 2 +- InvenTree/part/test_pricing.py | 2 +- InvenTree/plugin/base/integration/mixins.py | 24 ++++--- .../samples/integration/validation_sample.py | 61 +++++++++++++---- InvenTree/stock/fixtures/stock.yaml | 4 +- InvenTree/stock/models.py | 3 +- InvenTree/stock/test_api.py | 8 +-- InvenTree/stock/tests.py | 22 ++++-- 22 files changed, 193 insertions(+), 137 deletions(-) diff --git a/.gitignore b/.gitignore index 83fef7d272..98610ffea5 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ _tmp.csv inventree/label.pdf inventree/label.png inventree/my_special* +_tests*.txt # Sphinx files docs/_build diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index d0f10e0e2a..0f5b04f512 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -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%") diff --git a/InvenTree/InvenTree/validators.py b/InvenTree/InvenTree/validators.py index 0edabd280c..0b8c263be2 100644 --- a/InvenTree/InvenTree/validators.py +++ b/InvenTree/InvenTree/validators.py @@ -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.""" diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 7080e4e4e3..f37937d7ce 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -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, { diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 762a0bf740..8d461402d1 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -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 diff --git a/InvenTree/part/migrations/0001_initial.py b/InvenTree/part/migrations/0001_initial.py index 0368abd9d0..eeed95ce20 100644 --- a/InvenTree/part/migrations/0001_initial.py +++ b/InvenTree/part/migrations/0001_initial.py @@ -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)), diff --git a/InvenTree/part/migrations/0006_auto_20190526_1215.py b/InvenTree/part/migrations/0006_auto_20190526_1215.py index b91cc8bbab..1fbf02407f 100644 --- a/InvenTree/part/migrations/0006_auto_20190526_1215.py +++ b/InvenTree/part/migrations/0006_auto_20190526_1215.py @@ -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', diff --git a/InvenTree/part/migrations/0010_auto_20190620_2135.py b/InvenTree/part/migrations/0010_auto_20190620_2135.py index 2033e2870f..a8bd18c43a 100644 --- a/InvenTree/part/migrations/0010_auto_20190620_2135.py +++ b/InvenTree/part/migrations/0010_auto_20190620_2135.py @@ -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), ), ] diff --git a/InvenTree/part/migrations/0028_auto_20200203_1007.py b/InvenTree/part/migrations/0028_auto_20200203_1007.py index eca1788f06..b34715ecbc 100644 --- a/InvenTree/part/migrations/0028_auto_20200203_1007.py +++ b/InvenTree/part/migrations/0028_auto_20200203_1007.py @@ -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), ), ] diff --git a/InvenTree/part/migrations/0048_auto_20200902_1404.py b/InvenTree/part/migrations/0048_auto_20200902_1404.py index 4f3584f3c4..5b1440c950 100644 --- a/InvenTree/part/migrations/0048_auto_20200902_1404.py +++ b/InvenTree/part/migrations/0048_auto_20200902_1404.py @@ -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', diff --git a/InvenTree/part/migrations/0061_auto_20210103_2313.py b/InvenTree/part/migrations/0061_auto_20210103_2313.py index 502ff7c8e2..baf52cc811 100644 --- a/InvenTree/part/migrations/0061_auto_20210103_2313.py +++ b/InvenTree/part/migrations/0061_auto_20210103_2313.py @@ -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', diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index cf9fb516d8..1aabf5ffb8 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -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( diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index e4ab05ae7f..fedbc55b84 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -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, ) diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index 266ae94aec..196915e593 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -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 diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 92178ae728..d6b191be13 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -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.""" diff --git a/InvenTree/part/test_pricing.py b/InvenTree/part/test_pricing.py index 5fa658fe54..dcd2fdc4aa 100644 --- a/InvenTree/part/test_pricing.py +++ b/InvenTree/part/test_pricing.py @@ -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 diff --git a/InvenTree/plugin/base/integration/mixins.py b/InvenTree/plugin/base/integration/mixins.py index f8280e9bd8..2551f00356 100644 --- a/InvenTree/plugin/base/integration/mixins.py +++ b/InvenTree/plugin/base/integration/mixins.py @@ -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 diff --git a/InvenTree/plugin/samples/integration/validation_sample.py b/InvenTree/plugin/samples/integration/validation_sample.py index aac6b0cd8e..a889dcdfc7 100644 --- a/InvenTree/plugin/samples/integration/validation_sample.py +++ b/InvenTree/plugin/samples/integration/validation_sample.py @@ -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.""" diff --git a/InvenTree/stock/fixtures/stock.yaml b/InvenTree/stock/fixtures/stock.yaml index 00d1b1ef4f..99ca9cacea 100644 --- a/InvenTree/stock/fixtures/stock.yaml +++ b/InvenTree/stock/fixtures/stock.yaml @@ -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 diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 4dc95c313c..2f25b84354 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -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: diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 31a8d514fe..68fb75baaf 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -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, ) diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index d30c121ccf..675cadadd2 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -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!