mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +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:
		| @@ -2,11 +2,14 @@ | ||||
|  | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 115 | ||||
| INVENTREE_API_VERSION = 116 | ||||
|  | ||||
| """ | ||||
| Increment this API version number whenever there is a significant change to the API that any clients need to know about | ||||
|  | ||||
| v116 -> 2023-05-18 : https://github.com/inventree/InvenTree/pull/4823 | ||||
|     - Updates to part parameter implementation, to use physical units | ||||
|  | ||||
| v115 - > 2023-05-18 : https://github.com/inventree/InvenTree/pull/4846 | ||||
|     - Adds ability to partially scrap a build output | ||||
|  | ||||
|   | ||||
							
								
								
									
										81
									
								
								InvenTree/InvenTree/conversion.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								InvenTree/InvenTree/conversion.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| """Helper functions for converting between units.""" | ||||
|  | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| import pint | ||||
|  | ||||
| _unit_registry = None | ||||
|  | ||||
|  | ||||
| def get_unit_registry(): | ||||
|     """Return a custom instance of the Pint UnitRegistry.""" | ||||
|  | ||||
|     global _unit_registry | ||||
|  | ||||
|     # Cache the unit registry for speedier access | ||||
|     if _unit_registry is None: | ||||
|         _unit_registry = pint.UnitRegistry() | ||||
|  | ||||
|     # TODO: Allow for custom units to be defined in the database | ||||
|  | ||||
|     return _unit_registry | ||||
|  | ||||
|  | ||||
| def convert_physical_value(value: str, unit: str = None): | ||||
|     """Validate that the provided value is a valid physical quantity. | ||||
|  | ||||
|     Arguments: | ||||
|         value: Value to validate (str) | ||||
|         unit: Optional unit to convert to, and validate against | ||||
|  | ||||
|     Raises: | ||||
|         ValidationError: If the value is invalid | ||||
|  | ||||
|     Returns: | ||||
|         The converted quantity, in the specified units | ||||
|     """ | ||||
|  | ||||
|     # Ensure that the value is a string | ||||
|     value = str(value).strip() | ||||
|  | ||||
|     # Error on blank values | ||||
|     if not value: | ||||
|         raise ValidationError(_('No value provided')) | ||||
|  | ||||
|     ureg = get_unit_registry() | ||||
|     error = '' | ||||
|  | ||||
|     try: | ||||
|         # Convert to a quantity | ||||
|         val = ureg.Quantity(value) | ||||
|  | ||||
|         if unit: | ||||
|  | ||||
|             if val.units == ureg.dimensionless: | ||||
|                 # If the provided value is dimensionless, assume that the unit is correct | ||||
|                 val = ureg.Quantity(value, unit) | ||||
|             else: | ||||
|                 # Convert to the provided unit (may raise an exception) | ||||
|                 val = val.to(unit) | ||||
|  | ||||
|         # At this point we *should* have a valid pint value | ||||
|         # To double check, look at the maginitude | ||||
|         float(val.magnitude) | ||||
|     except ValueError: | ||||
|         error = _('Provided value is not a valid number') | ||||
|     except pint.errors.UndefinedUnitError: | ||||
|         error = _('Provided value has an invalid unit') | ||||
|     except pint.errors.DefinitionSyntaxError: | ||||
|         error = _('Provided value has an invalid unit') | ||||
|     except pint.errors.DimensionalityError: | ||||
|         error = _('Provided value could not be converted to the specified unit') | ||||
|  | ||||
|     if error: | ||||
|         if unit: | ||||
|             error += f' ({unit})' | ||||
|  | ||||
|         raise ValidationError(error) | ||||
|  | ||||
|     # Return the converted value | ||||
|     return val | ||||
| @@ -319,7 +319,6 @@ main { | ||||
| .filter-input { | ||||
|     display: inline-block; | ||||
|     *display: inline; | ||||
|     zoom: 1; | ||||
| } | ||||
|  | ||||
| .filter-tag:hover { | ||||
|   | ||||
| @@ -167,6 +167,7 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs) | ||||
|     If workers are not running or force_sync flag | ||||
|     is set then the task is ran synchronously. | ||||
|     """ | ||||
|  | ||||
|     try: | ||||
|         import importlib | ||||
|  | ||||
|   | ||||
| @@ -8,9 +8,31 @@ from django.core import validators | ||||
| from django.core.exceptions import FieldDoesNotExist, ValidationError | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| import pint | ||||
| from jinja2 import Template | ||||
| from moneyed import CURRENCIES | ||||
|  | ||||
| import InvenTree.conversion | ||||
|  | ||||
|  | ||||
| def validate_physical_units(unit): | ||||
|     """Ensure that a given unit is a valid physical unit.""" | ||||
|  | ||||
|     unit = unit.strip() | ||||
|  | ||||
|     # Ignore blank units | ||||
|     if not unit: | ||||
|         return | ||||
|  | ||||
|     ureg = InvenTree.conversion.get_unit_registry() | ||||
|  | ||||
|     try: | ||||
|         ureg(unit) | ||||
|     except AttributeError: | ||||
|         raise ValidationError(_('Invalid physical unit')) | ||||
|     except pint.errors.UndefinedUnitError: | ||||
|         raise ValidationError(_('Invalid physical unit')) | ||||
|  | ||||
|  | ||||
| def validate_currency_code(code): | ||||
|     """Check that a given code is a valid currency code.""" | ||||
|   | ||||
| @@ -11,10 +11,6 @@ def update_tree(apps, schema_editor): | ||||
|     Build.objects.rebuild() | ||||
|  | ||||
|  | ||||
| def nupdate_tree(apps, schema_editor):  # pragma: no cover | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     atomic = False | ||||
| @@ -53,5 +49,5 @@ class Migration(migrations.Migration): | ||||
|             field=models.PositiveIntegerField(db_index=True, default=0, editable=False), | ||||
|             preserve_default=False, | ||||
|         ), | ||||
|         migrations.RunPython(update_tree, reverse_code=nupdate_tree), | ||||
|         migrations.RunPython(update_tree, reverse_code=migrations.RunPython.noop), | ||||
|     ] | ||||
|   | ||||
| @@ -23,13 +23,6 @@ def add_default_reference(apps, schema_editor): | ||||
|         print(f"\nUpdated build reference for {count} existing BuildOrder objects") | ||||
|  | ||||
|  | ||||
| def reverse_default_reference(apps, schema_editor):  # pragma: no cover | ||||
|     """ | ||||
|     Do nothing! But we need to have a function here so the whole process is reversible. | ||||
|     """ | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     atomic = False | ||||
| @@ -49,7 +42,7 @@ class Migration(migrations.Migration): | ||||
|         # Auto-populate the new reference field for any existing build order objects | ||||
|         migrations.RunPython( | ||||
|             add_default_reference, | ||||
|             reverse_code=reverse_default_reference | ||||
|             reverse_code=migrations.RunPython.noop | ||||
|         ), | ||||
|  | ||||
|         # Now that each build has a non-empty, unique reference, update the field requirements! | ||||
|   | ||||
| @@ -51,14 +51,6 @@ def assign_bom_items(apps, schema_editor): | ||||
|         logger.info(f"Assigned BomItem for {count_valid}/{count_total} entries") | ||||
|  | ||||
|  | ||||
| def unassign_bom_items(apps, schema_editor):  # pragma: no cover | ||||
|     """ | ||||
|     Reverse migration does not do anything. | ||||
|     Function here to preserve ability to reverse migration | ||||
|     """ | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -66,5 +58,5 @@ class Migration(migrations.Migration): | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython(assign_bom_items, reverse_code=unassign_bom_items), | ||||
|         migrations.RunPython(assign_bom_items, reverse_code=migrations.RunPython.noop), | ||||
|     ] | ||||
|   | ||||
| @@ -31,12 +31,6 @@ def build_refs(apps, schema_editor): | ||||
|         build.reference_int = ref | ||||
|         build.save() | ||||
|  | ||||
| def unbuild_refs(apps, schema_editor):  # pragma: no cover | ||||
|     """ | ||||
|     Provided only for reverse migration compatibility | ||||
|     """ | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
| @@ -49,6 +43,6 @@ class Migration(migrations.Migration): | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             build_refs, | ||||
|             reverse_code=unbuild_refs | ||||
|             reverse_code=migrations.RunPython.noop | ||||
|         ) | ||||
|     ] | ||||
|   | ||||
| @@ -50,11 +50,6 @@ def update_build_reference(apps, schema_editor): | ||||
|         print(f"Updated reference field for {n} BuildOrder objects") | ||||
|  | ||||
|  | ||||
| def nupdate_build_reference(apps, schema_editor): | ||||
|     """Reverse migration code. Does nothing.""" | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -64,6 +59,6 @@ class Migration(migrations.Migration): | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             update_build_reference, | ||||
|             reverse_code=nupdate_build_reference, | ||||
|             reverse_code=migrations.RunPython.noop, | ||||
|         ) | ||||
|     ] | ||||
|   | ||||
| @@ -52,13 +52,6 @@ def build_refs(apps, schema_editor): | ||||
|         order.save() | ||||
|  | ||||
|  | ||||
| def unbuild_refs(apps, schema_editor):  # pragma: no cover | ||||
|     """ | ||||
|     Provided only for reverse migration compatibility | ||||
|     """ | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -67,8 +60,5 @@ class Migration(migrations.Migration): | ||||
|  | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             build_refs, | ||||
|             reverse_code=unbuild_refs | ||||
|         ) | ||||
|         migrations.RunPython(build_refs, reverse_code=migrations.RunPython.noop) | ||||
|     ] | ||||
|   | ||||
| @@ -40,14 +40,6 @@ def calculate_shipped_quantity(apps, schema_editor): | ||||
|         item.save() | ||||
|  | ||||
|  | ||||
| def reverse_calculate_shipped_quantity(apps, schema_editor):  # pragma: no cover | ||||
|     """ | ||||
|     Provided only for reverse migration compatibility. | ||||
|     This function does nothing. | ||||
|     """ | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -57,6 +49,6 @@ class Migration(migrations.Migration): | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             calculate_shipped_quantity, | ||||
|             reverse_code=reverse_calculate_shipped_quantity | ||||
|             reverse_code=migrations.RunPython.noop | ||||
|         ) | ||||
|     ] | ||||
|   | ||||
| @@ -84,11 +84,6 @@ def update_purchaseorder_reference(apps, schema_editor): | ||||
|         print(f"Updated reference field for {n} PurchaseOrder objects") | ||||
|  | ||||
|  | ||||
| def nop(apps, schema_editor): | ||||
|     """Empty function for reverse migration""" | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -98,10 +93,10 @@ class Migration(migrations.Migration): | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             update_salesorder_reference, | ||||
|             reverse_code=nop, | ||||
|             reverse_code=migrations.RunPython.noop, | ||||
|         ), | ||||
|         migrations.RunPython( | ||||
|             update_purchaseorder_reference, | ||||
|             reverse_code=nop, | ||||
|             reverse_code=migrations.RunPython.noop, | ||||
|         ) | ||||
|     ] | ||||
|   | ||||
| @@ -108,11 +108,6 @@ def update_sales_order_price(apps, schema_editor): | ||||
|         logger.info(f"'total_price' field could not be updated for {invalid_count} SalesOrder instances") | ||||
|  | ||||
|  | ||||
| def reverse(apps, schema_editor): | ||||
|     """Reverse migration (does nothing)""" | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -122,10 +117,10 @@ class Migration(migrations.Migration): | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             update_purchase_order_price, | ||||
|             reverse_code=reverse | ||||
|             reverse_code=migrations.RunPython.noop | ||||
|         ), | ||||
|         migrations.RunPython( | ||||
|             update_sales_order_price, | ||||
|             reverse_code=reverse, | ||||
|             reverse_code=migrations.RunPython.noop, | ||||
|         ) | ||||
|     ] | ||||
|   | ||||
| @@ -122,9 +122,9 @@ class PartImportResource(InvenTreeResource): | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class StocktakeInline(admin.TabularInline): | ||||
|     """Inline for part stocktake data""" | ||||
|     model = models.PartStocktake | ||||
| class PartParameterInline(admin.TabularInline): | ||||
|     """Inline for part parameter data""" | ||||
|     model = models.PartParameter | ||||
|  | ||||
|  | ||||
| class PartAdmin(ImportExportModelAdmin): | ||||
| @@ -146,7 +146,7 @@ class PartAdmin(ImportExportModelAdmin): | ||||
|     ] | ||||
|  | ||||
|     inlines = [ | ||||
|         StocktakeInline, | ||||
|         PartParameterInline, | ||||
|     ] | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| """Provides a JSON API for the Part app.""" | ||||
|  | ||||
| import functools | ||||
| import re | ||||
|  | ||||
| from django.db.models import Count, F, Q | ||||
| from django.http import JsonResponse | ||||
| @@ -14,6 +15,7 @@ from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| import order.models | ||||
| import part.filters | ||||
| from build.models import Build, BuildItem | ||||
| from InvenTree.api import (APIDownloadMixin, AttachmentMixin, | ||||
|                            ListCreateDestroyAPIView, MetadataView) | ||||
| @@ -1102,7 +1104,6 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI): | ||||
|         # TODO: Querying bom_valid status may be quite expensive | ||||
|         # TODO: (It needs to be profiled!) | ||||
|         # TODO: It might be worth caching the bom_valid status to a database column | ||||
|  | ||||
|         if bom_valid is not None: | ||||
|  | ||||
|             bom_valid = str2bool(bom_valid) | ||||
| @@ -1112,9 +1113,9 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI): | ||||
|  | ||||
|             pks = [] | ||||
|  | ||||
|             for part in queryset: | ||||
|                 if part.is_bom_valid() == bom_valid: | ||||
|                     pks.append(part.pk) | ||||
|             for prt in queryset: | ||||
|                 if prt.is_bom_valid() == bom_valid: | ||||
|                     pks.append(prt.pk) | ||||
|  | ||||
|             queryset = queryset.filter(pk__in=pks) | ||||
|  | ||||
| @@ -1217,6 +1218,34 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI): | ||||
|  | ||||
|             queryset = queryset.filter(pk__in=parts_needed_to_complete_builds) | ||||
|  | ||||
|         queryset = self.filter_parameteric_data(queryset) | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|     def filter_parameteric_data(self, queryset): | ||||
|         """Filter queryset against part parameters. | ||||
|  | ||||
|         Here we can perfom a number of different functions: | ||||
|  | ||||
|         Ordering Based on Parameter Value: | ||||
|         - Used if the 'ordering' query param points to a parameter | ||||
|         - e.g. '&ordering=param_<id>' where <id> specifies the PartParameterTemplate | ||||
|         - Only parts which have a matching parameter are returned | ||||
|         - Queryset is ordered based on parameter value | ||||
|         """ | ||||
|  | ||||
|         # Extract "ordering" parameter from query args | ||||
|         ordering = self.request.query_params.get('ordering', None) | ||||
|  | ||||
|         if ordering: | ||||
|             # Ordering value must match required regex pattern | ||||
|             result = re.match(r'^\-?parameter_(\d+)$', ordering) | ||||
|  | ||||
|             if result: | ||||
|                 template_id = result.group(1) | ||||
|                 ascending = not ordering.startswith('-') | ||||
|                 queryset = part.filters.order_by_parameter(queryset, template_id, ascending) | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|     filter_backends = SEARCH_ORDER_FILTER_ALIAS | ||||
| @@ -1320,6 +1349,20 @@ class PartRelatedDetail(RetrieveUpdateDestroyAPI): | ||||
|     serializer_class = part_serializers.PartRelationSerializer | ||||
|  | ||||
|  | ||||
| class PartParameterTemplateFilter(rest_filters.FilterSet): | ||||
|     """FilterSet for PartParameterTemplate objects.""" | ||||
|  | ||||
|     class Meta: | ||||
|         """Metaclass options""" | ||||
|  | ||||
|         model = PartParameterTemplate | ||||
|  | ||||
|         # Simple filter fields | ||||
|         fields = [ | ||||
|             'units', | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class PartParameterTemplateList(ListCreateAPI): | ||||
|     """API endpoint for accessing a list of PartParameterTemplate objects. | ||||
|  | ||||
| @@ -1329,6 +1372,7 @@ class PartParameterTemplateList(ListCreateAPI): | ||||
|  | ||||
|     queryset = PartParameterTemplate.objects.all() | ||||
|     serializer_class = part_serializers.PartParameterTemplateSerializer | ||||
|     filterset_class = PartParameterTemplateFilter | ||||
|  | ||||
|     filter_backends = SEARCH_ORDER_FILTER | ||||
|  | ||||
| @@ -1338,6 +1382,12 @@ class PartParameterTemplateList(ListCreateAPI): | ||||
|  | ||||
|     search_fields = [ | ||||
|         'name', | ||||
|         'description', | ||||
|     ] | ||||
|  | ||||
|     ordering_fields = [ | ||||
|         'name', | ||||
|         'units', | ||||
|     ] | ||||
|  | ||||
|     def filter_queryset(self, queryset): | ||||
|   | ||||
| @@ -19,8 +19,9 @@ Relevant PRs: | ||||
| from decimal import Decimal | ||||
|  | ||||
| from django.db import models | ||||
| from django.db.models import (DecimalField, ExpressionWrapper, F, FloatField, | ||||
|                               Func, IntegerField, OuterRef, Q, Subquery) | ||||
| from django.db.models import (Case, DecimalField, Exists, ExpressionWrapper, F, | ||||
|                               FloatField, Func, IntegerField, OuterRef, Q, | ||||
|                               Subquery, Value, When) | ||||
| from django.db.models.functions import Coalesce | ||||
|  | ||||
| from sql_util.utils import SubquerySum | ||||
| @@ -210,3 +211,75 @@ def annotate_category_parts(): | ||||
|         0, | ||||
|         output_field=IntegerField() | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def filter_by_parameter(queryset, template_id: int, value: str, func: str = ''): | ||||
|     """Filter the given queryset by a given template parameter | ||||
|  | ||||
|     Parts which do not have a value for the given parameter are excluded. | ||||
|  | ||||
|     Arguments: | ||||
|         queryset - A queryset of Part objects | ||||
|         template_id - The ID of the template parameter to filter by | ||||
|         value - The value of the parameter to filter by | ||||
|         func - The function to use for the filter (e.g. __gt, __lt, __contains) | ||||
|  | ||||
|     Returns: | ||||
|         A queryset of Part objects filtered by the given parameter | ||||
|     """ | ||||
|  | ||||
|     # TODO | ||||
|  | ||||
|     return queryset | ||||
|  | ||||
|  | ||||
| def order_by_parameter(queryset, template_id: int, ascending=True): | ||||
|     """Order the given queryset by a given template parameter | ||||
|  | ||||
|     Parts which do not have a value for the given parameter are ordered last. | ||||
|  | ||||
|     Arguments: | ||||
|         queryset - A queryset of Part objects | ||||
|         template_id - The ID of the template parameter to order by | ||||
|  | ||||
|     Returns: | ||||
|         A queryset of Part objects ordered by the given parameter | ||||
|     """ | ||||
|  | ||||
|     template_filter = part.models.PartParameter.objects.filter( | ||||
|         template__id=template_id, | ||||
|         part_id=OuterRef('id'), | ||||
|     ) | ||||
|  | ||||
|     # Annotate the queryset with the parameter value, and whether it exists | ||||
|     queryset = queryset.annotate( | ||||
|         parameter_exists=Exists(template_filter) | ||||
|     ) | ||||
|  | ||||
|     # Annotate the text data value | ||||
|     queryset = queryset.annotate( | ||||
|         parameter_value=Case( | ||||
|             When( | ||||
|                 parameter_exists=True, | ||||
|                 then=Subquery(template_filter.values('data')[:1], output_field=models.CharField()), | ||||
|             ), | ||||
|             default=Value('', output_field=models.CharField()), | ||||
|         ), | ||||
|         parameter_value_numeric=Case( | ||||
|             When( | ||||
|                 parameter_exists=True, | ||||
|                 then=Subquery(template_filter.values('data_numeric')[:1], output_field=models.FloatField()), | ||||
|             ), | ||||
|             default=Value(0, output_field=models.FloatField()), | ||||
|         ) | ||||
|     ) | ||||
|  | ||||
|     prefix = '' if ascending else '-' | ||||
|  | ||||
|     # Return filtered queryset | ||||
|  | ||||
|     return queryset.order_by( | ||||
|         '-parameter_exists', | ||||
|         f'{prefix}parameter_value_numeric', | ||||
|         f'{prefix}parameter_value', | ||||
|     ) | ||||
|   | ||||
| @@ -10,10 +10,6 @@ def update_tree(apps, schema_editor): | ||||
|     Part.objects.rebuild() | ||||
|  | ||||
|  | ||||
| def nupdate_tree(apps, schema_editor):  # pragma: no cover | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     atomic = False | ||||
| @@ -48,5 +44,5 @@ class Migration(migrations.Migration): | ||||
|             preserve_default=False, | ||||
|         ), | ||||
|  | ||||
|         migrations.RunPython(update_tree, reverse_code=nupdate_tree) | ||||
|         migrations.RunPython(update_tree, reverse_code=migrations.RunPython.noop) | ||||
|     ] | ||||
|   | ||||
| @@ -34,12 +34,6 @@ def update_pathstring(apps, schema_editor): | ||||
|         print(f"\n--- Updated 'pathstring' for {n} PartCategory objects ---\n") | ||||
|  | ||||
|  | ||||
| def nupdate_pathstring(apps, schema_editor): | ||||
|     """Empty function for reverse migration compatibility""" | ||||
|  | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -49,6 +43,6 @@ class Migration(migrations.Migration): | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             update_pathstring, | ||||
|             reverse_code=nupdate_pathstring | ||||
|             reverse_code=migrations.RunPython.noop | ||||
|         ) | ||||
|     ] | ||||
|   | ||||
| @@ -94,11 +94,6 @@ def update_bom_item(apps, schema_editor): | ||||
|         logger.info(f"Updated 'validated' flag for {n} BomItem objects") | ||||
|  | ||||
|  | ||||
| def meti_mob_etadpu(apps, schema_editor): | ||||
|     """Provided for reverse compatibility""" | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -108,6 +103,6 @@ class Migration(migrations.Migration): | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             update_bom_item, | ||||
|             reverse_code=meti_mob_etadpu | ||||
|             reverse_code=migrations.RunPython.noop | ||||
|         ) | ||||
|     ] | ||||
|   | ||||
							
								
								
									
										30
									
								
								InvenTree/part/migrations/0108_auto_20230516_1334.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								InvenTree/part/migrations/0108_auto_20230516_1334.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| # Generated by Django 3.2.19 on 2023-05-16 13:34 | ||||
|  | ||||
| import InvenTree.validators | ||||
| import django.core.validators | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('part', '0107_alter_part_tags'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='partparameter', | ||||
|             name='data_numeric', | ||||
|             field=models.FloatField(default=None, null=True, blank=True), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='partparameter', | ||||
|             name='data', | ||||
|             field=models.CharField(help_text='Parameter Value', max_length=500, validators=[django.core.validators.MinLengthValidator(1)], verbose_name='Data'), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='partparametertemplate', | ||||
|             name='units', | ||||
|             field=models.CharField(blank=True, help_text='Physical units for this parameter', max_length=25, validators=[InvenTree.validators.validate_physical_units], verbose_name='Units'), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										147
									
								
								InvenTree/part/migrations/0109_auto_20230517_1048.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								InvenTree/part/migrations/0109_auto_20230517_1048.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,147 @@ | ||||
| # Generated by Django 3.2.19 on 2023-05-17 10:48 | ||||
|  | ||||
| import pint | ||||
|  | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.db import migrations | ||||
|  | ||||
| import InvenTree.conversion | ||||
|  | ||||
|  | ||||
| def update_template_units(apps, schema_editor): | ||||
|     """Update the units for each parameter template: | ||||
|  | ||||
|     - Check if the units are valid | ||||
|     - Attempt to convert to valid units (if possible) | ||||
|     """ | ||||
|  | ||||
|     PartParameterTemplate = apps.get_model('part', 'PartParameterTemplate') | ||||
|  | ||||
|     n_templates = PartParameterTemplate.objects.count() | ||||
|  | ||||
|     ureg = InvenTree.conversion.get_unit_registry() | ||||
|  | ||||
|     n_converted = 0 | ||||
|     invalid_units = [] | ||||
|  | ||||
|     for template in PartParameterTemplate.objects.all(): | ||||
|  | ||||
|         # Skip empty units | ||||
|         if not template.units: | ||||
|             continue | ||||
|  | ||||
|         # Override '%' units (which are invalid) | ||||
|         if template.units == '%': | ||||
|             template.units = 'percent' | ||||
|             template.save() | ||||
|             n_converted += 1 | ||||
|             continue | ||||
|  | ||||
|         # Test if unit is 'valid' | ||||
|         try: | ||||
|             ureg.Unit(template.units) | ||||
|             continue | ||||
|         except pint.errors.UndefinedUnitError: | ||||
|             pass | ||||
|  | ||||
|         # Check a lower-case version | ||||
|         try: | ||||
|             ureg.Unit(template.units.lower()) | ||||
|             print(f"Found unit match: {template.units} -> {template.units.lower()}") | ||||
|             template.units = template.units.lower() | ||||
|             template.save() | ||||
|             n_converted += 1 | ||||
|             continue | ||||
|         except pint.errors.UndefinedUnitError: | ||||
|             pass | ||||
|  | ||||
|         found = False | ||||
|  | ||||
|         # Attempt to convert to a valid unit | ||||
|         # Look for capitalization issues (e.g. "Ohm" -> "ohm") | ||||
|         for unit in ureg: | ||||
|             if unit.lower() == template.units.lower(): | ||||
|                 print(f"Found unit match: {template.units} -> {unit}") | ||||
|                 template.units = str(unit) | ||||
|                 template.save() | ||||
|                 n_converted += 1 | ||||
|                 found = True | ||||
|                 break | ||||
|  | ||||
|         if not found: | ||||
|             print(f"warningCould not find unit match for {template.units}") | ||||
|             invalid_units.append(template.units) | ||||
|  | ||||
|     print(f"Updated units for {n_templates} parameter templates") | ||||
|  | ||||
|     if n_converted > 0: | ||||
|         print(f" - Converted {n_converted} units") | ||||
|  | ||||
|     if len(invalid_units) > 0: | ||||
|         print(f" - Found {len(invalid_units)} invalid units:") | ||||
|  | ||||
|         for unit in invalid_units: | ||||
|             print(f"   - {unit}") | ||||
|  | ||||
|  | ||||
|  | ||||
| def convert_to_numeric_value(value: str, units: str): | ||||
|     """Convert a value (with units) to a numeric value. | ||||
|  | ||||
|     Defaults to zero if the value cannot be converted. | ||||
|     """ | ||||
|  | ||||
|     # Default value is null | ||||
|     result = None | ||||
|  | ||||
|     if units: | ||||
|         try: | ||||
|             result = InvenTree.conversion.convert_physical_value(value, units) | ||||
|             result = float(result.magnitude) | ||||
|         except (ValidationError, ValueError): | ||||
|             pass | ||||
|     else: | ||||
|         try: | ||||
|             result = float(value) | ||||
|         except ValueError: | ||||
|             pass | ||||
|  | ||||
|     return result | ||||
|  | ||||
|  | ||||
| def update_parameter_values(apps, schema_editor): | ||||
|     """Update the parameter values for all parts: | ||||
|  | ||||
|     - Calculate the 'data_numeric' value for each parameter | ||||
|     - If the template has invalid units, we'll ignore | ||||
|     """ | ||||
|  | ||||
|     PartParameter = apps.get_model('part', 'PartParameter') | ||||
|  | ||||
|     n_params = PartParameter.objects.count() | ||||
|  | ||||
|     # Convert each parameter value to a the specified units | ||||
|     for parameter in PartParameter.objects.all(): | ||||
|         parameter.data_numeric = convert_to_numeric_value(parameter.data, parameter.template.units) | ||||
|         parameter.save() | ||||
|  | ||||
|     if n_params > 0: | ||||
|         print(f"Updated {n_params} parameter values") | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('part', '0108_auto_20230516_1334'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             update_template_units, | ||||
|             reverse_code=migrations.RunPython.noop | ||||
|         ), | ||||
|         migrations.RunPython( | ||||
|             update_parameter_values, | ||||
|             reverse_code=migrations.RunPython.noop | ||||
|         ) | ||||
|     ] | ||||
| @@ -12,7 +12,7 @@ from decimal import Decimal, InvalidOperation | ||||
|  | ||||
| from django.contrib.auth.models import User | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.core.validators import MinValueValidator | ||||
| from django.core.validators import MinLengthValidator, MinValueValidator | ||||
| from django.db import models, transaction | ||||
| from django.db.models import ExpressionWrapper, F, Q, Sum, UniqueConstraint | ||||
| from django.db.models.functions import Coalesce | ||||
| @@ -35,6 +35,7 @@ from taggit.managers import TaggableManager | ||||
|  | ||||
| import common.models | ||||
| import common.settings | ||||
| import InvenTree.conversion | ||||
| import InvenTree.fields | ||||
| import InvenTree.ready | ||||
| import InvenTree.tasks | ||||
| @@ -982,7 +983,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) | ||||
|         max_length=20, default="", | ||||
|         blank=True, null=True, | ||||
|         verbose_name=_('Units'), | ||||
|         help_text=_('Units of measure for this part') | ||||
|         help_text=_('Units of measure for this part'), | ||||
|     ) | ||||
|  | ||||
|     assembly = models.BooleanField( | ||||
| @@ -3295,6 +3296,7 @@ class PartParameterTemplate(MetadataMixin, models.Model): | ||||
|     Attributes: | ||||
|         name: The name (key) of the Parameter [string] | ||||
|         units: The units of the Parameter [string] | ||||
|         description: Description of the parameter [string] | ||||
|     """ | ||||
|  | ||||
|     @staticmethod | ||||
| @@ -3332,7 +3334,14 @@ class PartParameterTemplate(MetadataMixin, models.Model): | ||||
|         unique=True | ||||
|     ) | ||||
|  | ||||
|     units = models.CharField(max_length=25, verbose_name=_('Units'), help_text=_('Parameter Units'), blank=True) | ||||
|     units = models.CharField( | ||||
|         max_length=25, | ||||
|         verbose_name=_('Units'), help_text=_('Physical units for this parameter'), | ||||
|         blank=True, | ||||
|         validators=[ | ||||
|             validators.validate_physical_units, | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
|     description = models.CharField( | ||||
|         max_length=250, | ||||
| @@ -3342,6 +3351,23 @@ class PartParameterTemplate(MetadataMixin, models.Model): | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @receiver(post_save, sender=PartParameterTemplate, dispatch_uid='post_save_part_parameter_template') | ||||
| def post_save_part_parameter_template(sender, instance, created, **kwargs): | ||||
|     """Callback function when a PartParameterTemplate is created or saved""" | ||||
|  | ||||
|     import part.tasks as part_tasks | ||||
|  | ||||
|     if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): | ||||
|  | ||||
|         # Schedule a background task to rebuild the parameters against this template | ||||
|         if not created: | ||||
|             InvenTree.tasks.offload_task( | ||||
|                 part_tasks.rebuild_parameters, | ||||
|                 instance.pk, | ||||
|                 force_async=True | ||||
|             ) | ||||
|  | ||||
|  | ||||
| class PartParameter(models.Model): | ||||
|     """A PartParameter is a specific instance of a PartParameterTemplate. It assigns a particular parameter <key:value> pair to a part. | ||||
|  | ||||
| @@ -3363,18 +3389,79 @@ class PartParameter(models.Model): | ||||
|  | ||||
|     def __str__(self): | ||||
|         """String representation of a PartParameter (used in the admin interface)""" | ||||
|         return "{part} : {param} = {data}{units}".format( | ||||
|         return "{part} : {param} = {data} ({units})".format( | ||||
|             part=str(self.part.full_name), | ||||
|             param=str(self.template.name), | ||||
|             data=str(self.data), | ||||
|             units=str(self.template.units) | ||||
|         ) | ||||
|  | ||||
|     part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='parameters', verbose_name=_('Part'), help_text=_('Parent Part')) | ||||
|     def save(self, *args, **kwargs): | ||||
|         """Custom save method for the PartParameter model.""" | ||||
|  | ||||
|     template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE, related_name='instances', verbose_name=_('Template'), help_text=_('Parameter Template')) | ||||
|         # Validate the PartParameter before saving | ||||
|         self.calculate_numeric_value() | ||||
|  | ||||
|     data = models.CharField(max_length=500, verbose_name=_('Data'), help_text=_('Parameter Value')) | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|     def clean(self): | ||||
|         """Validate the PartParameter before saving to the database.""" | ||||
|  | ||||
|         super().clean() | ||||
|  | ||||
|         # Validate the parameter data against the template units | ||||
|         if self.template.units: | ||||
|             try: | ||||
|                 InvenTree.conversion.convert_physical_value(self.data, self.template.units) | ||||
|             except ValidationError as e: | ||||
|                 raise ValidationError({ | ||||
|                     'data': e.message | ||||
|                 }) | ||||
|  | ||||
|     def calculate_numeric_value(self): | ||||
|         """Calculate a numeric value for the parameter data. | ||||
|  | ||||
|         - If a 'units' field is provided, then the data will be converted to the base SI unit. | ||||
|         - Otherwise, we'll try to do a simple float cast | ||||
|         """ | ||||
|  | ||||
|         if self.template.units: | ||||
|             try: | ||||
|                 converted = InvenTree.conversion.convert_physical_value(self.data, self.template.units) | ||||
|                 self.data_numeric = float(converted.magnitude) | ||||
|             except (ValidationError, ValueError): | ||||
|                 self.data_numeric = None | ||||
|  | ||||
|         # No units provided, so try to cast to a float | ||||
|         else: | ||||
|             try: | ||||
|                 self.data_numeric = float(self.data) | ||||
|             except ValueError: | ||||
|                 self.data_numeric = None | ||||
|  | ||||
|     part = models.ForeignKey( | ||||
|         Part, on_delete=models.CASCADE, related_name='parameters', | ||||
|         verbose_name=_('Part'), help_text=_('Parent Part') | ||||
|     ) | ||||
|  | ||||
|     template = models.ForeignKey( | ||||
|         PartParameterTemplate, on_delete=models.CASCADE, related_name='instances', | ||||
|         verbose_name=_('Template'), help_text=_('Parameter Template') | ||||
|     ) | ||||
|  | ||||
|     data = models.CharField( | ||||
|         max_length=500, | ||||
|         verbose_name=_('Data'), help_text=_('Parameter Value'), | ||||
|         validators=[ | ||||
|             MinLengthValidator(1), | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
|     data_numeric = models.FloatField( | ||||
|         default=None, | ||||
|         null=True, | ||||
|         blank=True, | ||||
|     ) | ||||
|  | ||||
|     @classmethod | ||||
|     def create(cls, part, template, data, save=False): | ||||
|   | ||||
| @@ -240,7 +240,8 @@ class PartParameterSerializer(InvenTreeModelSerializer): | ||||
|             'part', | ||||
|             'template', | ||||
|             'template_detail', | ||||
|             'data' | ||||
|             'data', | ||||
|             'data_numeric', | ||||
|         ] | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|   | ||||
| @@ -428,3 +428,31 @@ def scheduled_stocktake_reports(): | ||||
|  | ||||
|     # Record the date of this report | ||||
|     common.models.InvenTreeSetting.set_setting('STOCKTAKE_RECENT_REPORT', datetime.now().isoformat(), None) | ||||
|  | ||||
|  | ||||
| def rebuild_parameters(template_id): | ||||
|     """Rebuild all parameters for a given template. | ||||
|  | ||||
|     This method is called when a base template is changed, | ||||
|     which may cause the base unit to be adjusted. | ||||
|     """ | ||||
|  | ||||
|     try: | ||||
|         template = part.models.PartParameterTemplate.objects.get(pk=template_id) | ||||
|     except part.models.PartParameterTemplate.DoesNotExist: | ||||
|         return | ||||
|  | ||||
|     parameters = part.models.PartParameter.objects.filter(template=template) | ||||
|  | ||||
|     n = 0 | ||||
|  | ||||
|     for parameter in parameters: | ||||
|         # Update the parameter if the numeric value has changed | ||||
|         value_old = parameter.data_numeric | ||||
|         parameter.calculate_numeric_value() | ||||
|  | ||||
|         if value_old != parameter.data_numeric: | ||||
|             parameter.save() | ||||
|             n += 1 | ||||
|  | ||||
|     logger.info(f"Rebuilt {n} parameters for template '{template.name}'") | ||||
|   | ||||
| @@ -284,15 +284,6 @@ | ||||
|         ); | ||||
|     }); | ||||
|  | ||||
|     onPanelLoad('parameters', function() { | ||||
|         loadParametricPartTable( | ||||
|             "#parametric-part-table", | ||||
|             { | ||||
|                 category: {{ category.pk }}, | ||||
|             } | ||||
|         ); | ||||
|     }); | ||||
|  | ||||
|     $("#toggle-starred").click(function() { | ||||
|         toggleStar({ | ||||
|             url: '{% url "api-part-category-detail" category.pk %}', | ||||
| @@ -302,6 +293,17 @@ | ||||
|  | ||||
|     {% endif %} | ||||
|  | ||||
|     onPanelLoad('parameters', function() { | ||||
|         loadParametricPartTable( | ||||
|             "#parametric-part-table", | ||||
|             { | ||||
|                 {% if category %} | ||||
|                 category: {{ category.pk }}, | ||||
|                 {% endif %} | ||||
|             } | ||||
|         ); | ||||
|     }); | ||||
|  | ||||
|     // Enable breadcrumb tree view | ||||
|     enableBreadcrumbTree({ | ||||
|         label: 'category', | ||||
|   | ||||
| @@ -16,6 +16,6 @@ | ||||
| {% if category %} | ||||
| {% trans "Stock Items" as text %} | ||||
| {% include "sidebar_item.html" with label='stock' text=text icon='fa-boxes' %} | ||||
| {% endif %} | ||||
| {% trans "Parameters" as text %} | ||||
| {% include "sidebar_item.html" with label="parameters" text=text icon="fa-tasks" %} | ||||
| {% endif %} | ||||
|   | ||||
| @@ -2699,91 +2699,6 @@ class BomItemTest(InvenTreeAPITestCase): | ||||
|         self.assertEqual(response.data['available_variant_stock'], 1000) | ||||
|  | ||||
|  | ||||
| class PartParameterTest(InvenTreeAPITestCase): | ||||
|     """Tests for the ParParameter API.""" | ||||
|     superuser = True | ||||
|  | ||||
|     fixtures = [ | ||||
|         'category', | ||||
|         'part', | ||||
|         'location', | ||||
|         'params', | ||||
|     ] | ||||
|  | ||||
|     def test_list_params(self): | ||||
|         """Test for listing part parameters.""" | ||||
|         url = reverse('api-part-parameter-list') | ||||
|  | ||||
|         response = self.get(url) | ||||
|  | ||||
|         self.assertEqual(len(response.data), 7) | ||||
|  | ||||
|         # Filter by part | ||||
|         response = self.get( | ||||
|             url, | ||||
|             { | ||||
|                 'part': 3, | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(len(response.data), 3) | ||||
|  | ||||
|         # Filter by template | ||||
|         response = self.get( | ||||
|             url, | ||||
|             { | ||||
|                 'template': 1, | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(len(response.data), 4) | ||||
|  | ||||
|     def test_create_param(self): | ||||
|         """Test that we can create a param via the API.""" | ||||
|         url = reverse('api-part-parameter-list') | ||||
|  | ||||
|         response = self.post( | ||||
|             url, | ||||
|             { | ||||
|                 'part': '2', | ||||
|                 'template': '3', | ||||
|                 'data': 70 | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 201) | ||||
|  | ||||
|         response = self.get(url) | ||||
|  | ||||
|         self.assertEqual(len(response.data), 8) | ||||
|  | ||||
|     def test_param_detail(self): | ||||
|         """Tests for the PartParameter detail endpoint.""" | ||||
|         url = reverse('api-part-parameter-detail', kwargs={'pk': 5}) | ||||
|  | ||||
|         response = self.get(url) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         data = response.data | ||||
|  | ||||
|         self.assertEqual(data['pk'], 5) | ||||
|         self.assertEqual(data['part'], 3) | ||||
|         self.assertEqual(data['data'], '12') | ||||
|  | ||||
|         # PATCH data back in | ||||
|         response = self.patch(url, {'data': '15'}) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         # Check that the data changed! | ||||
|         response = self.get(url) | ||||
|  | ||||
|         data = response.data | ||||
|  | ||||
|         self.assertEqual(data['data'], '15') | ||||
|  | ||||
|  | ||||
| class PartAttachmentTest(InvenTreeAPITestCase): | ||||
|     """Unit tests for the PartAttachment API endpoint""" | ||||
|  | ||||
|   | ||||
| @@ -82,3 +82,75 @@ class TestBomItemMigrations(MigratorTestCase): | ||||
|  | ||||
|         for bom_item in BomItem.objects.all(): | ||||
|             self.assertFalse(bom_item.validated) | ||||
|  | ||||
|  | ||||
| class TestParameterMigrations(MigratorTestCase): | ||||
|     """Unit test for part parameter migrations""" | ||||
|  | ||||
|     migrate_from = ('part', '0106_part_tags') | ||||
|     migrate_to = ('part', '0109_auto_20230517_1048') | ||||
|  | ||||
|     def prepare(self): | ||||
|         """Create some parts, and templates with parameters""" | ||||
|  | ||||
|         Part = self.old_state.apps.get_model('part', 'part') | ||||
|         PartParameter = self.old_state.apps.get_model('part', 'partparameter') | ||||
|         PartParameterTemlate = self.old_state.apps.get_model('part', 'partparametertemplate') | ||||
|  | ||||
|         # Create some parts | ||||
|         a = Part.objects.create( | ||||
|             name='Part A', description='My part A', | ||||
|             level=0, lft=0, rght=0, tree_id=0, | ||||
|         ) | ||||
|  | ||||
|         b = Part.objects.create( | ||||
|             name='Part B', description='My part B', | ||||
|             level=0, lft=0, rght=0, tree_id=0, | ||||
|         ) | ||||
|  | ||||
|         # Create some templates | ||||
|         t1 = PartParameterTemlate.objects.create(name='Template 1', units='mm') | ||||
|         t2 = PartParameterTemlate.objects.create(name='Template 2', units='AMPERE') | ||||
|  | ||||
|         # Create some parameter values | ||||
|         PartParameter.objects.create(part=a, template=t1, data='1.0') | ||||
|         PartParameter.objects.create(part=a, template=t2, data='-2mA',) | ||||
|  | ||||
|         PartParameter.objects.create(part=b, template=t1, data='1/10 inch') | ||||
|         PartParameter.objects.create(part=b, template=t2, data='abc') | ||||
|  | ||||
|     def test_data_migration(self): | ||||
|         """Test that the template units and values have been updated correctly""" | ||||
|  | ||||
|         Part = self.new_state.apps.get_model('part', 'part') | ||||
|         PartParameter = self.new_state.apps.get_model('part', 'partparameter') | ||||
|         PartParameterTemlate = self.new_state.apps.get_model('part', 'partparametertemplate') | ||||
|  | ||||
|         # Extract the parts | ||||
|         a = Part.objects.get(name='Part A') | ||||
|         b = Part.objects.get(name='Part B') | ||||
|  | ||||
|         # Check that the templates have been updated correctly | ||||
|         t1 = PartParameterTemlate.objects.get(name='Template 1') | ||||
|         self.assertEqual(t1.units, 'mm') | ||||
|  | ||||
|         t2 = PartParameterTemlate.objects.get(name='Template 2') | ||||
|         self.assertEqual(t2.units, 'ampere') | ||||
|  | ||||
|         # Check that the parameter values have been updated correctly | ||||
|         p1 = PartParameter.objects.get(part=a, template=t1) | ||||
|         self.assertEqual(p1.data, '1.0') | ||||
|         self.assertEqual(p1.data_numeric, 1.0) | ||||
|  | ||||
|         p2 = PartParameter.objects.get(part=a, template=t2) | ||||
|         self.assertEqual(p2.data, '-2mA') | ||||
|         self.assertEqual(p2.data_numeric, -0.002) | ||||
|  | ||||
|         p3 = PartParameter.objects.get(part=b, template=t1) | ||||
|         self.assertEqual(p3.data, '1/10 inch') | ||||
|         self.assertEqual(p3.data_numeric, 2.54) | ||||
|  | ||||
|         # This one has not converted correctly | ||||
|         p4 = PartParameter.objects.get(part=b, template=t2) | ||||
|         self.assertEqual(p4.data, 'abc') | ||||
|         self.assertEqual(p4.data_numeric, None) | ||||
|   | ||||
| @@ -2,8 +2,11 @@ | ||||
|  | ||||
| import django.core.exceptions as django_exceptions | ||||
| from django.test import TestCase, TransactionTestCase | ||||
| from django.urls import reverse | ||||
|  | ||||
| from .models import (PartCategory, PartCategoryParameterTemplate, | ||||
| from InvenTree.unit_test import InvenTreeAPITestCase | ||||
|  | ||||
| from .models import (Part, PartCategory, PartCategoryParameterTemplate, | ||||
|                      PartParameter, PartParameterTemplate) | ||||
|  | ||||
|  | ||||
| @@ -23,7 +26,7 @@ class TestParams(TestCase): | ||||
|         self.assertEqual(str(t1), 'Length (mm)') | ||||
|  | ||||
|         p1 = PartParameter.objects.get(pk=1) | ||||
|         self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm') | ||||
|         self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4 (mm)') | ||||
|  | ||||
|         c1 = PartCategoryParameterTemplate.objects.get(pk=1) | ||||
|         self.assertEqual(str(c1), 'Mechanical | Length | 2.8') | ||||
| @@ -87,3 +90,252 @@ class TestCategoryTemplates(TransactionTestCase): | ||||
|  | ||||
|         n = PartCategoryParameterTemplate.objects.all().count() | ||||
|         self.assertEqual(n, 3) | ||||
|  | ||||
|  | ||||
| class ParameterTests(TestCase): | ||||
|     """Unit tests for parameter validation""" | ||||
|  | ||||
|     fixtures = [ | ||||
|         'location', | ||||
|         'category', | ||||
|         'part', | ||||
|         'params' | ||||
|     ] | ||||
|  | ||||
|     def test_unit_validation(self): | ||||
|         """Test validation of 'units' field for PartParameterTemplate""" | ||||
|  | ||||
|         # Test that valid units pass | ||||
|         for unit in [None, '', 'mm', 'A', 'm^2', 'Pa', 'V', 'C', 'F', 'uF', 'mF', 'millifarad']: | ||||
|             tmp = PartParameterTemplate(name='test', units=unit) | ||||
|             tmp.full_clean() | ||||
|  | ||||
|         # Test that invalid units fail | ||||
|         for unit in ['mmmmm', '-', 'x', int]: | ||||
|             tmp = PartParameterTemplate(name='test', units=unit) | ||||
|             with self.assertRaises(django_exceptions.ValidationError): | ||||
|                 tmp.full_clean() | ||||
|  | ||||
|     def test_param_validation(self): | ||||
|         """Test that parameters are correctly validated against template units""" | ||||
|  | ||||
|         template = PartParameterTemplate.objects.create( | ||||
|             name='My Template', | ||||
|             units='m', | ||||
|         ) | ||||
|  | ||||
|         prt = Part.objects.get(pk=1) | ||||
|  | ||||
|         # Test that valid parameters pass | ||||
|         for value in ['1', '1m', 'm', '-4m', -2, '2.032mm', '99km', '-12 mile', 'foot', '3 yards']: | ||||
|             param = PartParameter(part=prt, template=template, data=value) | ||||
|             param.full_clean() | ||||
|  | ||||
|         # Test that invalid parameters fail | ||||
|         for value in ['3 Amps', '-3 zogs', '3.14F']: | ||||
|             param = PartParameter(part=prt, template=template, data=value) | ||||
|             with self.assertRaises(django_exceptions.ValidationError): | ||||
|                 param.full_clean() | ||||
|  | ||||
|     def test_param_conversion(self): | ||||
|         """Test that parameters are correctly converted to template units""" | ||||
|  | ||||
|         template = PartParameterTemplate.objects.create( | ||||
|             name='My Template', | ||||
|             units='m', | ||||
|         ) | ||||
|  | ||||
|         tests = { | ||||
|             '1': 1.0, | ||||
|             '-1': -1.0, | ||||
|             '23m': 23.0, | ||||
|             '-89mm': -0.089, | ||||
|             '100 foot': 30.48, | ||||
|             '-17 yards': -15.54, | ||||
|         } | ||||
|  | ||||
|         prt = Part.objects.get(pk=1) | ||||
|         param = PartParameter(part=prt, template=template, data='1') | ||||
|  | ||||
|         for value, expected in tests.items(): | ||||
|             param.data = value | ||||
|             param.calculate_numeric_value() | ||||
|             self.assertAlmostEqual(param.data_numeric, expected, places=2) | ||||
|  | ||||
|  | ||||
| class PartParameterTest(InvenTreeAPITestCase): | ||||
|     """Tests for the ParParameter API.""" | ||||
|     superuser = True | ||||
|  | ||||
|     fixtures = [ | ||||
|         'category', | ||||
|         'part', | ||||
|         'location', | ||||
|         'params', | ||||
|     ] | ||||
|  | ||||
|     def test_list_params(self): | ||||
|         """Test for listing part parameters.""" | ||||
|         url = reverse('api-part-parameter-list') | ||||
|  | ||||
|         response = self.get(url) | ||||
|  | ||||
|         self.assertEqual(len(response.data), 7) | ||||
|  | ||||
|         # Filter by part | ||||
|         response = self.get( | ||||
|             url, | ||||
|             { | ||||
|                 'part': 3, | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(len(response.data), 3) | ||||
|  | ||||
|         # Filter by template | ||||
|         response = self.get( | ||||
|             url, | ||||
|             { | ||||
|                 'template': 1, | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(len(response.data), 4) | ||||
|  | ||||
|     def test_create_param(self): | ||||
|         """Test that we can create a param via the API.""" | ||||
|         url = reverse('api-part-parameter-list') | ||||
|  | ||||
|         response = self.post( | ||||
|             url, | ||||
|             { | ||||
|                 'part': '2', | ||||
|                 'template': '3', | ||||
|                 'data': 70 | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 201) | ||||
|  | ||||
|         response = self.get(url) | ||||
|  | ||||
|         self.assertEqual(len(response.data), 8) | ||||
|  | ||||
|     def test_param_detail(self): | ||||
|         """Tests for the PartParameter detail endpoint.""" | ||||
|         url = reverse('api-part-parameter-detail', kwargs={'pk': 5}) | ||||
|  | ||||
|         response = self.get(url) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         data = response.data | ||||
|  | ||||
|         self.assertEqual(data['pk'], 5) | ||||
|         self.assertEqual(data['part'], 3) | ||||
|         self.assertEqual(data['data'], '12') | ||||
|  | ||||
|         # PATCH data back in | ||||
|         response = self.patch(url, {'data': '15'}) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         # Check that the data changed! | ||||
|         response = self.get(url) | ||||
|  | ||||
|         data = response.data | ||||
|  | ||||
|         self.assertEqual(data['data'], '15') | ||||
|  | ||||
|     def test_order_parts_by_param(self): | ||||
|         """Test that we can order parts by a specified parameter.""" | ||||
|  | ||||
|         def get_param_value(response, template, index): | ||||
|             """Helper function to extract a parameter value from a response""" | ||||
|             params = response.data[index]['parameters'] | ||||
|  | ||||
|             for param in params: | ||||
|                 if param['template'] == template: | ||||
|                     return param['data'] | ||||
|  | ||||
|             # No match | ||||
|             return None | ||||
|  | ||||
|         # Create a new parameter template | ||||
|         template = PartParameterTemplate.objects.create( | ||||
|             name='Test Template', | ||||
|             description='My test template', | ||||
|             units='m' | ||||
|         ) | ||||
|  | ||||
|         # Create parameters for each existing part | ||||
|         params = [] | ||||
|  | ||||
|         parts = Part.objects.all().order_by('pk') | ||||
|  | ||||
|         for idx, part in enumerate(parts): | ||||
|  | ||||
|             # Skip parts every now and then | ||||
|             if idx % 10 == 7: | ||||
|                 continue | ||||
|  | ||||
|             suffix = 'mm' if idx % 3 == 0 else 'm' | ||||
|  | ||||
|             params.append( | ||||
|                 PartParameter.objects.create( | ||||
|                     part=part, | ||||
|                     template=template, | ||||
|                     data=f'{idx}{suffix}' | ||||
|                 ) | ||||
|             ) | ||||
|  | ||||
|         # Now, request parts, ordered by this parameter | ||||
|         url = reverse('api-part-list') | ||||
|  | ||||
|         response = self.get( | ||||
|             url, | ||||
|             { | ||||
|                 'ordering': 'parameter_{pk}'.format(pk=template.pk), | ||||
|                 'parameters': 'true', | ||||
|             }, | ||||
|             expected_code=200 | ||||
|         ) | ||||
|  | ||||
|         # All parts should be returned | ||||
|         self.assertEqual(len(response.data), len(parts)) | ||||
|  | ||||
|         # Check that the parts are ordered correctly (in increasing order) | ||||
|         expectation = { | ||||
|             0: '0mm', | ||||
|             1: '3mm', | ||||
|             7: '4m', | ||||
|             9: '8m', | ||||
|             -2: '13m', | ||||
|             -1: None, | ||||
|         } | ||||
|  | ||||
|         for idx, expected in expectation.items(): | ||||
|             actual = get_param_value(response, template.pk, idx) | ||||
|             self.assertEqual(actual, expected) | ||||
|  | ||||
|         # Next, check reverse ordering | ||||
|         response = self.get( | ||||
|             url, | ||||
|             { | ||||
|                 'ordering': '-parameter_{pk}'.format(pk=template.pk), | ||||
|                 'parameters': 'true', | ||||
|             }, | ||||
|             expected_code=200 | ||||
|         ) | ||||
|  | ||||
|         expectation = { | ||||
|             0: '13m', | ||||
|             1: '11m', | ||||
|             -3: '3mm', | ||||
|             -2: '0mm', | ||||
|             -1: None, | ||||
|         } | ||||
|  | ||||
|         for idx, expected in expectation.items(): | ||||
|             actual = get_param_value(response, template.pk, idx) | ||||
|             self.assertEqual(actual, expected) | ||||
|   | ||||
| @@ -314,7 +314,7 @@ class PluginsRegistry: | ||||
|                         handle_error(error, do_raise=False, log_name='discovery') | ||||
|  | ||||
|         # Log collected plugins | ||||
|         logger.info(f'Collected {len(collected_plugins)} plugins!') | ||||
|         logger.info(f'Collected {len(collected_plugins)} plugins') | ||||
|         logger.debug(", ".join([a.__module__ for a in collected_plugins])) | ||||
|  | ||||
|         return collected_plugins | ||||
|   | ||||
| @@ -203,12 +203,6 @@ def update_history(apps, schema_editor): | ||||
|         print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")  # pragma: no cover | ||||
|  | ||||
|  | ||||
| def reverse_update(apps, schema_editor): | ||||
|     """ | ||||
|     """ | ||||
|     pass  # pragma: no cover | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -216,5 +210,5 @@ class Migration(migrations.Migration): | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython(update_history, reverse_code=reverse_update) | ||||
|         migrations.RunPython(update_history, reverse_code=migrations.RunPython.noop) | ||||
|     ] | ||||
|   | ||||
| @@ -60,12 +60,6 @@ def extract_purchase_price(apps, schema_editor): | ||||
|     if update_count > 0:  # pragma: no cover | ||||
|         print(f"Updated pricing for {update_count} stock items") | ||||
|  | ||||
| def reverse_operation(apps, schema_editor):  # pragma: no cover | ||||
|     """ | ||||
|     DO NOTHING! | ||||
|     """ | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
| @@ -74,5 +68,5 @@ class Migration(migrations.Migration): | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython(extract_purchase_price, reverse_code=reverse_operation) | ||||
|         migrations.RunPython(extract_purchase_price, reverse_code=migrations.RunPython.noop) | ||||
|     ] | ||||
|   | ||||
| @@ -36,13 +36,6 @@ def update_serials(apps, schema_editor): | ||||
|         item.save() | ||||
|  | ||||
|  | ||||
| def nupdate_serials(apps, schema_editor):  # pragma: no cover | ||||
|     """ | ||||
|     Provided only for reverse migration compatibility | ||||
|     """ | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -52,6 +45,6 @@ class Migration(migrations.Migration): | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             update_serials, | ||||
|             reverse_code=nupdate_serials, | ||||
|             reverse_code=migrations.RunPython.noop, | ||||
|         ) | ||||
|     ] | ||||
|   | ||||
| @@ -35,10 +35,6 @@ def delete_scheduled(apps, schema_editor): | ||||
|     Task.objects.filter(func='stock.tasks.delete_old_stock_items').delete() | ||||
|  | ||||
|  | ||||
| def reverse(apps, schema_editor):  # pragma: no cover | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -48,6 +44,6 @@ class Migration(migrations.Migration): | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             delete_scheduled, | ||||
|             reverse_code=reverse, | ||||
|             reverse_code=migrations.RunPython.noop, | ||||
|         ) | ||||
|     ] | ||||
|   | ||||
| @@ -34,13 +34,6 @@ def update_pathstring(apps, schema_editor): | ||||
|         print(f"\n--- Updated 'pathstring' for {n} StockLocation objects ---\n") | ||||
|  | ||||
|  | ||||
| def nupdate_pathstring(apps, schema_editor): | ||||
|     """Empty function for reverse migration compatibility""" | ||||
|  | ||||
|     pass | ||||
|  | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -50,6 +43,6 @@ class Migration(migrations.Migration): | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             update_pathstring, | ||||
|             reverse_code=nupdate_pathstring | ||||
|             reverse_code=migrations.RunPython.noop | ||||
|         ) | ||||
|     ] | ||||
|   | ||||
| @@ -62,9 +62,6 @@ def fix_purchase_price(apps, schema_editor): | ||||
|         logger.info(f"Corrected purchase_price field for {n_updated} stock items.") | ||||
|  | ||||
|  | ||||
| def reverse(apps, schema_editor):  # pragmae: no cover | ||||
|     pass | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -74,6 +71,6 @@ class Migration(migrations.Migration): | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             fix_purchase_price, | ||||
|             reverse_code=reverse, | ||||
|             reverse_code=migrations.RunPython.noop, | ||||
|         ) | ||||
|     ] | ||||
|   | ||||
| @@ -52,11 +52,6 @@ def update_stock_history(apps, schema_editor): | ||||
|         print(f"Updated {n} StockItemTracking entries with SalesOrder data") | ||||
|  | ||||
|  | ||||
| def nope(apps, schema_editor): | ||||
|     """Provided for reverse migration compatibility""" | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -65,6 +60,6 @@ class Migration(migrations.Migration): | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             update_stock_history, reverse_code=nope, | ||||
|             update_stock_history, reverse_code=migrations.RunPython.noop, | ||||
|         ) | ||||
|     ] | ||||
|   | ||||
| @@ -55,19 +55,4 @@ | ||||
|     </tbody> | ||||
| </table> | ||||
|  | ||||
| <div class='panel-heading'> | ||||
|     <span class='d-flex flex-span'> | ||||
|         <h4>{% trans "Part Parameter Templates" %}</h4> | ||||
|         {% include "spacer.html" %} | ||||
|         <div class='btn-group' role='group'> | ||||
|             <button class='btn btn-success' id='new-param'> | ||||
|                 <span class='fas fa-plus-circle'></span> {% trans "New Parameter" %} | ||||
|             </button> | ||||
|         </div> | ||||
|     </span> | ||||
| </div> | ||||
|  | ||||
| <table class='table table-striped table-condensed' id='param-table' data-toolbar='#param-buttons'> | ||||
| </table> | ||||
|  | ||||
| {% endblock content %} | ||||
|   | ||||
							
								
								
									
										25
									
								
								InvenTree/templates/InvenTree/settings/part_parameters.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								InvenTree/templates/InvenTree/settings/part_parameters.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| {% extends "panel.html" %} | ||||
| {% load i18n %} | ||||
|  | ||||
| {% block label %}part-parameters{% endblock label %} | ||||
|  | ||||
| {% block heading %} | ||||
| {% trans "Part Parameter Templates" %} | ||||
| {% endblock heading %} | ||||
|  | ||||
| {% block actions %} | ||||
| <button class='btn btn-success' id='new-param'> | ||||
|     <span class='fas fa-plus-circle'></span> {% trans "New Parameter" %} | ||||
| </button> | ||||
| {% endblock actions %} | ||||
|  | ||||
| {% block content %} | ||||
| <div id='param-buttons'> | ||||
|     <div class='btn-group' role='group'> | ||||
|         {% include "filter_list.html" with id="parameter-templates" %} | ||||
|     </div> | ||||
| </div> | ||||
| <table class='table table-striped table-condensed' id='param-table' data-toolbar='#param-buttons'> | ||||
| </table> | ||||
|  | ||||
| {% endblock content %} | ||||
| @@ -36,6 +36,7 @@ | ||||
| {% include "InvenTree/settings/label.html" %} | ||||
| {% include "InvenTree/settings/report.html" %} | ||||
| {% include "InvenTree/settings/part.html" %} | ||||
| {% include "InvenTree/settings/part_parameters.html" %} | ||||
| {% include "InvenTree/settings/part_stocktake.html" %} | ||||
| {% include "InvenTree/settings/category.html" %} | ||||
| {% include "InvenTree/settings/pricing.html" %} | ||||
|   | ||||
| @@ -302,50 +302,10 @@ onPanelLoad('category', function() { | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| // Javascript for the Part settings panel | ||||
| onPanelLoad('parts', function() { | ||||
|     $("#param-table").inventreeTable({ | ||||
|         url: "{% url 'api-part-parameter-template-list' %}", | ||||
|         queryParams: { | ||||
|             ordering: 'name', | ||||
|         }, | ||||
|         formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; }, | ||||
|         columns: [ | ||||
|             { | ||||
|                 field: 'pk', | ||||
|                 title: '{% trans "ID" %}', | ||||
|                 visible: false, | ||||
|                 switchable: false, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'name', | ||||
|                 title: '{% trans "Name" %}', | ||||
|                 sortable: true, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'units', | ||||
|                 title: '{% trans "Units" %}', | ||||
|                 sortable: true, | ||||
|                 switchable: true, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'description', | ||||
|                 title: '{% trans "Description" %}', | ||||
|                 sortable: false, | ||||
|                 switchable: true, | ||||
|             }, | ||||
|             { | ||||
|                 formatter: function(value, row, index, field) { | ||||
|                     var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit icon-green'></span></button>"; | ||||
|                     var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>"; | ||||
| // Javascript for the Part parameters settings panel | ||||
| onPanelLoad('part-parameters', function() { | ||||
|  | ||||
|                     var html = "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>"; | ||||
|  | ||||
|                     return html; | ||||
|                 } | ||||
|             } | ||||
|         ] | ||||
|     }); | ||||
|     loadPartParameterTemplateTable("#param-table", {}); | ||||
|  | ||||
|     $("#new-param").click(function() { | ||||
|         constructForm('{% url "api-part-parameter-template-list" %}', { | ||||
| @@ -359,45 +319,10 @@ onPanelLoad('parts', function() { | ||||
|             refreshTable: '#param-table', | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
|     $("#param-table").on('click', '.template-edit', function() { | ||||
|         var button = $(this); | ||||
|         var pk = button.attr('pk'); | ||||
|  | ||||
|         constructForm( | ||||
|             `/api/part/parameter/template/${pk}/`, | ||||
|             { | ||||
|                 fields: { | ||||
|                     name: {}, | ||||
|                     units: {}, | ||||
|                     description: {}, | ||||
|                 }, | ||||
|                 title: '{% trans "Edit Part Parameter Template" %}', | ||||
|                 refreshTable: '#param-table', | ||||
|             } | ||||
|         ); | ||||
|     }); | ||||
|  | ||||
|     $("#param-table").on('click', '.template-delete', function() { | ||||
|         var button = $(this); | ||||
|         var pk = button.attr('pk'); | ||||
|  | ||||
|         var html = ` | ||||
|         <div class='alert alert-block alert-danger'> | ||||
|             {% trans "Any parameters which reference this template will also be deleted" %} | ||||
|         </div>`; | ||||
|  | ||||
|         constructForm( | ||||
|             `/api/part/parameter/template/${pk}/`, | ||||
|             { | ||||
|                 method: 'DELETE', | ||||
|                 preFormContent: html, | ||||
|                 title: '{% trans "Delete Part Parameter Template" %}', | ||||
|                 refreshTable: '#param-table', | ||||
|             } | ||||
|         ); | ||||
|     }); | ||||
|  | ||||
| // Javascript for the Part settings panel | ||||
| onPanelLoad('parts', function() { | ||||
|     $("#import-part").click(function() { | ||||
|         launchModalForm("{% url 'api-part-import' %}?reset", {}); | ||||
|     }); | ||||
|   | ||||
| @@ -44,6 +44,8 @@ | ||||
| {% include "sidebar_item.html" with label='category' text=text icon="fa-sitemap" %} | ||||
| {% trans "Parts" as text %} | ||||
| {% include "sidebar_item.html" with label='parts' text=text icon="fa-shapes" %} | ||||
| {% trans "Part Parameters" as text %} | ||||
| {% include "sidebar_item.html" with label='part-parameters' text=text icon="fa-th-list" %} | ||||
| {% trans "Stock" as text %} | ||||
| {% include "sidebar_item.html" with label='stock' text=text icon="fa-boxes" %} | ||||
| {% trans "Stocktake" as text %} | ||||
|   | ||||
| @@ -31,6 +31,7 @@ | ||||
|     loadParametricPartTable, | ||||
|     loadPartCategoryTable, | ||||
|     loadPartParameterTable, | ||||
|     loadPartParameterTemplateTable, | ||||
|     loadPartPurchaseOrderTable, | ||||
|     loadPartTable, | ||||
|     loadPartTestTemplateTable, | ||||
| @@ -1286,11 +1287,27 @@ function loadPartParameterTable(table, options) { | ||||
|                     return row.template_detail.name; | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'description', | ||||
|                 title: '{% trans "Description" %}', | ||||
|                 switchable: true, | ||||
|                 sortable: false, | ||||
|                 formatter: function(value, row) { | ||||
|                     return row.template_detail.description; | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'data', | ||||
|                 title: '{% trans "Value" %}', | ||||
|                 switchable: false, | ||||
|                 sortable: true, | ||||
|                 formatter: function(value, row) { | ||||
|                     if (row.data_numeric && row.template_detail.units) { | ||||
|                         return `<span title='${row.data_numeric} ${row.template_detail.units}'>${row.data}</span>`; | ||||
|                     } else { | ||||
|                         return row.data; | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'units', | ||||
| @@ -1345,6 +1362,107 @@ function loadPartParameterTable(table, options) { | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Construct a table showing a list of part parameter templates | ||||
|  */ | ||||
| function loadPartParameterTemplateTable(table, options={}) { | ||||
|  | ||||
|     let params = options.params || {}; | ||||
|  | ||||
|     params.ordering = 'name'; | ||||
|  | ||||
|     let filters = loadTableFilters('part-parameter-templates', params); | ||||
|  | ||||
|     let filterTarget = options.filterTarget || '#filter-list-parameter-templates'; | ||||
|  | ||||
|     setupFilterList('part-parameter-templates', $(table), filterTarget); | ||||
|  | ||||
|     $(table).inventreeTable({ | ||||
|         url: '{% url "api-part-parameter-template-list" %}', | ||||
|         original: params, | ||||
|         queryParams: filters, | ||||
|         name: 'part-parameter-templates', | ||||
|         formatNoMatches: function() { | ||||
|             return '{% trans "No part parameter templates found" %}'; | ||||
|         }, | ||||
|         columns: [ | ||||
|             { | ||||
|                 field: 'pk', | ||||
|                 title: '{% trans "ID" %}', | ||||
|                 visible: false, | ||||
|                 switchable: false, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'name', | ||||
|                 title: '{% trans "Name" %}', | ||||
|                 sortable: true, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'units', | ||||
|                 title: '{% trans "Units" %}', | ||||
|                 sortable: true, | ||||
|                 switchable: true, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'description', | ||||
|                 title: '{% trans "Description" %}', | ||||
|                 sortable: false, | ||||
|                 switchable: true, | ||||
|             }, | ||||
|             { | ||||
|                 formatter: function(value, row, index, field) { | ||||
|  | ||||
|                     let buttons = ''; | ||||
|  | ||||
|                     buttons += makeEditButton('template-edit', row.pk, '{% trans "Edit Template" %}'); | ||||
|                     buttons += makeDeleteButton('template-delete', row.pk, '{% trans "Delete Template" %}'); | ||||
|  | ||||
|                     return wrapButtons(buttons); | ||||
|                 } | ||||
|             } | ||||
|         ] | ||||
|     }); | ||||
|  | ||||
|     $(table).on('click', '.template-edit', function() { | ||||
|         var button = $(this); | ||||
|         var pk = button.attr('pk'); | ||||
|  | ||||
|         constructForm( | ||||
|             `/api/part/parameter/template/${pk}/`, | ||||
|             { | ||||
|                 fields: { | ||||
|                     name: {}, | ||||
|                     units: {}, | ||||
|                     description: {}, | ||||
|                 }, | ||||
|                 title: '{% trans "Edit Part Parameter Template" %}', | ||||
|                 refreshTable: table, | ||||
|             } | ||||
|         ); | ||||
|     }); | ||||
|  | ||||
|     $(table).on('click', '.template-delete', function() { | ||||
|         var button = $(this); | ||||
|         var pk = button.attr('pk'); | ||||
|  | ||||
|         var html = ` | ||||
|         <div class='alert alert-block alert-danger'> | ||||
|             {% trans "Any parameters which reference this template will also be deleted" %} | ||||
|         </div>`; | ||||
|  | ||||
|         constructForm( | ||||
|             `/api/part/parameter/template/${pk}/`, | ||||
|             { | ||||
|                 method: 'DELETE', | ||||
|                 preFormContent: html, | ||||
|                 title: '{% trans "Delete Part Parameter Template" %}', | ||||
|                 refreshTable: table, | ||||
|             } | ||||
|         ); | ||||
|     }); | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Construct a table showing a list of purchase orders for a given part. | ||||
|  * | ||||
| @@ -1663,6 +1781,12 @@ function loadRelatedPartsTable(table, part_id, options={}) { | ||||
|  */ | ||||
| function loadParametricPartTable(table, options={}) { | ||||
|  | ||||
|     options.params = options.params || {}; | ||||
|  | ||||
|     options.params['parameters'] = true; | ||||
|  | ||||
|     let filters = loadTableFilters('parameters', options.params); | ||||
|  | ||||
|     setupFilterList('parameters', $(table), '#filter-list-parameters'); | ||||
|  | ||||
|     var columns = [ | ||||
| @@ -1691,11 +1815,18 @@ function loadParametricPartTable(table, options={}) { | ||||
|             async: false, | ||||
|             success: function(response) { | ||||
|                 for (var template of response) { | ||||
|  | ||||
|                     let template_name = template.name; | ||||
|  | ||||
|                     if (template.units) { | ||||
|                         template_name += ` [${template.units}]`; | ||||
|                     } | ||||
|  | ||||
|                     columns.push({ | ||||
|                         field: `parameter_${template.pk}`, | ||||
|                         title: template.name, | ||||
|                         title: template_name, | ||||
|                         switchable: true, | ||||
|                         sortable: false, | ||||
|                         sortable: true, | ||||
|                         filterControl: 'input', | ||||
|                     }); | ||||
|                 } | ||||
| @@ -1703,20 +1834,21 @@ function loadParametricPartTable(table, options={}) { | ||||
|         } | ||||
|     ); | ||||
|  | ||||
|     // TODO: Re-enable filter control for parameter values | ||||
|  | ||||
|     $(table).inventreeTable({ | ||||
|         url: '{% url "api-part-list" %}', | ||||
|         queryParams: { | ||||
|             category: options.category, | ||||
|             cascade: true, | ||||
|             parameters: true, | ||||
|         }, | ||||
|         queryParams: filters, | ||||
|         original: options.params, | ||||
|         groupBy: false, | ||||
|         name: options.name || 'part-parameters', | ||||
|         formatNoMatches: function() { | ||||
|             return '{% trans "No parts found" %}'; | ||||
|         }, | ||||
|         // TODO: Re-enable filter control for parameter values | ||||
|         // Ref: https://github.com/inventree/InvenTree/issues/4851 | ||||
|         // filterControl: true, | ||||
|         // showFilterControlSwitch: true, | ||||
|         // sortSelectOptions: true, | ||||
|         columns: columns, | ||||
|         showColumns: true, | ||||
|         sidePagination: 'server', | ||||
| @@ -1751,8 +1883,8 @@ function loadParametricPartTable(table, options={}) { | ||||
| } | ||||
|  | ||||
|  | ||||
| // Generate a "grid tile" view for a particular part | ||||
| function partGridTile(part) { | ||||
|     // Generate a "grid tile" view for a particular part | ||||
|  | ||||
|     // Rows for table view | ||||
|     var rows = ''; | ||||
| @@ -1822,6 +1954,8 @@ function partGridTile(part) { | ||||
|  */ | ||||
| function loadPartTable(table, url, options={}) { | ||||
|  | ||||
|     options.params = options.params || {}; | ||||
|  | ||||
|     // Ensure category detail is included | ||||
|     options.params['category_detail'] = true; | ||||
|  | ||||
|   | ||||
| @@ -55,7 +55,6 @@ function renderStatusLabel(key, codes, options={}) { | ||||
|     return `<span class='${classes}'>${text}</span>`; | ||||
| } | ||||
|  | ||||
|  | ||||
| {% include "status_codes.html" with label='stock' data=StockStatus.list %} | ||||
| {% include "status_codes.html" with label='stockHistory' data=StockHistoryCode.list %} | ||||
| {% include "status_codes.html" with label='build' data=BuildStatus.list %} | ||||
|   | ||||
| @@ -700,6 +700,19 @@ function getCompanyFilters() { | ||||
| } | ||||
|  | ||||
|  | ||||
| // Return a dictionary of filters for the "part parameter template" table | ||||
| function getPartParameterTemplateFilters() { | ||||
|     return {}; | ||||
| } | ||||
|  | ||||
|  | ||||
| // Return a dictionary of filters for the "parameteric part" table | ||||
| function getParametricPartTableFilters() { | ||||
|     let filters = getPartTableFilters(); | ||||
|  | ||||
|     return filters; | ||||
| } | ||||
|  | ||||
|  | ||||
| // Return a dictionary of filters for a given table, based on the name of the table | ||||
| function getAvailableTableFilters(tableKey) { | ||||
| @@ -723,6 +736,10 @@ function getAvailableTableFilters(tableKey) { | ||||
|         return getBuildItemTableFilters(); | ||||
|     case 'location': | ||||
|         return getStockLocationFilters(); | ||||
|     case 'parameters': | ||||
|         return getParametricPartTableFilters(); | ||||
|     case 'part-parameter-templates': | ||||
|         return getPartParameterTemplateFilters(); | ||||
|     case 'parts': | ||||
|         return getPartTableFilters(); | ||||
|     case 'parttests': | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								docs/docs/assets/images/part/part_invalid_units.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/docs/assets/images/part/part_invalid_units.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 53 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/docs/assets/images/part/part_sort_by_param.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/docs/assets/images/part/part_sort_by_param.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 186 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/docs/assets/images/part/part_sorting_units.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/docs/assets/images/part/part_sorting_units.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 89 KiB | 
| @@ -44,6 +44,7 @@ InvenTree relies on the following Python libraries: | ||||
| | [coveralls](https://pypi.org/project/coveralls/) | MIT | coverage uploader | | ||||
| | [django-formtools](https://pypi.org/project/django-formtools/) | MIT | better forms / wizards | | ||||
| | [django-allauth](https://pypi.org/project/django-allauth/) | MIT | SSO for django | | ||||
| | [pint](https://pint.readthedocs.io/en/stable/) | [licence](https://github.com/hgrecco/pint/blob/master/LICENSE) | Physical unit conversion | | ||||
|  | ||||
| ## Frontend libraries | ||||
|  | ||||
|   | ||||
| @@ -4,26 +4,30 @@ title: Part Parameters | ||||
|  | ||||
| ## Part Parameters | ||||
|  | ||||
| A part *parameter* describes a particular "attribute" or "property" of a specific part. | ||||
|  | ||||
| Part parameters are located in the "Parameters" tab, on each part detail page. | ||||
| There is no limit for the number of part parameters and they are fully customizable through the use of parameters templates. | ||||
| There is no limit for the number of part parameters and they are fully customizable through the use of [parameters templates](#parameter-templates). | ||||
|  | ||||
| Here is an example of parameters for a capacitor: | ||||
| {% with id="part_parameters_example", url="part/part_parameters_example.png", description="Part Parameters Example List" %} | ||||
| {% include 'img.html' %} | ||||
| {% endwith %} | ||||
|  | ||||
| ### Create Template | ||||
| ## Parameter Templates | ||||
|  | ||||
| A *Parameter Template* is required for each part parameter. | ||||
| Parameter templates are used to define the different types of parameters which are available for use. These are edited via the [settings interface](../settings/global.md). | ||||
|  | ||||
| ### Create Template | ||||
|  | ||||
| To create a template: | ||||
|  | ||||
| - navigate to the "Settings" page | ||||
| - click on the "Parts" tab | ||||
| - scroll down to the "Part Parameter Templates" section | ||||
| - click on the "New Parameter" button | ||||
| - fill out the `Create Part Parameter Template` form: `Name` (required) and `Units` (optional) fields | ||||
| - finally click on the "Submit" button. | ||||
| - Navigate to the "Settings" page | ||||
| - Click on the "Parts" tab | ||||
| - Scroll down to the "Part Parameter Templates" section | ||||
| - Click on the "New Parameter" button | ||||
| - Fill out the `Create Part Parameter Template` form: `Name` (required) and `Units` (optional) fields | ||||
| - Click on the "Submit" button. | ||||
|  | ||||
| ### Create Parameter | ||||
|  | ||||
| @@ -37,9 +41,9 @@ To add a parameter, navigate to a specific part detail page, click on the "Param | ||||
|  | ||||
| Select the parameter `Template` you would like to use for this parameter, fill-out the `Data` field (value of this specific parameter) and click the "Submit" button. | ||||
|  | ||||
| ### Parametric Tables | ||||
| ## Parametric Tables | ||||
|  | ||||
| Parametric tables gather all parameters from all parts inside a category to be sorted and filtered. | ||||
| Parametric tables gather all parameters from all parts inside a particular [part category](./part.md#part-category) to be sorted and filtered. | ||||
|  | ||||
| To access a category's parametric table, click on the "Parameters" tab within the category view: | ||||
|  | ||||
| @@ -52,3 +56,36 @@ Below is an example of capacitor parametric table filtered with `Package Type = | ||||
| {% with id="parametric_table_example", url="part/parametric_table_example.png", description="Parametric Table Example" %} | ||||
| {% include 'img.html' %} | ||||
| {% endwith %} | ||||
|  | ||||
| ### Sorting by Parameter Value | ||||
|  | ||||
| The parametric parts table allows the returned parts to be sorted by particular parameter values. Click on the header of a particular parameter column to sort results by that parameter: | ||||
|  | ||||
| {% with id="sort_by_param", url="part/part_sort_by_param.png", description="Sort by Parameter" %} | ||||
| {% include 'img.html' %} | ||||
| {% endwith %} | ||||
|  | ||||
| ## Parameter Units | ||||
|  | ||||
| The *units* field (which is defined against a [parameter template](#parameter-templates)) defines the base unit of that template. Any parameters which are created against that unit *must* be specified in compatible units. Unit conversion is implemented using the [pint](https://pint.readthedocs.io/en/stable/) Python library. This conversion library is used to perform two main functions: | ||||
|  | ||||
| - Enforce use of compatible units when creating part parameters | ||||
| - Perform conversion to the base template unit | ||||
|  | ||||
| The in-built conversion functionality means that parameter values can be input in different dimensions - *as long as the dimension is compatible with the base template units*. | ||||
|  | ||||
| ### Incompatible Units | ||||
|  | ||||
| If a part parameter is created with a value which is incompatible with the units specified for the template, it will be rejected: | ||||
|  | ||||
| {% with id="invalid_units", url="part/part_invalid_units.png", description="Invalid Parameter Units" %} | ||||
| {% include 'img.html' %} | ||||
| {% endwith %} | ||||
|  | ||||
| ### Parameter Sorting | ||||
|  | ||||
| Parameter sorting takes unit conversion into account, meaning that values provided in different (but compatible) units are sorted correctly: | ||||
|  | ||||
| {% with id="sort_by_param_units", url="part/part_sorting_units.png", description="Sort by Parameter Units" %} | ||||
| {% include 'img.html' %} | ||||
| {% endwith %} | ||||
|   | ||||
| @@ -33,6 +33,7 @@ feedparser                              # RSS newsfeed parser | ||||
| gunicorn                                # Gunicorn web server | ||||
| pdf2image                               # PDF to image conversion | ||||
| pillow                                  # Image manipulation | ||||
| pint                                    # Unit conversion | ||||
| python-barcode[images]                  # Barcode generator | ||||
| qrcode[pil]                             # QR code generator | ||||
| rapidfuzz==0.7.6                        # Fuzzy string matching | ||||
|   | ||||
| @@ -186,6 +186,8 @@ pillow==9.5.0 | ||||
|     #   python-barcode | ||||
|     #   qrcode | ||||
|     #   weasyprint | ||||
| pint==0.21 | ||||
|     # via -r requirements.in | ||||
| py-moneyed==1.2 | ||||
|     # via | ||||
|     #   -r requirements.in | ||||
|   | ||||
		Reference in New Issue
	
	Block a user