Parameter filtering (#4823)

* adds new field 'parameter type' to PartParameterTemplate model

* Move part parameter settings onto their own page

* Add "choices" and "regex" template types

* Adds validation for PartParameter based on template type

* javascript cleanup

* Fix for serializers.py

* Add unit testing for parameter validation

* Add filters

* Rename "type" field to "param_type"

- Should have seen that one coming

* Coerce 'boolean' value to True/False

* table update

* js linting

* Add requirement for "pint" package

* Add validator for physical unit types

- Revert a previous migration which adds "parameter type" and "validator" fields
- These will get implemented later, too much scope creep for this PR
- Add unit test for validation of "units" field

* Update PartParameter model

- Add data_numeric field (will be used later)
- Add MinLengthValidator to data field

* Run validation for part parameter data

- Ensure it can be converted to internal units

* Update admin interface to display partparameter values inline for a part

* Adds validation of part parameter data value

- Also converts to base units, and stores as "numeric" value
- Display "numeric" value in tables
- Create new file conversion.py for data conversion

* Update unit tests and fix some bugs

* Update docstring

* Add units to parameter columns in parameteric part table

* Allow part list to be ordered by a particular parameter value

- Annotate queryset with new "order_by_parameter" method
- Skeleton method for future work

* Bump API version

* Adds unit testing for sorting parts by parameter value

* Update historical data migrations

- Turns out RunPython.noop is a thing?

* Cache the unit registry

- Creating the unit registry takes a significant amount of time
- Construct when first called, and then cache for subsequent hits
- Massive improvement in performance

* Throw error on empty values when converting between units

* Data migration for converting existing part parameter values

* Handle more error cases

* Show parameteric table on top-level part page too

* Unit test for data migration

* Update credits in docs

* Improved error checking

* WIP docs updates

* Fix parameteric table filtering

* remove zoom property

* Fix for import path

* Update parameter docs

* Run background task to rebuild parameters when template changes

* Make "data_numeric" field nullable

- Defaulting to zero is not appropriate, as the actual value may be zero
- Sorting still seems to work just fine

* Fixes for unit test

* More unit test fixes

* Further fixes for unit tests

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver 2023-05-19 13:24:55 +10:00 committed by GitHub
parent cb8ae10280
commit 9e77b9fc56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1148 additions and 367 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 115 INVENTREE_API_VERSION = 116
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v116 -> 2023-05-18 : https://github.com/inventree/InvenTree/pull/4823
- Updates to part parameter implementation, to use physical units
v115 - > 2023-05-18 : https://github.com/inventree/InvenTree/pull/4846 v115 - > 2023-05-18 : https://github.com/inventree/InvenTree/pull/4846
- Adds ability to partially scrap a build output - Adds ability to partially scrap a build output

View File

@ -0,0 +1,81 @@
"""Helper functions for converting between units."""
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
import pint
_unit_registry = None
def get_unit_registry():
"""Return a custom instance of the Pint UnitRegistry."""
global _unit_registry
# Cache the unit registry for speedier access
if _unit_registry is None:
_unit_registry = pint.UnitRegistry()
# TODO: Allow for custom units to be defined in the database
return _unit_registry
def convert_physical_value(value: str, unit: str = None):
"""Validate that the provided value is a valid physical quantity.
Arguments:
value: Value to validate (str)
unit: Optional unit to convert to, and validate against
Raises:
ValidationError: If the value is invalid
Returns:
The converted quantity, in the specified units
"""
# Ensure that the value is a string
value = str(value).strip()
# Error on blank values
if not value:
raise ValidationError(_('No value provided'))
ureg = get_unit_registry()
error = ''
try:
# Convert to a quantity
val = ureg.Quantity(value)
if unit:
if val.units == ureg.dimensionless:
# If the provided value is dimensionless, assume that the unit is correct
val = ureg.Quantity(value, unit)
else:
# Convert to the provided unit (may raise an exception)
val = val.to(unit)
# At this point we *should* have a valid pint value
# To double check, look at the maginitude
float(val.magnitude)
except ValueError:
error = _('Provided value is not a valid number')
except pint.errors.UndefinedUnitError:
error = _('Provided value has an invalid unit')
except pint.errors.DefinitionSyntaxError:
error = _('Provided value has an invalid unit')
except pint.errors.DimensionalityError:
error = _('Provided value could not be converted to the specified unit')
if error:
if unit:
error += f' ({unit})'
raise ValidationError(error)
# Return the converted value
return val

View File

@ -319,7 +319,6 @@ main {
.filter-input { .filter-input {
display: inline-block; display: inline-block;
*display: inline; *display: inline;
zoom: 1;
} }
.filter-tag:hover { .filter-tag:hover {

View File

@ -167,6 +167,7 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs)
If workers are not running or force_sync flag If workers are not running or force_sync flag
is set then the task is ran synchronously. is set then the task is ran synchronously.
""" """
try: try:
import importlib import importlib

View File

@ -8,9 +8,31 @@ from django.core import validators
from django.core.exceptions import FieldDoesNotExist, ValidationError from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import pint
from jinja2 import Template from jinja2 import Template
from moneyed import CURRENCIES from moneyed import CURRENCIES
import InvenTree.conversion
def validate_physical_units(unit):
"""Ensure that a given unit is a valid physical unit."""
unit = unit.strip()
# Ignore blank units
if not unit:
return
ureg = InvenTree.conversion.get_unit_registry()
try:
ureg(unit)
except AttributeError:
raise ValidationError(_('Invalid physical unit'))
except pint.errors.UndefinedUnitError:
raise ValidationError(_('Invalid physical unit'))
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."""

View File

@ -11,10 +11,6 @@ def update_tree(apps, schema_editor):
Build.objects.rebuild() Build.objects.rebuild()
def nupdate_tree(apps, schema_editor): # pragma: no cover
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
atomic = False atomic = False
@ -53,5 +49,5 @@ class Migration(migrations.Migration):
field=models.PositiveIntegerField(db_index=True, default=0, editable=False), field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
preserve_default=False, preserve_default=False,
), ),
migrations.RunPython(update_tree, reverse_code=nupdate_tree), migrations.RunPython(update_tree, reverse_code=migrations.RunPython.noop),
] ]

View File

@ -23,13 +23,6 @@ def add_default_reference(apps, schema_editor):
print(f"\nUpdated build reference for {count} existing BuildOrder objects") print(f"\nUpdated build reference for {count} existing BuildOrder objects")
def reverse_default_reference(apps, schema_editor): # pragma: no cover
"""
Do nothing! But we need to have a function here so the whole process is reversible.
"""
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
atomic = False atomic = False
@ -49,7 +42,7 @@ class Migration(migrations.Migration):
# Auto-populate the new reference field for any existing build order objects # Auto-populate the new reference field for any existing build order objects
migrations.RunPython( migrations.RunPython(
add_default_reference, add_default_reference,
reverse_code=reverse_default_reference reverse_code=migrations.RunPython.noop
), ),
# Now that each build has a non-empty, unique reference, update the field requirements! # Now that each build has a non-empty, unique reference, update the field requirements!

View File

@ -51,14 +51,6 @@ def assign_bom_items(apps, schema_editor):
logger.info(f"Assigned BomItem for {count_valid}/{count_total} entries") logger.info(f"Assigned BomItem for {count_valid}/{count_total} entries")
def unassign_bom_items(apps, schema_editor): # pragma: no cover
"""
Reverse migration does not do anything.
Function here to preserve ability to reverse migration
"""
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -66,5 +58,5 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RunPython(assign_bom_items, reverse_code=unassign_bom_items), migrations.RunPython(assign_bom_items, reverse_code=migrations.RunPython.noop),
] ]

View File

@ -31,12 +31,6 @@ def build_refs(apps, schema_editor):
build.reference_int = ref build.reference_int = ref
build.save() build.save()
def unbuild_refs(apps, schema_editor): # pragma: no cover
"""
Provided only for reverse migration compatibility
"""
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -49,6 +43,6 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython( migrations.RunPython(
build_refs, build_refs,
reverse_code=unbuild_refs reverse_code=migrations.RunPython.noop
) )
] ]

View File

@ -50,11 +50,6 @@ def update_build_reference(apps, schema_editor):
print(f"Updated reference field for {n} BuildOrder objects") print(f"Updated reference field for {n} BuildOrder objects")
def nupdate_build_reference(apps, schema_editor):
"""Reverse migration code. Does nothing."""
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -64,6 +59,6 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython( migrations.RunPython(
update_build_reference, update_build_reference,
reverse_code=nupdate_build_reference, reverse_code=migrations.RunPython.noop,
) )
] ]

View File

@ -52,13 +52,6 @@ def build_refs(apps, schema_editor):
order.save() order.save()
def unbuild_refs(apps, schema_editor): # pragma: no cover
"""
Provided only for reverse migration compatibility
"""
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -67,8 +60,5 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython( migrations.RunPython(build_refs, reverse_code=migrations.RunPython.noop)
build_refs,
reverse_code=unbuild_refs
)
] ]

View File

@ -40,14 +40,6 @@ def calculate_shipped_quantity(apps, schema_editor):
item.save() item.save()
def reverse_calculate_shipped_quantity(apps, schema_editor): # pragma: no cover
"""
Provided only for reverse migration compatibility.
This function does nothing.
"""
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -57,6 +49,6 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython( migrations.RunPython(
calculate_shipped_quantity, calculate_shipped_quantity,
reverse_code=reverse_calculate_shipped_quantity reverse_code=migrations.RunPython.noop
) )
] ]

View File

@ -84,11 +84,6 @@ def update_purchaseorder_reference(apps, schema_editor):
print(f"Updated reference field for {n} PurchaseOrder objects") print(f"Updated reference field for {n} PurchaseOrder objects")
def nop(apps, schema_editor):
"""Empty function for reverse migration"""
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -98,10 +93,10 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython( migrations.RunPython(
update_salesorder_reference, update_salesorder_reference,
reverse_code=nop, reverse_code=migrations.RunPython.noop,
), ),
migrations.RunPython( migrations.RunPython(
update_purchaseorder_reference, update_purchaseorder_reference,
reverse_code=nop, reverse_code=migrations.RunPython.noop,
) )
] ]

View File

@ -108,11 +108,6 @@ def update_sales_order_price(apps, schema_editor):
logger.info(f"'total_price' field could not be updated for {invalid_count} SalesOrder instances") logger.info(f"'total_price' field could not be updated for {invalid_count} SalesOrder instances")
def reverse(apps, schema_editor):
"""Reverse migration (does nothing)"""
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -122,10 +117,10 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython( migrations.RunPython(
update_purchase_order_price, update_purchase_order_price,
reverse_code=reverse reverse_code=migrations.RunPython.noop
), ),
migrations.RunPython( migrations.RunPython(
update_sales_order_price, update_sales_order_price,
reverse_code=reverse, reverse_code=migrations.RunPython.noop,
) )
] ]

View File

@ -122,9 +122,9 @@ class PartImportResource(InvenTreeResource):
] ]
class StocktakeInline(admin.TabularInline): class PartParameterInline(admin.TabularInline):
"""Inline for part stocktake data""" """Inline for part parameter data"""
model = models.PartStocktake model = models.PartParameter
class PartAdmin(ImportExportModelAdmin): class PartAdmin(ImportExportModelAdmin):
@ -146,7 +146,7 @@ class PartAdmin(ImportExportModelAdmin):
] ]
inlines = [ inlines = [
StocktakeInline, PartParameterInline,
] ]

View File

@ -1,6 +1,7 @@
"""Provides a JSON API for the Part app.""" """Provides a JSON API for the Part app."""
import functools import functools
import re
from django.db.models import Count, F, Q from django.db.models import Count, F, Q
from django.http import JsonResponse from django.http import JsonResponse
@ -14,6 +15,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.response import Response from rest_framework.response import Response
import order.models import order.models
import part.filters
from build.models import Build, BuildItem from build.models import Build, BuildItem
from InvenTree.api import (APIDownloadMixin, AttachmentMixin, from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
ListCreateDestroyAPIView, MetadataView) ListCreateDestroyAPIView, MetadataView)
@ -1102,7 +1104,6 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
# TODO: Querying bom_valid status may be quite expensive # TODO: Querying bom_valid status may be quite expensive
# TODO: (It needs to be profiled!) # TODO: (It needs to be profiled!)
# TODO: It might be worth caching the bom_valid status to a database column # TODO: It might be worth caching the bom_valid status to a database column
if bom_valid is not None: if bom_valid is not None:
bom_valid = str2bool(bom_valid) bom_valid = str2bool(bom_valid)
@ -1112,9 +1113,9 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
pks = [] pks = []
for part in queryset: for prt in queryset:
if part.is_bom_valid() == bom_valid: if prt.is_bom_valid() == bom_valid:
pks.append(part.pk) pks.append(prt.pk)
queryset = queryset.filter(pk__in=pks) queryset = queryset.filter(pk__in=pks)
@ -1217,6 +1218,34 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
queryset = queryset.filter(pk__in=parts_needed_to_complete_builds) queryset = queryset.filter(pk__in=parts_needed_to_complete_builds)
queryset = self.filter_parameteric_data(queryset)
return queryset
def filter_parameteric_data(self, queryset):
"""Filter queryset against part parameters.
Here we can perfom a number of different functions:
Ordering Based on Parameter Value:
- Used if the 'ordering' query param points to a parameter
- e.g. '&ordering=param_<id>' where <id> specifies the PartParameterTemplate
- Only parts which have a matching parameter are returned
- Queryset is ordered based on parameter value
"""
# Extract "ordering" parameter from query args
ordering = self.request.query_params.get('ordering', None)
if ordering:
# Ordering value must match required regex pattern
result = re.match(r'^\-?parameter_(\d+)$', ordering)
if result:
template_id = result.group(1)
ascending = not ordering.startswith('-')
queryset = part.filters.order_by_parameter(queryset, template_id, ascending)
return queryset return queryset
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER_ALIAS
@ -1320,6 +1349,20 @@ class PartRelatedDetail(RetrieveUpdateDestroyAPI):
serializer_class = part_serializers.PartRelationSerializer serializer_class = part_serializers.PartRelationSerializer
class PartParameterTemplateFilter(rest_filters.FilterSet):
"""FilterSet for PartParameterTemplate objects."""
class Meta:
"""Metaclass options"""
model = PartParameterTemplate
# Simple filter fields
fields = [
'units',
]
class PartParameterTemplateList(ListCreateAPI): class PartParameterTemplateList(ListCreateAPI):
"""API endpoint for accessing a list of PartParameterTemplate objects. """API endpoint for accessing a list of PartParameterTemplate objects.
@ -1329,6 +1372,7 @@ class PartParameterTemplateList(ListCreateAPI):
queryset = PartParameterTemplate.objects.all() queryset = PartParameterTemplate.objects.all()
serializer_class = part_serializers.PartParameterTemplateSerializer serializer_class = part_serializers.PartParameterTemplateSerializer
filterset_class = PartParameterTemplateFilter
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
@ -1338,6 +1382,12 @@ class PartParameterTemplateList(ListCreateAPI):
search_fields = [ search_fields = [
'name', 'name',
'description',
]
ordering_fields = [
'name',
'units',
] ]
def filter_queryset(self, queryset): def filter_queryset(self, queryset):

View File

@ -19,8 +19,9 @@ Relevant PRs:
from decimal import Decimal from decimal import Decimal
from django.db import models from django.db import models
from django.db.models import (DecimalField, ExpressionWrapper, F, FloatField, from django.db.models import (Case, DecimalField, Exists, ExpressionWrapper, F,
Func, IntegerField, OuterRef, Q, Subquery) FloatField, Func, IntegerField, OuterRef, Q,
Subquery, Value, When)
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from sql_util.utils import SubquerySum from sql_util.utils import SubquerySum
@ -210,3 +211,75 @@ def annotate_category_parts():
0, 0,
output_field=IntegerField() output_field=IntegerField()
) )
def filter_by_parameter(queryset, template_id: int, value: str, func: str = ''):
"""Filter the given queryset by a given template parameter
Parts which do not have a value for the given parameter are excluded.
Arguments:
queryset - A queryset of Part objects
template_id - The ID of the template parameter to filter by
value - The value of the parameter to filter by
func - The function to use for the filter (e.g. __gt, __lt, __contains)
Returns:
A queryset of Part objects filtered by the given parameter
"""
# TODO
return queryset
def order_by_parameter(queryset, template_id: int, ascending=True):
"""Order the given queryset by a given template parameter
Parts which do not have a value for the given parameter are ordered last.
Arguments:
queryset - A queryset of Part objects
template_id - The ID of the template parameter to order by
Returns:
A queryset of Part objects ordered by the given parameter
"""
template_filter = part.models.PartParameter.objects.filter(
template__id=template_id,
part_id=OuterRef('id'),
)
# Annotate the queryset with the parameter value, and whether it exists
queryset = queryset.annotate(
parameter_exists=Exists(template_filter)
)
# Annotate the text data value
queryset = queryset.annotate(
parameter_value=Case(
When(
parameter_exists=True,
then=Subquery(template_filter.values('data')[:1], output_field=models.CharField()),
),
default=Value('', output_field=models.CharField()),
),
parameter_value_numeric=Case(
When(
parameter_exists=True,
then=Subquery(template_filter.values('data_numeric')[:1], output_field=models.FloatField()),
),
default=Value(0, output_field=models.FloatField()),
)
)
prefix = '' if ascending else '-'
# Return filtered queryset
return queryset.order_by(
'-parameter_exists',
f'{prefix}parameter_value_numeric',
f'{prefix}parameter_value',
)

View File

@ -10,10 +10,6 @@ def update_tree(apps, schema_editor):
Part.objects.rebuild() Part.objects.rebuild()
def nupdate_tree(apps, schema_editor): # pragma: no cover
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
atomic = False atomic = False
@ -48,5 +44,5 @@ class Migration(migrations.Migration):
preserve_default=False, preserve_default=False,
), ),
migrations.RunPython(update_tree, reverse_code=nupdate_tree) migrations.RunPython(update_tree, reverse_code=migrations.RunPython.noop)
] ]

View File

@ -34,12 +34,6 @@ def update_pathstring(apps, schema_editor):
print(f"\n--- Updated 'pathstring' for {n} PartCategory objects ---\n") print(f"\n--- Updated 'pathstring' for {n} PartCategory objects ---\n")
def nupdate_pathstring(apps, schema_editor):
"""Empty function for reverse migration compatibility"""
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -49,6 +43,6 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython( migrations.RunPython(
update_pathstring, update_pathstring,
reverse_code=nupdate_pathstring reverse_code=migrations.RunPython.noop
) )
] ]

View File

@ -94,11 +94,6 @@ def update_bom_item(apps, schema_editor):
logger.info(f"Updated 'validated' flag for {n} BomItem objects") logger.info(f"Updated 'validated' flag for {n} BomItem objects")
def meti_mob_etadpu(apps, schema_editor):
"""Provided for reverse compatibility"""
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -108,6 +103,6 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython( migrations.RunPython(
update_bom_item, update_bom_item,
reverse_code=meti_mob_etadpu reverse_code=migrations.RunPython.noop
) )
] ]

View File

@ -0,0 +1,30 @@
# Generated by Django 3.2.19 on 2023-05-16 13:34
import InvenTree.validators
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0107_alter_part_tags'),
]
operations = [
migrations.AddField(
model_name='partparameter',
name='data_numeric',
field=models.FloatField(default=None, null=True, blank=True),
),
migrations.AlterField(
model_name='partparameter',
name='data',
field=models.CharField(help_text='Parameter Value', max_length=500, validators=[django.core.validators.MinLengthValidator(1)], verbose_name='Data'),
),
migrations.AlterField(
model_name='partparametertemplate',
name='units',
field=models.CharField(blank=True, help_text='Physical units for this parameter', max_length=25, validators=[InvenTree.validators.validate_physical_units], verbose_name='Units'),
),
]

View File

@ -0,0 +1,147 @@
# Generated by Django 3.2.19 on 2023-05-17 10:48
import pint
from django.core.exceptions import ValidationError
from django.db import migrations
import InvenTree.conversion
def update_template_units(apps, schema_editor):
"""Update the units for each parameter template:
- Check if the units are valid
- Attempt to convert to valid units (if possible)
"""
PartParameterTemplate = apps.get_model('part', 'PartParameterTemplate')
n_templates = PartParameterTemplate.objects.count()
ureg = InvenTree.conversion.get_unit_registry()
n_converted = 0
invalid_units = []
for template in PartParameterTemplate.objects.all():
# Skip empty units
if not template.units:
continue
# Override '%' units (which are invalid)
if template.units == '%':
template.units = 'percent'
template.save()
n_converted += 1
continue
# Test if unit is 'valid'
try:
ureg.Unit(template.units)
continue
except pint.errors.UndefinedUnitError:
pass
# Check a lower-case version
try:
ureg.Unit(template.units.lower())
print(f"Found unit match: {template.units} -> {template.units.lower()}")
template.units = template.units.lower()
template.save()
n_converted += 1
continue
except pint.errors.UndefinedUnitError:
pass
found = False
# Attempt to convert to a valid unit
# Look for capitalization issues (e.g. "Ohm" -> "ohm")
for unit in ureg:
if unit.lower() == template.units.lower():
print(f"Found unit match: {template.units} -> {unit}")
template.units = str(unit)
template.save()
n_converted += 1
found = True
break
if not found:
print(f"warningCould not find unit match for {template.units}")
invalid_units.append(template.units)
print(f"Updated units for {n_templates} parameter templates")
if n_converted > 0:
print(f" - Converted {n_converted} units")
if len(invalid_units) > 0:
print(f" - Found {len(invalid_units)} invalid units:")
for unit in invalid_units:
print(f" - {unit}")
def convert_to_numeric_value(value: str, units: str):
"""Convert a value (with units) to a numeric value.
Defaults to zero if the value cannot be converted.
"""
# Default value is null
result = None
if units:
try:
result = InvenTree.conversion.convert_physical_value(value, units)
result = float(result.magnitude)
except (ValidationError, ValueError):
pass
else:
try:
result = float(value)
except ValueError:
pass
return result
def update_parameter_values(apps, schema_editor):
"""Update the parameter values for all parts:
- Calculate the 'data_numeric' value for each parameter
- If the template has invalid units, we'll ignore
"""
PartParameter = apps.get_model('part', 'PartParameter')
n_params = PartParameter.objects.count()
# Convert each parameter value to a the specified units
for parameter in PartParameter.objects.all():
parameter.data_numeric = convert_to_numeric_value(parameter.data, parameter.template.units)
parameter.save()
if n_params > 0:
print(f"Updated {n_params} parameter values")
class Migration(migrations.Migration):
dependencies = [
('part', '0108_auto_20230516_1334'),
]
operations = [
migrations.RunPython(
update_template_units,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
update_parameter_values,
reverse_code=migrations.RunPython.noop
)
]

View File

@ -12,7 +12,7 @@ from decimal import Decimal, InvalidOperation
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinLengthValidator, MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, Q, Sum, UniqueConstraint from django.db.models import ExpressionWrapper, F, Q, Sum, UniqueConstraint
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
@ -35,6 +35,7 @@ from taggit.managers import TaggableManager
import common.models import common.models
import common.settings import common.settings
import InvenTree.conversion
import InvenTree.fields import InvenTree.fields
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
@ -982,7 +983,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
max_length=20, default="", max_length=20, default="",
blank=True, null=True, blank=True, null=True,
verbose_name=_('Units'), verbose_name=_('Units'),
help_text=_('Units of measure for this part') help_text=_('Units of measure for this part'),
) )
assembly = models.BooleanField( assembly = models.BooleanField(
@ -3295,6 +3296,7 @@ class PartParameterTemplate(MetadataMixin, models.Model):
Attributes: Attributes:
name: The name (key) of the Parameter [string] name: The name (key) of the Parameter [string]
units: The units of the Parameter [string] units: The units of the Parameter [string]
description: Description of the parameter [string]
""" """
@staticmethod @staticmethod
@ -3332,7 +3334,14 @@ class PartParameterTemplate(MetadataMixin, models.Model):
unique=True unique=True
) )
units = models.CharField(max_length=25, verbose_name=_('Units'), help_text=_('Parameter Units'), blank=True) units = models.CharField(
max_length=25,
verbose_name=_('Units'), help_text=_('Physical units for this parameter'),
blank=True,
validators=[
validators.validate_physical_units,
]
)
description = models.CharField( description = models.CharField(
max_length=250, max_length=250,
@ -3342,6 +3351,23 @@ class PartParameterTemplate(MetadataMixin, models.Model):
) )
@receiver(post_save, sender=PartParameterTemplate, dispatch_uid='post_save_part_parameter_template')
def post_save_part_parameter_template(sender, instance, created, **kwargs):
"""Callback function when a PartParameterTemplate is created or saved"""
import part.tasks as part_tasks
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
# Schedule a background task to rebuild the parameters against this template
if not created:
InvenTree.tasks.offload_task(
part_tasks.rebuild_parameters,
instance.pk,
force_async=True
)
class PartParameter(models.Model): class PartParameter(models.Model):
"""A PartParameter is a specific instance of a PartParameterTemplate. It assigns a particular parameter <key:value> pair to a part. """A PartParameter is a specific instance of a PartParameterTemplate. It assigns a particular parameter <key:value> pair to a part.
@ -3363,18 +3389,79 @@ class PartParameter(models.Model):
def __str__(self): def __str__(self):
"""String representation of a PartParameter (used in the admin interface)""" """String representation of a PartParameter (used in the admin interface)"""
return "{part} : {param} = {data}{units}".format( return "{part} : {param} = {data} ({units})".format(
part=str(self.part.full_name), part=str(self.part.full_name),
param=str(self.template.name), param=str(self.template.name),
data=str(self.data), data=str(self.data),
units=str(self.template.units) units=str(self.template.units)
) )
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='parameters', verbose_name=_('Part'), help_text=_('Parent Part')) def save(self, *args, **kwargs):
"""Custom save method for the PartParameter model."""
template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE, related_name='instances', verbose_name=_('Template'), help_text=_('Parameter Template')) # Validate the PartParameter before saving
self.calculate_numeric_value()
data = models.CharField(max_length=500, verbose_name=_('Data'), help_text=_('Parameter Value')) super().save(*args, **kwargs)
def clean(self):
"""Validate the PartParameter before saving to the database."""
super().clean()
# Validate the parameter data against the template units
if self.template.units:
try:
InvenTree.conversion.convert_physical_value(self.data, self.template.units)
except ValidationError as e:
raise ValidationError({
'data': e.message
})
def calculate_numeric_value(self):
"""Calculate a numeric value for the parameter data.
- If a 'units' field is provided, then the data will be converted to the base SI unit.
- Otherwise, we'll try to do a simple float cast
"""
if self.template.units:
try:
converted = InvenTree.conversion.convert_physical_value(self.data, self.template.units)
self.data_numeric = float(converted.magnitude)
except (ValidationError, ValueError):
self.data_numeric = None
# No units provided, so try to cast to a float
else:
try:
self.data_numeric = float(self.data)
except ValueError:
self.data_numeric = None
part = models.ForeignKey(
Part, on_delete=models.CASCADE, related_name='parameters',
verbose_name=_('Part'), help_text=_('Parent Part')
)
template = models.ForeignKey(
PartParameterTemplate, on_delete=models.CASCADE, related_name='instances',
verbose_name=_('Template'), help_text=_('Parameter Template')
)
data = models.CharField(
max_length=500,
verbose_name=_('Data'), help_text=_('Parameter Value'),
validators=[
MinLengthValidator(1),
]
)
data_numeric = models.FloatField(
default=None,
null=True,
blank=True,
)
@classmethod @classmethod
def create(cls, part, template, data, save=False): def create(cls, part, template, data, save=False):

View File

@ -240,7 +240,8 @@ class PartParameterSerializer(InvenTreeModelSerializer):
'part', 'part',
'template', 'template',
'template_detail', 'template_detail',
'data' 'data',
'data_numeric',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -428,3 +428,31 @@ def scheduled_stocktake_reports():
# Record the date of this report # Record the date of this report
common.models.InvenTreeSetting.set_setting('STOCKTAKE_RECENT_REPORT', datetime.now().isoformat(), None) common.models.InvenTreeSetting.set_setting('STOCKTAKE_RECENT_REPORT', datetime.now().isoformat(), None)
def rebuild_parameters(template_id):
"""Rebuild all parameters for a given template.
This method is called when a base template is changed,
which may cause the base unit to be adjusted.
"""
try:
template = part.models.PartParameterTemplate.objects.get(pk=template_id)
except part.models.PartParameterTemplate.DoesNotExist:
return
parameters = part.models.PartParameter.objects.filter(template=template)
n = 0
for parameter in parameters:
# Update the parameter if the numeric value has changed
value_old = parameter.data_numeric
parameter.calculate_numeric_value()
if value_old != parameter.data_numeric:
parameter.save()
n += 1
logger.info(f"Rebuilt {n} parameters for template '{template.name}'")

View File

@ -284,15 +284,6 @@
); );
}); });
onPanelLoad('parameters', function() {
loadParametricPartTable(
"#parametric-part-table",
{
category: {{ category.pk }},
}
);
});
$("#toggle-starred").click(function() { $("#toggle-starred").click(function() {
toggleStar({ toggleStar({
url: '{% url "api-part-category-detail" category.pk %}', url: '{% url "api-part-category-detail" category.pk %}',
@ -302,6 +293,17 @@
{% endif %} {% endif %}
onPanelLoad('parameters', function() {
loadParametricPartTable(
"#parametric-part-table",
{
{% if category %}
category: {{ category.pk }},
{% endif %}
}
);
});
// Enable breadcrumb tree view // Enable breadcrumb tree view
enableBreadcrumbTree({ enableBreadcrumbTree({
label: 'category', label: 'category',

View File

@ -16,6 +16,6 @@
{% if category %} {% if category %}
{% trans "Stock Items" as text %} {% trans "Stock Items" as text %}
{% include "sidebar_item.html" with label='stock' text=text icon='fa-boxes' %} {% include "sidebar_item.html" with label='stock' text=text icon='fa-boxes' %}
{% endif %}
{% trans "Parameters" as text %} {% trans "Parameters" as text %}
{% include "sidebar_item.html" with label="parameters" text=text icon="fa-tasks" %} {% include "sidebar_item.html" with label="parameters" text=text icon="fa-tasks" %}
{% endif %}

View File

@ -2699,91 +2699,6 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(response.data['available_variant_stock'], 1000) self.assertEqual(response.data['available_variant_stock'], 1000)
class PartParameterTest(InvenTreeAPITestCase):
"""Tests for the ParParameter API."""
superuser = True
fixtures = [
'category',
'part',
'location',
'params',
]
def test_list_params(self):
"""Test for listing part parameters."""
url = reverse('api-part-parameter-list')
response = self.get(url)
self.assertEqual(len(response.data), 7)
# Filter by part
response = self.get(
url,
{
'part': 3,
}
)
self.assertEqual(len(response.data), 3)
# Filter by template
response = self.get(
url,
{
'template': 1,
}
)
self.assertEqual(len(response.data), 4)
def test_create_param(self):
"""Test that we can create a param via the API."""
url = reverse('api-part-parameter-list')
response = self.post(
url,
{
'part': '2',
'template': '3',
'data': 70
}
)
self.assertEqual(response.status_code, 201)
response = self.get(url)
self.assertEqual(len(response.data), 8)
def test_param_detail(self):
"""Tests for the PartParameter detail endpoint."""
url = reverse('api-part-parameter-detail', kwargs={'pk': 5})
response = self.get(url)
self.assertEqual(response.status_code, 200)
data = response.data
self.assertEqual(data['pk'], 5)
self.assertEqual(data['part'], 3)
self.assertEqual(data['data'], '12')
# PATCH data back in
response = self.patch(url, {'data': '15'})
self.assertEqual(response.status_code, 200)
# Check that the data changed!
response = self.get(url)
data = response.data
self.assertEqual(data['data'], '15')
class PartAttachmentTest(InvenTreeAPITestCase): class PartAttachmentTest(InvenTreeAPITestCase):
"""Unit tests for the PartAttachment API endpoint""" """Unit tests for the PartAttachment API endpoint"""

View File

@ -82,3 +82,75 @@ class TestBomItemMigrations(MigratorTestCase):
for bom_item in BomItem.objects.all(): for bom_item in BomItem.objects.all():
self.assertFalse(bom_item.validated) self.assertFalse(bom_item.validated)
class TestParameterMigrations(MigratorTestCase):
"""Unit test for part parameter migrations"""
migrate_from = ('part', '0106_part_tags')
migrate_to = ('part', '0109_auto_20230517_1048')
def prepare(self):
"""Create some parts, and templates with parameters"""
Part = self.old_state.apps.get_model('part', 'part')
PartParameter = self.old_state.apps.get_model('part', 'partparameter')
PartParameterTemlate = self.old_state.apps.get_model('part', 'partparametertemplate')
# Create some parts
a = Part.objects.create(
name='Part A', description='My part A',
level=0, lft=0, rght=0, tree_id=0,
)
b = Part.objects.create(
name='Part B', description='My part B',
level=0, lft=0, rght=0, tree_id=0,
)
# Create some templates
t1 = PartParameterTemlate.objects.create(name='Template 1', units='mm')
t2 = PartParameterTemlate.objects.create(name='Template 2', units='AMPERE')
# Create some parameter values
PartParameter.objects.create(part=a, template=t1, data='1.0')
PartParameter.objects.create(part=a, template=t2, data='-2mA',)
PartParameter.objects.create(part=b, template=t1, data='1/10 inch')
PartParameter.objects.create(part=b, template=t2, data='abc')
def test_data_migration(self):
"""Test that the template units and values have been updated correctly"""
Part = self.new_state.apps.get_model('part', 'part')
PartParameter = self.new_state.apps.get_model('part', 'partparameter')
PartParameterTemlate = self.new_state.apps.get_model('part', 'partparametertemplate')
# Extract the parts
a = Part.objects.get(name='Part A')
b = Part.objects.get(name='Part B')
# Check that the templates have been updated correctly
t1 = PartParameterTemlate.objects.get(name='Template 1')
self.assertEqual(t1.units, 'mm')
t2 = PartParameterTemlate.objects.get(name='Template 2')
self.assertEqual(t2.units, 'ampere')
# Check that the parameter values have been updated correctly
p1 = PartParameter.objects.get(part=a, template=t1)
self.assertEqual(p1.data, '1.0')
self.assertEqual(p1.data_numeric, 1.0)
p2 = PartParameter.objects.get(part=a, template=t2)
self.assertEqual(p2.data, '-2mA')
self.assertEqual(p2.data_numeric, -0.002)
p3 = PartParameter.objects.get(part=b, template=t1)
self.assertEqual(p3.data, '1/10 inch')
self.assertEqual(p3.data_numeric, 2.54)
# This one has not converted correctly
p4 = PartParameter.objects.get(part=b, template=t2)
self.assertEqual(p4.data, 'abc')
self.assertEqual(p4.data_numeric, None)

View File

@ -2,8 +2,11 @@
import django.core.exceptions as django_exceptions import django.core.exceptions as django_exceptions
from django.test import TestCase, TransactionTestCase from django.test import TestCase, TransactionTestCase
from django.urls import reverse
from .models import (PartCategory, PartCategoryParameterTemplate, from InvenTree.unit_test import InvenTreeAPITestCase
from .models import (Part, PartCategory, PartCategoryParameterTemplate,
PartParameter, PartParameterTemplate) PartParameter, PartParameterTemplate)
@ -23,7 +26,7 @@ class TestParams(TestCase):
self.assertEqual(str(t1), 'Length (mm)') self.assertEqual(str(t1), 'Length (mm)')
p1 = PartParameter.objects.get(pk=1) p1 = PartParameter.objects.get(pk=1)
self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm') self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4 (mm)')
c1 = PartCategoryParameterTemplate.objects.get(pk=1) c1 = PartCategoryParameterTemplate.objects.get(pk=1)
self.assertEqual(str(c1), 'Mechanical | Length | 2.8') self.assertEqual(str(c1), 'Mechanical | Length | 2.8')
@ -87,3 +90,252 @@ class TestCategoryTemplates(TransactionTestCase):
n = PartCategoryParameterTemplate.objects.all().count() n = PartCategoryParameterTemplate.objects.all().count()
self.assertEqual(n, 3) self.assertEqual(n, 3)
class ParameterTests(TestCase):
"""Unit tests for parameter validation"""
fixtures = [
'location',
'category',
'part',
'params'
]
def test_unit_validation(self):
"""Test validation of 'units' field for PartParameterTemplate"""
# Test that valid units pass
for unit in [None, '', 'mm', 'A', 'm^2', 'Pa', 'V', 'C', 'F', 'uF', 'mF', 'millifarad']:
tmp = PartParameterTemplate(name='test', units=unit)
tmp.full_clean()
# Test that invalid units fail
for unit in ['mmmmm', '-', 'x', int]:
tmp = PartParameterTemplate(name='test', units=unit)
with self.assertRaises(django_exceptions.ValidationError):
tmp.full_clean()
def test_param_validation(self):
"""Test that parameters are correctly validated against template units"""
template = PartParameterTemplate.objects.create(
name='My Template',
units='m',
)
prt = Part.objects.get(pk=1)
# Test that valid parameters pass
for value in ['1', '1m', 'm', '-4m', -2, '2.032mm', '99km', '-12 mile', 'foot', '3 yards']:
param = PartParameter(part=prt, template=template, data=value)
param.full_clean()
# Test that invalid parameters fail
for value in ['3 Amps', '-3 zogs', '3.14F']:
param = PartParameter(part=prt, template=template, data=value)
with self.assertRaises(django_exceptions.ValidationError):
param.full_clean()
def test_param_conversion(self):
"""Test that parameters are correctly converted to template units"""
template = PartParameterTemplate.objects.create(
name='My Template',
units='m',
)
tests = {
'1': 1.0,
'-1': -1.0,
'23m': 23.0,
'-89mm': -0.089,
'100 foot': 30.48,
'-17 yards': -15.54,
}
prt = Part.objects.get(pk=1)
param = PartParameter(part=prt, template=template, data='1')
for value, expected in tests.items():
param.data = value
param.calculate_numeric_value()
self.assertAlmostEqual(param.data_numeric, expected, places=2)
class PartParameterTest(InvenTreeAPITestCase):
"""Tests for the ParParameter API."""
superuser = True
fixtures = [
'category',
'part',
'location',
'params',
]
def test_list_params(self):
"""Test for listing part parameters."""
url = reverse('api-part-parameter-list')
response = self.get(url)
self.assertEqual(len(response.data), 7)
# Filter by part
response = self.get(
url,
{
'part': 3,
}
)
self.assertEqual(len(response.data), 3)
# Filter by template
response = self.get(
url,
{
'template': 1,
}
)
self.assertEqual(len(response.data), 4)
def test_create_param(self):
"""Test that we can create a param via the API."""
url = reverse('api-part-parameter-list')
response = self.post(
url,
{
'part': '2',
'template': '3',
'data': 70
}
)
self.assertEqual(response.status_code, 201)
response = self.get(url)
self.assertEqual(len(response.data), 8)
def test_param_detail(self):
"""Tests for the PartParameter detail endpoint."""
url = reverse('api-part-parameter-detail', kwargs={'pk': 5})
response = self.get(url)
self.assertEqual(response.status_code, 200)
data = response.data
self.assertEqual(data['pk'], 5)
self.assertEqual(data['part'], 3)
self.assertEqual(data['data'], '12')
# PATCH data back in
response = self.patch(url, {'data': '15'})
self.assertEqual(response.status_code, 200)
# Check that the data changed!
response = self.get(url)
data = response.data
self.assertEqual(data['data'], '15')
def test_order_parts_by_param(self):
"""Test that we can order parts by a specified parameter."""
def get_param_value(response, template, index):
"""Helper function to extract a parameter value from a response"""
params = response.data[index]['parameters']
for param in params:
if param['template'] == template:
return param['data']
# No match
return None
# Create a new parameter template
template = PartParameterTemplate.objects.create(
name='Test Template',
description='My test template',
units='m'
)
# Create parameters for each existing part
params = []
parts = Part.objects.all().order_by('pk')
for idx, part in enumerate(parts):
# Skip parts every now and then
if idx % 10 == 7:
continue
suffix = 'mm' if idx % 3 == 0 else 'm'
params.append(
PartParameter.objects.create(
part=part,
template=template,
data=f'{idx}{suffix}'
)
)
# Now, request parts, ordered by this parameter
url = reverse('api-part-list')
response = self.get(
url,
{
'ordering': 'parameter_{pk}'.format(pk=template.pk),
'parameters': 'true',
},
expected_code=200
)
# All parts should be returned
self.assertEqual(len(response.data), len(parts))
# Check that the parts are ordered correctly (in increasing order)
expectation = {
0: '0mm',
1: '3mm',
7: '4m',
9: '8m',
-2: '13m',
-1: None,
}
for idx, expected in expectation.items():
actual = get_param_value(response, template.pk, idx)
self.assertEqual(actual, expected)
# Next, check reverse ordering
response = self.get(
url,
{
'ordering': '-parameter_{pk}'.format(pk=template.pk),
'parameters': 'true',
},
expected_code=200
)
expectation = {
0: '13m',
1: '11m',
-3: '3mm',
-2: '0mm',
-1: None,
}
for idx, expected in expectation.items():
actual = get_param_value(response, template.pk, idx)
self.assertEqual(actual, expected)

View File

@ -314,7 +314,7 @@ class PluginsRegistry:
handle_error(error, do_raise=False, log_name='discovery') handle_error(error, do_raise=False, log_name='discovery')
# Log collected plugins # Log collected plugins
logger.info(f'Collected {len(collected_plugins)} plugins!') logger.info(f'Collected {len(collected_plugins)} plugins')
logger.debug(", ".join([a.__module__ for a in collected_plugins])) logger.debug(", ".join([a.__module__ for a in collected_plugins]))
return collected_plugins return collected_plugins

View File

@ -203,12 +203,6 @@ def update_history(apps, schema_editor):
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries") # pragma: no cover print(f"\n==========================\nUpdated {update_count} StockItemHistory entries") # pragma: no cover
def reverse_update(apps, schema_editor):
"""
"""
pass # pragma: no cover
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -216,5 +210,5 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RunPython(update_history, reverse_code=reverse_update) migrations.RunPython(update_history, reverse_code=migrations.RunPython.noop)
] ]

View File

@ -60,12 +60,6 @@ def extract_purchase_price(apps, schema_editor):
if update_count > 0: # pragma: no cover if update_count > 0: # pragma: no cover
print(f"Updated pricing for {update_count} stock items") print(f"Updated pricing for {update_count} stock items")
def reverse_operation(apps, schema_editor): # pragma: no cover
"""
DO NOTHING!
"""
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -74,5 +68,5 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RunPython(extract_purchase_price, reverse_code=reverse_operation) migrations.RunPython(extract_purchase_price, reverse_code=migrations.RunPython.noop)
] ]

View File

@ -36,13 +36,6 @@ def update_serials(apps, schema_editor):
item.save() item.save()
def nupdate_serials(apps, schema_editor): # pragma: no cover
"""
Provided only for reverse migration compatibility
"""
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -52,6 +45,6 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython( migrations.RunPython(
update_serials, update_serials,
reverse_code=nupdate_serials, reverse_code=migrations.RunPython.noop,
) )
] ]

View File

@ -35,10 +35,6 @@ def delete_scheduled(apps, schema_editor):
Task.objects.filter(func='stock.tasks.delete_old_stock_items').delete() Task.objects.filter(func='stock.tasks.delete_old_stock_items').delete()
def reverse(apps, schema_editor): # pragma: no cover
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -48,6 +44,6 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython( migrations.RunPython(
delete_scheduled, delete_scheduled,
reverse_code=reverse, reverse_code=migrations.RunPython.noop,
) )
] ]

View File

@ -34,13 +34,6 @@ def update_pathstring(apps, schema_editor):
print(f"\n--- Updated 'pathstring' for {n} StockLocation objects ---\n") print(f"\n--- Updated 'pathstring' for {n} StockLocation objects ---\n")
def nupdate_pathstring(apps, schema_editor):
"""Empty function for reverse migration compatibility"""
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -50,6 +43,6 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython( migrations.RunPython(
update_pathstring, update_pathstring,
reverse_code=nupdate_pathstring reverse_code=migrations.RunPython.noop
) )
] ]

View File

@ -62,9 +62,6 @@ def fix_purchase_price(apps, schema_editor):
logger.info(f"Corrected purchase_price field for {n_updated} stock items.") logger.info(f"Corrected purchase_price field for {n_updated} stock items.")
def reverse(apps, schema_editor): # pragmae: no cover
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -74,6 +71,6 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython( migrations.RunPython(
fix_purchase_price, fix_purchase_price,
reverse_code=reverse, reverse_code=migrations.RunPython.noop,
) )
] ]

View File

@ -52,11 +52,6 @@ def update_stock_history(apps, schema_editor):
print(f"Updated {n} StockItemTracking entries with SalesOrder data") print(f"Updated {n} StockItemTracking entries with SalesOrder data")
def nope(apps, schema_editor):
"""Provided for reverse migration compatibility"""
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -65,6 +60,6 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython( migrations.RunPython(
update_stock_history, reverse_code=nope, update_stock_history, reverse_code=migrations.RunPython.noop,
) )
] ]

View File

@ -55,19 +55,4 @@
</tbody> </tbody>
</table> </table>
<div class='panel-heading'>
<span class='d-flex flex-span'>
<h4>{% trans "Part Parameter Templates" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<button class='btn btn-success' id='new-param'>
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
</button>
</div>
</span>
</div>
<table class='table table-striped table-condensed' id='param-table' data-toolbar='#param-buttons'>
</table>
{% endblock content %} {% endblock content %}

View File

@ -0,0 +1,25 @@
{% extends "panel.html" %}
{% load i18n %}
{% block label %}part-parameters{% endblock label %}
{% block heading %}
{% trans "Part Parameter Templates" %}
{% endblock heading %}
{% block actions %}
<button class='btn btn-success' id='new-param'>
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
</button>
{% endblock actions %}
{% block content %}
<div id='param-buttons'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="parameter-templates" %}
</div>
</div>
<table class='table table-striped table-condensed' id='param-table' data-toolbar='#param-buttons'>
</table>
{% endblock content %}

View File

@ -36,6 +36,7 @@
{% include "InvenTree/settings/label.html" %} {% include "InvenTree/settings/label.html" %}
{% include "InvenTree/settings/report.html" %} {% include "InvenTree/settings/report.html" %}
{% include "InvenTree/settings/part.html" %} {% include "InvenTree/settings/part.html" %}
{% include "InvenTree/settings/part_parameters.html" %}
{% include "InvenTree/settings/part_stocktake.html" %} {% include "InvenTree/settings/part_stocktake.html" %}
{% include "InvenTree/settings/category.html" %} {% include "InvenTree/settings/category.html" %}
{% include "InvenTree/settings/pricing.html" %} {% include "InvenTree/settings/pricing.html" %}

View File

@ -302,50 +302,10 @@ onPanelLoad('category', function() {
}); });
}); });
// Javascript for the Part settings panel // Javascript for the Part parameters settings panel
onPanelLoad('parts', function() { onPanelLoad('part-parameters', function() {
$("#param-table").inventreeTable({
url: "{% url 'api-part-parameter-template-list' %}",
queryParams: {
ordering: 'name',
},
formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; },
columns: [
{
field: 'pk',
title: '{% trans "ID" %}',
visible: false,
switchable: false,
},
{
field: 'name',
title: '{% trans "Name" %}',
sortable: true,
},
{
field: 'units',
title: '{% trans "Units" %}',
sortable: true,
switchable: true,
},
{
field: 'description',
title: '{% trans "Description" %}',
sortable: false,
switchable: true,
},
{
formatter: function(value, row, index, field) {
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit icon-green'></span></button>";
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
var html = "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>"; loadPartParameterTemplateTable("#param-table", {});
return html;
}
}
]
});
$("#new-param").click(function() { $("#new-param").click(function() {
constructForm('{% url "api-part-parameter-template-list" %}', { constructForm('{% url "api-part-parameter-template-list" %}', {
@ -359,45 +319,10 @@ onPanelLoad('parts', function() {
refreshTable: '#param-table', refreshTable: '#param-table',
}); });
}); });
});
$("#param-table").on('click', '.template-edit', function() { // Javascript for the Part settings panel
var button = $(this); onPanelLoad('parts', function() {
var pk = button.attr('pk');
constructForm(
`/api/part/parameter/template/${pk}/`,
{
fields: {
name: {},
units: {},
description: {},
},
title: '{% trans "Edit Part Parameter Template" %}',
refreshTable: '#param-table',
}
);
});
$("#param-table").on('click', '.template-delete', function() {
var button = $(this);
var pk = button.attr('pk');
var html = `
<div class='alert alert-block alert-danger'>
{% trans "Any parameters which reference this template will also be deleted" %}
</div>`;
constructForm(
`/api/part/parameter/template/${pk}/`,
{
method: 'DELETE',
preFormContent: html,
title: '{% trans "Delete Part Parameter Template" %}',
refreshTable: '#param-table',
}
);
});
$("#import-part").click(function() { $("#import-part").click(function() {
launchModalForm("{% url 'api-part-import' %}?reset", {}); launchModalForm("{% url 'api-part-import' %}?reset", {});
}); });

View File

@ -44,6 +44,8 @@
{% include "sidebar_item.html" with label='category' text=text icon="fa-sitemap" %} {% include "sidebar_item.html" with label='category' text=text icon="fa-sitemap" %}
{% trans "Parts" as text %} {% trans "Parts" as text %}
{% include "sidebar_item.html" with label='parts' text=text icon="fa-shapes" %} {% include "sidebar_item.html" with label='parts' text=text icon="fa-shapes" %}
{% trans "Part Parameters" as text %}
{% include "sidebar_item.html" with label='part-parameters' text=text icon="fa-th-list" %}
{% trans "Stock" as text %} {% trans "Stock" as text %}
{% include "sidebar_item.html" with label='stock' text=text icon="fa-boxes" %} {% include "sidebar_item.html" with label='stock' text=text icon="fa-boxes" %}
{% trans "Stocktake" as text %} {% trans "Stocktake" as text %}

View File

@ -31,6 +31,7 @@
loadParametricPartTable, loadParametricPartTable,
loadPartCategoryTable, loadPartCategoryTable,
loadPartParameterTable, loadPartParameterTable,
loadPartParameterTemplateTable,
loadPartPurchaseOrderTable, loadPartPurchaseOrderTable,
loadPartTable, loadPartTable,
loadPartTestTemplateTable, loadPartTestTemplateTable,
@ -1286,11 +1287,27 @@ function loadPartParameterTable(table, options) {
return row.template_detail.name; return row.template_detail.name;
} }
}, },
{
field: 'description',
title: '{% trans "Description" %}',
switchable: true,
sortable: false,
formatter: function(value, row) {
return row.template_detail.description;
}
},
{ {
field: 'data', field: 'data',
title: '{% trans "Value" %}', title: '{% trans "Value" %}',
switchable: false, switchable: false,
sortable: true, sortable: true,
formatter: function(value, row) {
if (row.data_numeric && row.template_detail.units) {
return `<span title='${row.data_numeric} ${row.template_detail.units}'>${row.data}</span>`;
} else {
return row.data;
}
}
}, },
{ {
field: 'units', field: 'units',
@ -1345,6 +1362,107 @@ function loadPartParameterTable(table, options) {
} }
/*
* Construct a table showing a list of part parameter templates
*/
function loadPartParameterTemplateTable(table, options={}) {
let params = options.params || {};
params.ordering = 'name';
let filters = loadTableFilters('part-parameter-templates', params);
let filterTarget = options.filterTarget || '#filter-list-parameter-templates';
setupFilterList('part-parameter-templates', $(table), filterTarget);
$(table).inventreeTable({
url: '{% url "api-part-parameter-template-list" %}',
original: params,
queryParams: filters,
name: 'part-parameter-templates',
formatNoMatches: function() {
return '{% trans "No part parameter templates found" %}';
},
columns: [
{
field: 'pk',
title: '{% trans "ID" %}',
visible: false,
switchable: false,
},
{
field: 'name',
title: '{% trans "Name" %}',
sortable: true,
},
{
field: 'units',
title: '{% trans "Units" %}',
sortable: true,
switchable: true,
},
{
field: 'description',
title: '{% trans "Description" %}',
sortable: false,
switchable: true,
},
{
formatter: function(value, row, index, field) {
let buttons = '';
buttons += makeEditButton('template-edit', row.pk, '{% trans "Edit Template" %}');
buttons += makeDeleteButton('template-delete', row.pk, '{% trans "Delete Template" %}');
return wrapButtons(buttons);
}
}
]
});
$(table).on('click', '.template-edit', function() {
var button = $(this);
var pk = button.attr('pk');
constructForm(
`/api/part/parameter/template/${pk}/`,
{
fields: {
name: {},
units: {},
description: {},
},
title: '{% trans "Edit Part Parameter Template" %}',
refreshTable: table,
}
);
});
$(table).on('click', '.template-delete', function() {
var button = $(this);
var pk = button.attr('pk');
var html = `
<div class='alert alert-block alert-danger'>
{% trans "Any parameters which reference this template will also be deleted" %}
</div>`;
constructForm(
`/api/part/parameter/template/${pk}/`,
{
method: 'DELETE',
preFormContent: html,
title: '{% trans "Delete Part Parameter Template" %}',
refreshTable: table,
}
);
});
}
/* /*
* Construct a table showing a list of purchase orders for a given part. * Construct a table showing a list of purchase orders for a given part.
* *
@ -1663,6 +1781,12 @@ function loadRelatedPartsTable(table, part_id, options={}) {
*/ */
function loadParametricPartTable(table, options={}) { function loadParametricPartTable(table, options={}) {
options.params = options.params || {};
options.params['parameters'] = true;
let filters = loadTableFilters('parameters', options.params);
setupFilterList('parameters', $(table), '#filter-list-parameters'); setupFilterList('parameters', $(table), '#filter-list-parameters');
var columns = [ var columns = [
@ -1691,11 +1815,18 @@ function loadParametricPartTable(table, options={}) {
async: false, async: false,
success: function(response) { success: function(response) {
for (var template of response) { for (var template of response) {
let template_name = template.name;
if (template.units) {
template_name += ` [${template.units}]`;
}
columns.push({ columns.push({
field: `parameter_${template.pk}`, field: `parameter_${template.pk}`,
title: template.name, title: template_name,
switchable: true, switchable: true,
sortable: false, sortable: true,
filterControl: 'input', filterControl: 'input',
}); });
} }
@ -1703,20 +1834,21 @@ function loadParametricPartTable(table, options={}) {
} }
); );
// TODO: Re-enable filter control for parameter values
$(table).inventreeTable({ $(table).inventreeTable({
url: '{% url "api-part-list" %}', url: '{% url "api-part-list" %}',
queryParams: { queryParams: filters,
category: options.category, original: options.params,
cascade: true,
parameters: true,
},
groupBy: false, groupBy: false,
name: options.name || 'part-parameters', name: options.name || 'part-parameters',
formatNoMatches: function() { formatNoMatches: function() {
return '{% trans "No parts found" %}'; return '{% trans "No parts found" %}';
}, },
// TODO: Re-enable filter control for parameter values
// Ref: https://github.com/inventree/InvenTree/issues/4851
// filterControl: true,
// showFilterControlSwitch: true,
// sortSelectOptions: true,
columns: columns, columns: columns,
showColumns: true, showColumns: true,
sidePagination: 'server', sidePagination: 'server',
@ -1751,8 +1883,8 @@ function loadParametricPartTable(table, options={}) {
} }
// Generate a "grid tile" view for a particular part
function partGridTile(part) { function partGridTile(part) {
// Generate a "grid tile" view for a particular part
// Rows for table view // Rows for table view
var rows = ''; var rows = '';
@ -1822,6 +1954,8 @@ function partGridTile(part) {
*/ */
function loadPartTable(table, url, options={}) { function loadPartTable(table, url, options={}) {
options.params = options.params || {};
// Ensure category detail is included // Ensure category detail is included
options.params['category_detail'] = true; options.params['category_detail'] = true;

View File

@ -55,7 +55,6 @@ function renderStatusLabel(key, codes, options={}) {
return `<span class='${classes}'>${text}</span>`; return `<span class='${classes}'>${text}</span>`;
} }
{% include "status_codes.html" with label='stock' data=StockStatus.list %} {% include "status_codes.html" with label='stock' data=StockStatus.list %}
{% include "status_codes.html" with label='stockHistory' data=StockHistoryCode.list %} {% include "status_codes.html" with label='stockHistory' data=StockHistoryCode.list %}
{% include "status_codes.html" with label='build' data=BuildStatus.list %} {% include "status_codes.html" with label='build' data=BuildStatus.list %}

View File

@ -700,6 +700,19 @@ function getCompanyFilters() {
} }
// Return a dictionary of filters for the "part parameter template" table
function getPartParameterTemplateFilters() {
return {};
}
// Return a dictionary of filters for the "parameteric part" table
function getParametricPartTableFilters() {
let filters = getPartTableFilters();
return filters;
}
// Return a dictionary of filters for a given table, based on the name of the table // Return a dictionary of filters for a given table, based on the name of the table
function getAvailableTableFilters(tableKey) { function getAvailableTableFilters(tableKey) {
@ -723,6 +736,10 @@ function getAvailableTableFilters(tableKey) {
return getBuildItemTableFilters(); return getBuildItemTableFilters();
case 'location': case 'location':
return getStockLocationFilters(); return getStockLocationFilters();
case 'parameters':
return getParametricPartTableFilters();
case 'part-parameter-templates':
return getPartParameterTemplateFilters();
case 'parts': case 'parts':
return getPartTableFilters(); return getPartTableFilters();
case 'parttests': case 'parttests':

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@ -44,6 +44,7 @@ InvenTree relies on the following Python libraries:
| [coveralls](https://pypi.org/project/coveralls/) | MIT | coverage uploader | | [coveralls](https://pypi.org/project/coveralls/) | MIT | coverage uploader |
| [django-formtools](https://pypi.org/project/django-formtools/) | MIT | better forms / wizards | | [django-formtools](https://pypi.org/project/django-formtools/) | MIT | better forms / wizards |
| [django-allauth](https://pypi.org/project/django-allauth/) | MIT | SSO for django | | [django-allauth](https://pypi.org/project/django-allauth/) | MIT | SSO for django |
| [pint](https://pint.readthedocs.io/en/stable/) | [licence](https://github.com/hgrecco/pint/blob/master/LICENSE) | Physical unit conversion |
## Frontend libraries ## Frontend libraries

View File

@ -4,26 +4,30 @@ title: Part Parameters
## Part Parameters ## Part Parameters
A part *parameter* describes a particular "attribute" or "property" of a specific part.
Part parameters are located in the "Parameters" tab, on each part detail page. Part parameters are located in the "Parameters" tab, on each part detail page.
There is no limit for the number of part parameters and they are fully customizable through the use of parameters templates. There is no limit for the number of part parameters and they are fully customizable through the use of [parameters templates](#parameter-templates).
Here is an example of parameters for a capacitor: Here is an example of parameters for a capacitor:
{% with id="part_parameters_example", url="part/part_parameters_example.png", description="Part Parameters Example List" %} {% with id="part_parameters_example", url="part/part_parameters_example.png", description="Part Parameters Example List" %}
{% include 'img.html' %} {% include 'img.html' %}
{% endwith %} {% endwith %}
### Create Template ## Parameter Templates
A *Parameter Template* is required for each part parameter. Parameter templates are used to define the different types of parameters which are available for use. These are edited via the [settings interface](../settings/global.md).
### Create Template
To create a template: To create a template:
- navigate to the "Settings" page - Navigate to the "Settings" page
- click on the "Parts" tab - Click on the "Parts" tab
- scroll down to the "Part Parameter Templates" section - Scroll down to the "Part Parameter Templates" section
- click on the "New Parameter" button - Click on the "New Parameter" button
- fill out the `Create Part Parameter Template` form: `Name` (required) and `Units` (optional) fields - Fill out the `Create Part Parameter Template` form: `Name` (required) and `Units` (optional) fields
- finally click on the "Submit" button. - Click on the "Submit" button.
### Create Parameter ### Create Parameter
@ -37,9 +41,9 @@ To add a parameter, navigate to a specific part detail page, click on the "Param
Select the parameter `Template` you would like to use for this parameter, fill-out the `Data` field (value of this specific parameter) and click the "Submit" button. Select the parameter `Template` you would like to use for this parameter, fill-out the `Data` field (value of this specific parameter) and click the "Submit" button.
### Parametric Tables ## Parametric Tables
Parametric tables gather all parameters from all parts inside a category to be sorted and filtered. Parametric tables gather all parameters from all parts inside a particular [part category](./part.md#part-category) to be sorted and filtered.
To access a category's parametric table, click on the "Parameters" tab within the category view: To access a category's parametric table, click on the "Parameters" tab within the category view:
@ -52,3 +56,36 @@ Below is an example of capacitor parametric table filtered with `Package Type =
{% with id="parametric_table_example", url="part/parametric_table_example.png", description="Parametric Table Example" %} {% with id="parametric_table_example", url="part/parametric_table_example.png", description="Parametric Table Example" %}
{% include 'img.html' %} {% include 'img.html' %}
{% endwith %} {% endwith %}
### Sorting by Parameter Value
The parametric parts table allows the returned parts to be sorted by particular parameter values. Click on the header of a particular parameter column to sort results by that parameter:
{% with id="sort_by_param", url="part/part_sort_by_param.png", description="Sort by Parameter" %}
{% include 'img.html' %}
{% endwith %}
## Parameter Units
The *units* field (which is defined against a [parameter template](#parameter-templates)) defines the base unit of that template. Any parameters which are created against that unit *must* be specified in compatible units. Unit conversion is implemented using the [pint](https://pint.readthedocs.io/en/stable/) Python library. This conversion library is used to perform two main functions:
- Enforce use of compatible units when creating part parameters
- Perform conversion to the base template unit
The in-built conversion functionality means that parameter values can be input in different dimensions - *as long as the dimension is compatible with the base template units*.
### Incompatible Units
If a part parameter is created with a value which is incompatible with the units specified for the template, it will be rejected:
{% with id="invalid_units", url="part/part_invalid_units.png", description="Invalid Parameter Units" %}
{% include 'img.html' %}
{% endwith %}
### Parameter Sorting
Parameter sorting takes unit conversion into account, meaning that values provided in different (but compatible) units are sorted correctly:
{% with id="sort_by_param_units", url="part/part_sorting_units.png", description="Sort by Parameter Units" %}
{% include 'img.html' %}
{% endwith %}

View File

@ -33,6 +33,7 @@ feedparser # RSS newsfeed parser
gunicorn # Gunicorn web server gunicorn # Gunicorn web server
pdf2image # PDF to image conversion pdf2image # PDF to image conversion
pillow # Image manipulation pillow # Image manipulation
pint # Unit conversion
python-barcode[images] # Barcode generator python-barcode[images] # Barcode generator
qrcode[pil] # QR code generator qrcode[pil] # QR code generator
rapidfuzz==0.7.6 # Fuzzy string matching rapidfuzz==0.7.6 # Fuzzy string matching

View File

@ -186,6 +186,8 @@ pillow==9.5.0
# python-barcode # python-barcode
# qrcode # qrcode
# weasyprint # weasyprint
pint==0.21
# via -r requirements.in
py-moneyed==1.2 py-moneyed==1.2
# via # via
# -r requirements.in # -r requirements.in