Extend functionality of custom validation plugins (#4391)

* Pass "Part" instance to plugins when calling validate_serial_number

* Pass part instance through when validating IPN

* Improve custom part name validation

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

* Pass StockItem through when validating batch code

* Pass Part instance through when calling validate_serial_number

* Bug fix

* Update unit tests

* Unit test fixes

* Fixes for unit tests

* More unit test fixes

* More unit tests

* Furrther unit test fixes

* Simplify custom batch code validation

* Further improvements to unit tests

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

1
.gitignore vendored
View File

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

View File

@ -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%")

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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