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
|
||||||
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
|
||||||
|
|
||||||
|
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 {
|
.filter-input {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
*display: inline;
|
*display: inline;
|
||||||
zoom: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-tag:hover {
|
.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
|
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
|
||||||
|
|
||||||
|
@ -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."""
|
||||||
|
@ -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),
|
||||||
]
|
]
|
||||||
|
@ -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!
|
||||||
|
@ -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),
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -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
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -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,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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',
|
||||||
|
)
|
||||||
|
@ -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)
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
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.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):
|
||||||
|
@ -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):
|
||||||
|
@ -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}'")
|
||||||
|
@ -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',
|
||||||
|
@ -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 %}
|
|
||||||
|
@ -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"""
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
]
|
]
|
||||||
|
@ -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)
|
||||||
]
|
]
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -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 %}
|
||||||
|
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/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" %}
|
||||||
|
@ -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() {
|
|
||||||
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() {
|
$("#import-part").click(function() {
|
||||||
launchModalForm("{% url 'api-part-import' %}?reset", {});
|
launchModalForm("{% url 'api-part-import' %}?reset", {});
|
||||||
});
|
});
|
||||||
|
@ -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 %}
|
||||||
|
@ -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={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function partGridTile(part) {
|
|
||||||
// Generate a "grid tile" view for a particular part
|
// Generate a "grid tile" view for a particular part
|
||||||
|
function partGridTile(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;
|
||||||
|
|
||||||
|
@ -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 %}
|
||||||
|
@ -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':
|
||||||
|
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 |
|
| [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
|
||||||
|
|
||||||
|
@ -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 %}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user