mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
cb8ae10280
commit
9e77b9fc56
@ -2,11 +2,14 @@
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
- Adds ability to partially scrap a build output
|
||||
|
||||
|
81
InvenTree/InvenTree/conversion.py
Normal file
81
InvenTree/InvenTree/conversion.py
Normal 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
|
@ -319,7 +319,6 @@ main {
|
||||
.filter-input {
|
||||
display: inline-block;
|
||||
*display: inline;
|
||||
zoom: 1;
|
||||
}
|
||||
|
||||
.filter-tag:hover {
|
||||
|
@ -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
|
||||
is set then the task is ran synchronously.
|
||||
"""
|
||||
|
||||
try:
|
||||
import importlib
|
||||
|
||||
|
@ -8,9 +8,31 @@ from django.core import validators
|
||||
from django.core.exceptions import FieldDoesNotExist, ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import pint
|
||||
from jinja2 import Template
|
||||
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):
|
||||
"""Check that a given code is a valid currency code."""
|
||||
|
@ -11,10 +11,6 @@ def update_tree(apps, schema_editor):
|
||||
Build.objects.rebuild()
|
||||
|
||||
|
||||
def nupdate_tree(apps, schema_editor): # pragma: no cover
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
atomic = False
|
||||
@ -53,5 +49,5 @@ class Migration(migrations.Migration):
|
||||
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(update_tree, reverse_code=nupdate_tree),
|
||||
migrations.RunPython(update_tree, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
|
@ -23,13 +23,6 @@ def add_default_reference(apps, schema_editor):
|
||||
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):
|
||||
|
||||
atomic = False
|
||||
@ -49,7 +42,7 @@ class Migration(migrations.Migration):
|
||||
# Auto-populate the new reference field for any existing build order objects
|
||||
migrations.RunPython(
|
||||
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!
|
||||
|
@ -51,14 +51,6 @@ def assign_bom_items(apps, schema_editor):
|
||||
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):
|
||||
|
||||
dependencies = [
|
||||
@ -66,5 +58,5 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(assign_bom_items, reverse_code=unassign_bom_items),
|
||||
migrations.RunPython(assign_bom_items, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
|
@ -31,12 +31,6 @@ def build_refs(apps, schema_editor):
|
||||
build.reference_int = ref
|
||||
build.save()
|
||||
|
||||
def unbuild_refs(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Provided only for reverse migration compatibility
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@ -49,6 +43,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
build_refs,
|
||||
reverse_code=unbuild_refs
|
||||
reverse_code=migrations.RunPython.noop
|
||||
)
|
||||
]
|
||||
|
@ -50,11 +50,6 @@ def update_build_reference(apps, schema_editor):
|
||||
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):
|
||||
|
||||
dependencies = [
|
||||
@ -64,6 +59,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
update_build_reference,
|
||||
reverse_code=nupdate_build_reference,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
)
|
||||
]
|
||||
|
@ -52,13 +52,6 @@ def build_refs(apps, schema_editor):
|
||||
order.save()
|
||||
|
||||
|
||||
def unbuild_refs(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Provided only for reverse migration compatibility
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
@ -67,8 +60,5 @@ class Migration(migrations.Migration):
|
||||
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
build_refs,
|
||||
reverse_code=unbuild_refs
|
||||
)
|
||||
migrations.RunPython(build_refs, reverse_code=migrations.RunPython.noop)
|
||||
]
|
||||
|
@ -40,14 +40,6 @@ def calculate_shipped_quantity(apps, schema_editor):
|
||||
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):
|
||||
|
||||
dependencies = [
|
||||
@ -57,6 +49,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
calculate_shipped_quantity,
|
||||
reverse_code=reverse_calculate_shipped_quantity
|
||||
reverse_code=migrations.RunPython.noop
|
||||
)
|
||||
]
|
||||
|
@ -84,11 +84,6 @@ def update_purchaseorder_reference(apps, schema_editor):
|
||||
print(f"Updated reference field for {n} PurchaseOrder objects")
|
||||
|
||||
|
||||
def nop(apps, schema_editor):
|
||||
"""Empty function for reverse migration"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
@ -98,10 +93,10 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
update_salesorder_reference,
|
||||
reverse_code=nop,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
migrations.RunPython(
|
||||
update_purchaseorder_reference,
|
||||
reverse_code=nop,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
)
|
||||
]
|
||||
|
@ -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")
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
"""Reverse migration (does nothing)"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
@ -122,10 +117,10 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
update_purchase_order_price,
|
||||
reverse_code=reverse
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
migrations.RunPython(
|
||||
update_sales_order_price,
|
||||
reverse_code=reverse,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
)
|
||||
]
|
||||
|
@ -122,9 +122,9 @@ class PartImportResource(InvenTreeResource):
|
||||
]
|
||||
|
||||
|
||||
class StocktakeInline(admin.TabularInline):
|
||||
"""Inline for part stocktake data"""
|
||||
model = models.PartStocktake
|
||||
class PartParameterInline(admin.TabularInline):
|
||||
"""Inline for part parameter data"""
|
||||
model = models.PartParameter
|
||||
|
||||
|
||||
class PartAdmin(ImportExportModelAdmin):
|
||||
@ -146,7 +146,7 @@ class PartAdmin(ImportExportModelAdmin):
|
||||
]
|
||||
|
||||
inlines = [
|
||||
StocktakeInline,
|
||||
PartParameterInline,
|
||||
]
|
||||
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""Provides a JSON API for the Part app."""
|
||||
|
||||
import functools
|
||||
import re
|
||||
|
||||
from django.db.models import Count, F, Q
|
||||
from django.http import JsonResponse
|
||||
@ -14,6 +15,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
import order.models
|
||||
import part.filters
|
||||
from build.models import Build, BuildItem
|
||||
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
|
||||
ListCreateDestroyAPIView, MetadataView)
|
||||
@ -1102,7 +1104,6 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
|
||||
# TODO: Querying bom_valid status may be quite expensive
|
||||
# TODO: (It needs to be profiled!)
|
||||
# TODO: It might be worth caching the bom_valid status to a database column
|
||||
|
||||
if bom_valid is not None:
|
||||
|
||||
bom_valid = str2bool(bom_valid)
|
||||
@ -1112,9 +1113,9 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
|
||||
|
||||
pks = []
|
||||
|
||||
for part in queryset:
|
||||
if part.is_bom_valid() == bom_valid:
|
||||
pks.append(part.pk)
|
||||
for prt in queryset:
|
||||
if prt.is_bom_valid() == bom_valid:
|
||||
pks.append(prt.pk)
|
||||
|
||||
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 = 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
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
@ -1320,6 +1349,20 @@ class PartRelatedDetail(RetrieveUpdateDestroyAPI):
|
||||
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):
|
||||
"""API endpoint for accessing a list of PartParameterTemplate objects.
|
||||
|
||||
@ -1329,6 +1372,7 @@ class PartParameterTemplateList(ListCreateAPI):
|
||||
|
||||
queryset = PartParameterTemplate.objects.all()
|
||||
serializer_class = part_serializers.PartParameterTemplateSerializer
|
||||
filterset_class = PartParameterTemplateFilter
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
@ -1338,6 +1382,12 @@ class PartParameterTemplateList(ListCreateAPI):
|
||||
|
||||
search_fields = [
|
||||
'name',
|
||||
'description',
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
'name',
|
||||
'units',
|
||||
]
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
|
@ -19,8 +19,9 @@ Relevant PRs:
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import (DecimalField, ExpressionWrapper, F, FloatField,
|
||||
Func, IntegerField, OuterRef, Q, Subquery)
|
||||
from django.db.models import (Case, DecimalField, Exists, ExpressionWrapper, F,
|
||||
FloatField, Func, IntegerField, OuterRef, Q,
|
||||
Subquery, Value, When)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from sql_util.utils import SubquerySum
|
||||
@ -210,3 +211,75 @@ def annotate_category_parts():
|
||||
0,
|
||||
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',
|
||||
)
|
||||
|
@ -10,10 +10,6 @@ def update_tree(apps, schema_editor):
|
||||
Part.objects.rebuild()
|
||||
|
||||
|
||||
def nupdate_tree(apps, schema_editor): # pragma: no cover
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
atomic = False
|
||||
@ -48,5 +44,5 @@ class Migration(migrations.Migration):
|
||||
preserve_default=False,
|
||||
),
|
||||
|
||||
migrations.RunPython(update_tree, reverse_code=nupdate_tree)
|
||||
migrations.RunPython(update_tree, reverse_code=migrations.RunPython.noop)
|
||||
]
|
||||
|
@ -34,12 +34,6 @@ def update_pathstring(apps, schema_editor):
|
||||
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):
|
||||
|
||||
dependencies = [
|
||||
@ -49,6 +43,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
update_pathstring,
|
||||
reverse_code=nupdate_pathstring
|
||||
reverse_code=migrations.RunPython.noop
|
||||
)
|
||||
]
|
||||
|
@ -94,11 +94,6 @@ def update_bom_item(apps, schema_editor):
|
||||
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):
|
||||
|
||||
dependencies = [
|
||||
@ -108,6 +103,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
update_bom_item,
|
||||
reverse_code=meti_mob_etadpu
|
||||
reverse_code=migrations.RunPython.noop
|
||||
)
|
||||
]
|
||||
|
30
InvenTree/part/migrations/0108_auto_20230516_1334.py
Normal file
30
InvenTree/part/migrations/0108_auto_20230516_1334.py
Normal 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'),
|
||||
),
|
||||
]
|
147
InvenTree/part/migrations/0109_auto_20230517_1048.py
Normal file
147
InvenTree/part/migrations/0109_auto_20230517_1048.py
Normal 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
|
||||
)
|
||||
]
|
@ -12,7 +12,7 @@ from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
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.models import ExpressionWrapper, F, Q, Sum, UniqueConstraint
|
||||
from django.db.models.functions import Coalesce
|
||||
@ -35,6 +35,7 @@ from taggit.managers import TaggableManager
|
||||
|
||||
import common.models
|
||||
import common.settings
|
||||
import InvenTree.conversion
|
||||
import InvenTree.fields
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
@ -982,7 +983,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
|
||||
max_length=20, default="",
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Units'),
|
||||
help_text=_('Units of measure for this part')
|
||||
help_text=_('Units of measure for this part'),
|
||||
)
|
||||
|
||||
assembly = models.BooleanField(
|
||||
@ -3295,6 +3296,7 @@ class PartParameterTemplate(MetadataMixin, models.Model):
|
||||
Attributes:
|
||||
name: The name (key) of the Parameter [string]
|
||||
units: The units of the Parameter [string]
|
||||
description: Description of the parameter [string]
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@ -3332,7 +3334,14 @@ class PartParameterTemplate(MetadataMixin, models.Model):
|
||||
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(
|
||||
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):
|
||||
"""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):
|
||||
"""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),
|
||||
param=str(self.template.name),
|
||||
data=str(self.data),
|
||||
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
|
||||
def create(cls, part, template, data, save=False):
|
||||
|
@ -240,7 +240,8 @@ class PartParameterSerializer(InvenTreeModelSerializer):
|
||||
'part',
|
||||
'template',
|
||||
'template_detail',
|
||||
'data'
|
||||
'data',
|
||||
'data_numeric',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
@ -428,3 +428,31 @@ def scheduled_stocktake_reports():
|
||||
|
||||
# Record the date of this report
|
||||
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}'")
|
||||
|
@ -284,15 +284,6 @@
|
||||
);
|
||||
});
|
||||
|
||||
onPanelLoad('parameters', function() {
|
||||
loadParametricPartTable(
|
||||
"#parametric-part-table",
|
||||
{
|
||||
category: {{ category.pk }},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#toggle-starred").click(function() {
|
||||
toggleStar({
|
||||
url: '{% url "api-part-category-detail" category.pk %}',
|
||||
@ -302,6 +293,17 @@
|
||||
|
||||
{% endif %}
|
||||
|
||||
onPanelLoad('parameters', function() {
|
||||
loadParametricPartTable(
|
||||
"#parametric-part-table",
|
||||
{
|
||||
{% if category %}
|
||||
category: {{ category.pk }},
|
||||
{% endif %}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Enable breadcrumb tree view
|
||||
enableBreadcrumbTree({
|
||||
label: 'category',
|
||||
|
@ -16,6 +16,6 @@
|
||||
{% if category %}
|
||||
{% trans "Stock Items" as text %}
|
||||
{% include "sidebar_item.html" with label='stock' text=text icon='fa-boxes' %}
|
||||
{% endif %}
|
||||
{% trans "Parameters" as text %}
|
||||
{% include "sidebar_item.html" with label="parameters" text=text icon="fa-tasks" %}
|
||||
{% endif %}
|
||||
|
@ -2699,91 +2699,6 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
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):
|
||||
"""Unit tests for the PartAttachment API endpoint"""
|
||||
|
||||
|
@ -82,3 +82,75 @@ class TestBomItemMigrations(MigratorTestCase):
|
||||
|
||||
for bom_item in BomItem.objects.all():
|
||||
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)
|
||||
|
@ -2,8 +2,11 @@
|
||||
|
||||
import django.core.exceptions as django_exceptions
|
||||
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)
|
||||
|
||||
|
||||
@ -23,7 +26,7 @@ class TestParams(TestCase):
|
||||
self.assertEqual(str(t1), 'Length (mm)')
|
||||
|
||||
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)
|
||||
self.assertEqual(str(c1), 'Mechanical | Length | 2.8')
|
||||
@ -87,3 +90,252 @@ class TestCategoryTemplates(TransactionTestCase):
|
||||
|
||||
n = PartCategoryParameterTemplate.objects.all().count()
|
||||
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)
|
||||
|
@ -314,7 +314,7 @@ class PluginsRegistry:
|
||||
handle_error(error, do_raise=False, log_name='discovery')
|
||||
|
||||
# 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]))
|
||||
|
||||
return collected_plugins
|
||||
|
@ -203,12 +203,6 @@ def update_history(apps, schema_editor):
|
||||
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):
|
||||
|
||||
dependencies = [
|
||||
@ -216,5 +210,5 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_history, reverse_code=reverse_update)
|
||||
migrations.RunPython(update_history, reverse_code=migrations.RunPython.noop)
|
||||
]
|
||||
|
@ -60,12 +60,6 @@ def extract_purchase_price(apps, schema_editor):
|
||||
if update_count > 0: # pragma: no cover
|
||||
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):
|
||||
|
||||
@ -74,5 +68,5 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(extract_purchase_price, reverse_code=reverse_operation)
|
||||
migrations.RunPython(extract_purchase_price, reverse_code=migrations.RunPython.noop)
|
||||
]
|
||||
|
@ -36,13 +36,6 @@ def update_serials(apps, schema_editor):
|
||||
item.save()
|
||||
|
||||
|
||||
def nupdate_serials(apps, schema_editor): # pragma: no cover
|
||||
"""
|
||||
Provided only for reverse migration compatibility
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
@ -52,6 +45,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
update_serials,
|
||||
reverse_code=nupdate_serials,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
)
|
||||
]
|
||||
|
@ -35,10 +35,6 @@ def delete_scheduled(apps, schema_editor):
|
||||
Task.objects.filter(func='stock.tasks.delete_old_stock_items').delete()
|
||||
|
||||
|
||||
def reverse(apps, schema_editor): # pragma: no cover
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
@ -48,6 +44,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
delete_scheduled,
|
||||
reverse_code=reverse,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
)
|
||||
]
|
||||
|
@ -34,13 +34,6 @@ def update_pathstring(apps, schema_editor):
|
||||
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):
|
||||
|
||||
dependencies = [
|
||||
@ -50,6 +43,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
update_pathstring,
|
||||
reverse_code=nupdate_pathstring
|
||||
reverse_code=migrations.RunPython.noop
|
||||
)
|
||||
]
|
||||
|
@ -62,9 +62,6 @@ def fix_purchase_price(apps, schema_editor):
|
||||
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):
|
||||
|
||||
dependencies = [
|
||||
@ -74,6 +71,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
fix_purchase_price,
|
||||
reverse_code=reverse,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
)
|
||||
]
|
||||
|
@ -52,11 +52,6 @@ def update_stock_history(apps, schema_editor):
|
||||
print(f"Updated {n} StockItemTracking entries with SalesOrder data")
|
||||
|
||||
|
||||
def nope(apps, schema_editor):
|
||||
"""Provided for reverse migration compatibility"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
@ -65,6 +60,6 @@ class Migration(migrations.Migration):
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
update_stock_history, reverse_code=nope,
|
||||
update_stock_history, reverse_code=migrations.RunPython.noop,
|
||||
)
|
||||
]
|
||||
|
@ -55,19 +55,4 @@
|
||||
</tbody>
|
||||
</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 %}
|
||||
|
25
InvenTree/templates/InvenTree/settings/part_parameters.html
Normal file
25
InvenTree/templates/InvenTree/settings/part_parameters.html
Normal 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 %}
|
@ -36,6 +36,7 @@
|
||||
{% include "InvenTree/settings/label.html" %}
|
||||
{% include "InvenTree/settings/report.html" %}
|
||||
{% include "InvenTree/settings/part.html" %}
|
||||
{% include "InvenTree/settings/part_parameters.html" %}
|
||||
{% include "InvenTree/settings/part_stocktake.html" %}
|
||||
{% include "InvenTree/settings/category.html" %}
|
||||
{% include "InvenTree/settings/pricing.html" %}
|
||||
|
@ -302,50 +302,10 @@ onPanelLoad('category', function() {
|
||||
});
|
||||
});
|
||||
|
||||
// Javascript for the Part settings panel
|
||||
onPanelLoad('parts', 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>";
|
||||
// Javascript for the Part parameters settings panel
|
||||
onPanelLoad('part-parameters', function() {
|
||||
|
||||
var html = "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
loadPartParameterTemplateTable("#param-table", {});
|
||||
|
||||
$("#new-param").click(function() {
|
||||
constructForm('{% url "api-part-parameter-template-list" %}', {
|
||||
@ -359,45 +319,10 @@ onPanelLoad('parts', function() {
|
||||
refreshTable: '#param-table',
|
||||
});
|
||||
});
|
||||
|
||||
$("#param-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: '#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',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Javascript for the Part settings panel
|
||||
onPanelLoad('parts', function() {
|
||||
$("#import-part").click(function() {
|
||||
launchModalForm("{% url 'api-part-import' %}?reset", {});
|
||||
});
|
||||
|
@ -44,6 +44,8 @@
|
||||
{% include "sidebar_item.html" with label='category' text=text icon="fa-sitemap" %}
|
||||
{% trans "Parts" as text %}
|
||||
{% 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 %}
|
||||
{% include "sidebar_item.html" with label='stock' text=text icon="fa-boxes" %}
|
||||
{% trans "Stocktake" as text %}
|
||||
|
@ -31,6 +31,7 @@
|
||||
loadParametricPartTable,
|
||||
loadPartCategoryTable,
|
||||
loadPartParameterTable,
|
||||
loadPartParameterTemplateTable,
|
||||
loadPartPurchaseOrderTable,
|
||||
loadPartTable,
|
||||
loadPartTestTemplateTable,
|
||||
@ -1286,11 +1287,27 @@ function loadPartParameterTable(table, options) {
|
||||
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',
|
||||
title: '{% trans "Value" %}',
|
||||
switchable: false,
|
||||
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',
|
||||
@ -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.
|
||||
*
|
||||
@ -1663,6 +1781,12 @@ function loadRelatedPartsTable(table, part_id, 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');
|
||||
|
||||
var columns = [
|
||||
@ -1691,11 +1815,18 @@ function loadParametricPartTable(table, options={}) {
|
||||
async: false,
|
||||
success: function(response) {
|
||||
for (var template of response) {
|
||||
|
||||
let template_name = template.name;
|
||||
|
||||
if (template.units) {
|
||||
template_name += ` [${template.units}]`;
|
||||
}
|
||||
|
||||
columns.push({
|
||||
field: `parameter_${template.pk}`,
|
||||
title: template.name,
|
||||
title: template_name,
|
||||
switchable: true,
|
||||
sortable: false,
|
||||
sortable: true,
|
||||
filterControl: 'input',
|
||||
});
|
||||
}
|
||||
@ -1703,20 +1834,21 @@ function loadParametricPartTable(table, options={}) {
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: Re-enable filter control for parameter values
|
||||
|
||||
$(table).inventreeTable({
|
||||
url: '{% url "api-part-list" %}',
|
||||
queryParams: {
|
||||
category: options.category,
|
||||
cascade: true,
|
||||
parameters: true,
|
||||
},
|
||||
queryParams: filters,
|
||||
original: options.params,
|
||||
groupBy: false,
|
||||
name: options.name || 'part-parameters',
|
||||
formatNoMatches: function() {
|
||||
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,
|
||||
showColumns: true,
|
||||
sidePagination: 'server',
|
||||
@ -1751,8 +1883,8 @@ function loadParametricPartTable(table, options={}) {
|
||||
}
|
||||
|
||||
|
||||
function partGridTile(part) {
|
||||
// Generate a "grid tile" view for a particular part
|
||||
function partGridTile(part) {
|
||||
|
||||
// Rows for table view
|
||||
var rows = '';
|
||||
@ -1822,6 +1954,8 @@ function partGridTile(part) {
|
||||
*/
|
||||
function loadPartTable(table, url, options={}) {
|
||||
|
||||
options.params = options.params || {};
|
||||
|
||||
// Ensure category detail is included
|
||||
options.params['category_detail'] = true;
|
||||
|
||||
|
@ -55,7 +55,6 @@ function renderStatusLabel(key, codes, options={}) {
|
||||
return `<span class='${classes}'>${text}</span>`;
|
||||
}
|
||||
|
||||
|
||||
{% 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='build' data=BuildStatus.list %}
|
||||
|
@ -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
|
||||
function getAvailableTableFilters(tableKey) {
|
||||
@ -723,6 +736,10 @@ function getAvailableTableFilters(tableKey) {
|
||||
return getBuildItemTableFilters();
|
||||
case 'location':
|
||||
return getStockLocationFilters();
|
||||
case 'parameters':
|
||||
return getParametricPartTableFilters();
|
||||
case 'part-parameter-templates':
|
||||
return getPartParameterTemplateFilters();
|
||||
case 'parts':
|
||||
return getPartTableFilters();
|
||||
case 'parttests':
|
||||
|
BIN
docs/docs/assets/images/part/part_invalid_units.png
Normal file
BIN
docs/docs/assets/images/part/part_invalid_units.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 53 KiB |
BIN
docs/docs/assets/images/part/part_sort_by_param.png
Normal file
BIN
docs/docs/assets/images/part/part_sort_by_param.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 186 KiB |
BIN
docs/docs/assets/images/part/part_sorting_units.png
Normal file
BIN
docs/docs/assets/images/part/part_sorting_units.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 89 KiB |
@ -44,6 +44,7 @@ InvenTree relies on the following Python libraries:
|
||||
| [coveralls](https://pypi.org/project/coveralls/) | MIT | coverage uploader |
|
||||
| [django-formtools](https://pypi.org/project/django-formtools/) | MIT | better forms / wizards |
|
||||
| [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
|
||||
|
||||
|
@ -4,26 +4,30 @@ title: 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.
|
||||
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:
|
||||
{% with id="part_parameters_example", url="part/part_parameters_example.png", description="Part Parameters Example List" %}
|
||||
{% include 'img.html' %}
|
||||
{% 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:
|
||||
|
||||
- navigate to the "Settings" page
|
||||
- click on the "Parts" tab
|
||||
- scroll down to the "Part Parameter Templates" section
|
||||
- click on the "New Parameter" button
|
||||
- fill out the `Create Part Parameter Template` form: `Name` (required) and `Units` (optional) fields
|
||||
- finally click on the "Submit" button.
|
||||
- Navigate to the "Settings" page
|
||||
- Click on the "Parts" tab
|
||||
- Scroll down to the "Part Parameter Templates" section
|
||||
- Click on the "New Parameter" button
|
||||
- Fill out the `Create Part Parameter Template` form: `Name` (required) and `Units` (optional) fields
|
||||
- Click on the "Submit" button.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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:
|
||||
|
||||
@ -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" %}
|
||||
{% include 'img.html' %}
|
||||
{% 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 %}
|
||||
|
@ -33,6 +33,7 @@ feedparser # RSS newsfeed parser
|
||||
gunicorn # Gunicorn web server
|
||||
pdf2image # PDF to image conversion
|
||||
pillow # Image manipulation
|
||||
pint # Unit conversion
|
||||
python-barcode[images] # Barcode generator
|
||||
qrcode[pil] # QR code generator
|
||||
rapidfuzz==0.7.6 # Fuzzy string matching
|
||||
|
@ -186,6 +186,8 @@ pillow==9.5.0
|
||||
# python-barcode
|
||||
# qrcode
|
||||
# weasyprint
|
||||
pint==0.21
|
||||
# via -r requirements.in
|
||||
py-moneyed==1.2
|
||||
# via
|
||||
# -r requirements.in
|
||||
|
Loading…
Reference in New Issue
Block a user