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