diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 8e8855a837..9e22618957 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/InvenTree/InvenTree/conversion.py b/InvenTree/InvenTree/conversion.py new file mode 100644 index 0000000000..29d9816950 --- /dev/null +++ b/InvenTree/InvenTree/conversion.py @@ -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 diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 5cca13f86b..cac997df90 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -319,7 +319,6 @@ main { .filter-input { display: inline-block; *display: inline; - zoom: 1; } .filter-tag:hover { diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 2677a3da5c..bfa5a6a38c 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -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 diff --git a/InvenTree/InvenTree/validators.py b/InvenTree/InvenTree/validators.py index 0b8c263be2..1d38891da4 100644 --- a/InvenTree/InvenTree/validators.py +++ b/InvenTree/InvenTree/validators.py @@ -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.""" diff --git a/InvenTree/build/migrations/0013_auto_20200425_0507.py b/InvenTree/build/migrations/0013_auto_20200425_0507.py index 988bdcc9b8..4468b9aca5 100644 --- a/InvenTree/build/migrations/0013_auto_20200425_0507.py +++ b/InvenTree/build/migrations/0013_auto_20200425_0507.py @@ -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), ] diff --git a/InvenTree/build/migrations/0018_build_reference.py b/InvenTree/build/migrations/0018_build_reference.py index 5a6e489496..8fd790b753 100644 --- a/InvenTree/build/migrations/0018_build_reference.py +++ b/InvenTree/build/migrations/0018_build_reference.py @@ -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! diff --git a/InvenTree/build/migrations/0029_auto_20210601_1525.py b/InvenTree/build/migrations/0029_auto_20210601_1525.py index 5470416dcd..68b1f098e1 100644 --- a/InvenTree/build/migrations/0029_auto_20210601_1525.py +++ b/InvenTree/build/migrations/0029_auto_20210601_1525.py @@ -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), ] diff --git a/InvenTree/build/migrations/0032_auto_20211014_0632.py b/InvenTree/build/migrations/0032_auto_20211014_0632.py index 1ae56af4e6..52ce1d5f7d 100644 --- a/InvenTree/build/migrations/0032_auto_20211014_0632.py +++ b/InvenTree/build/migrations/0032_auto_20211014_0632.py @@ -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 ) ] diff --git a/InvenTree/build/migrations/0036_auto_20220707_1101.py b/InvenTree/build/migrations/0036_auto_20220707_1101.py index 3fbe72cdaf..bf52780791 100644 --- a/InvenTree/build/migrations/0036_auto_20220707_1101.py +++ b/InvenTree/build/migrations/0036_auto_20220707_1101.py @@ -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, ) ] diff --git a/InvenTree/order/migrations/0052_auto_20211014_0631.py b/InvenTree/order/migrations/0052_auto_20211014_0631.py index 36e2d882ac..9f6589bc3f 100644 --- a/InvenTree/order/migrations/0052_auto_20211014_0631.py +++ b/InvenTree/order/migrations/0052_auto_20211014_0631.py @@ -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) ] diff --git a/InvenTree/order/migrations/0058_auto_20211126_1210.py b/InvenTree/order/migrations/0058_auto_20211126_1210.py index 6ba2430af9..1377a9a0b9 100644 --- a/InvenTree/order/migrations/0058_auto_20211126_1210.py +++ b/InvenTree/order/migrations/0058_auto_20211126_1210.py @@ -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 ) ] diff --git a/InvenTree/order/migrations/0074_auto_20220709_0108.py b/InvenTree/order/migrations/0074_auto_20220709_0108.py index 8c7a546274..4d8e5014c8 100644 --- a/InvenTree/order/migrations/0074_auto_20220709_0108.py +++ b/InvenTree/order/migrations/0074_auto_20220709_0108.py @@ -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, ) ] diff --git a/InvenTree/order/migrations/0079_auto_20230304_0904.py b/InvenTree/order/migrations/0079_auto_20230304_0904.py index 85eb9f2b62..0d1dd8041c 100644 --- a/InvenTree/order/migrations/0079_auto_20230304_0904.py +++ b/InvenTree/order/migrations/0079_auto_20230304_0904.py @@ -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, ) ] diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index dbe312a120..fb221b0724 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -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, ] diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index c2afd23fbe..b9a390d9d5 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -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_' where 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): diff --git a/InvenTree/part/filters.py b/InvenTree/part/filters.py index 026328f816..8b690f56cd 100644 --- a/InvenTree/part/filters.py +++ b/InvenTree/part/filters.py @@ -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', + ) diff --git a/InvenTree/part/migrations/0039_auto_20200515_1127.py b/InvenTree/part/migrations/0039_auto_20200515_1127.py index 9f95f46e85..ec97498148 100644 --- a/InvenTree/part/migrations/0039_auto_20200515_1127.py +++ b/InvenTree/part/migrations/0039_auto_20200515_1127.py @@ -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) ] diff --git a/InvenTree/part/migrations/0083_auto_20220731_2357.py b/InvenTree/part/migrations/0083_auto_20220731_2357.py index d332a8e7de..06980347e9 100644 --- a/InvenTree/part/migrations/0083_auto_20220731_2357.py +++ b/InvenTree/part/migrations/0083_auto_20220731_2357.py @@ -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 ) ] diff --git a/InvenTree/part/migrations/0102_auto_20230314_0112.py b/InvenTree/part/migrations/0102_auto_20230314_0112.py index f0fb735193..220e14c1e0 100644 --- a/InvenTree/part/migrations/0102_auto_20230314_0112.py +++ b/InvenTree/part/migrations/0102_auto_20230314_0112.py @@ -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 ) ] diff --git a/InvenTree/part/migrations/0108_auto_20230516_1334.py b/InvenTree/part/migrations/0108_auto_20230516_1334.py new file mode 100644 index 0000000000..1bbef6aa1b --- /dev/null +++ b/InvenTree/part/migrations/0108_auto_20230516_1334.py @@ -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'), + ), + ] diff --git a/InvenTree/part/migrations/0109_auto_20230517_1048.py b/InvenTree/part/migrations/0109_auto_20230517_1048.py new file mode 100644 index 0000000000..7f5c551830 --- /dev/null +++ b/InvenTree/part/migrations/0109_auto_20230517_1048.py @@ -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 + ) + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index a9fd88fbee..4e0accdfdf 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -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 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): diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 4aee89f050..9d7b76275e 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -240,7 +240,8 @@ class PartParameterSerializer(InvenTreeModelSerializer): 'part', 'template', 'template_detail', - 'data' + 'data', + 'data_numeric', ] def __init__(self, *args, **kwargs): diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py index 5e3d29d067..e089852906 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -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}'") diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 2c36111310..70c11b0dd6 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -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', diff --git a/InvenTree/part/templates/part/category_sidebar.html b/InvenTree/part/templates/part/category_sidebar.html index 34c2c7bea9..898ddb81d8 100644 --- a/InvenTree/part/templates/part/category_sidebar.html +++ b/InvenTree/part/templates/part/category_sidebar.html @@ -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 %} diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index d26bbd1374..e2bfbb99ec 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -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""" diff --git a/InvenTree/part/test_migrations.py b/InvenTree/part/test_migrations.py index a20c14fa3e..cf51d67bee 100644 --- a/InvenTree/part/test_migrations.py +++ b/InvenTree/part/test_migrations.py @@ -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) diff --git a/InvenTree/part/test_param.py b/InvenTree/part/test_param.py index 7844503d94..88161b221d 100644 --- a/InvenTree/part/test_param.py +++ b/InvenTree/part/test_param.py @@ -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) diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 70ea74a189..83e6f3bdae 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -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 diff --git a/InvenTree/stock/migrations/0061_auto_20210511_0911.py b/InvenTree/stock/migrations/0061_auto_20210511_0911.py index b788d29eae..e26079cb6c 100644 --- a/InvenTree/stock/migrations/0061_auto_20210511_0911.py +++ b/InvenTree/stock/migrations/0061_auto_20210511_0911.py @@ -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) ] diff --git a/InvenTree/stock/migrations/0064_auto_20210621_1724.py b/InvenTree/stock/migrations/0064_auto_20210621_1724.py index f54fa6da83..32ccba7d51 100644 --- a/InvenTree/stock/migrations/0064_auto_20210621_1724.py +++ b/InvenTree/stock/migrations/0064_auto_20210621_1724.py @@ -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) ] diff --git a/InvenTree/stock/migrations/0069_auto_20211109_2347.py b/InvenTree/stock/migrations/0069_auto_20211109_2347.py index 2691b6305e..7399bc7963 100644 --- a/InvenTree/stock/migrations/0069_auto_20211109_2347.py +++ b/InvenTree/stock/migrations/0069_auto_20211109_2347.py @@ -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, ) ] diff --git a/InvenTree/stock/migrations/0071_auto_20211205_1733.py b/InvenTree/stock/migrations/0071_auto_20211205_1733.py index 91a8c65163..a7e6259125 100644 --- a/InvenTree/stock/migrations/0071_auto_20211205_1733.py +++ b/InvenTree/stock/migrations/0071_auto_20211205_1733.py @@ -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, ) ] diff --git a/InvenTree/stock/migrations/0081_auto_20220801_0044.py b/InvenTree/stock/migrations/0081_auto_20220801_0044.py index 361a7bc5e1..b755deb393 100644 --- a/InvenTree/stock/migrations/0081_auto_20220801_0044.py +++ b/InvenTree/stock/migrations/0081_auto_20220801_0044.py @@ -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 ) ] diff --git a/InvenTree/stock/migrations/0094_auto_20230220_0025.py b/InvenTree/stock/migrations/0094_auto_20230220_0025.py index f83a21b5e9..9d4de5a438 100644 --- a/InvenTree/stock/migrations/0094_auto_20230220_0025.py +++ b/InvenTree/stock/migrations/0094_auto_20230220_0025.py @@ -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, ) ] diff --git a/InvenTree/stock/migrations/0096_auto_20230330_1121.py b/InvenTree/stock/migrations/0096_auto_20230330_1121.py index d8e3c1ce53..42a81a2865 100644 --- a/InvenTree/stock/migrations/0096_auto_20230330_1121.py +++ b/InvenTree/stock/migrations/0096_auto_20230330_1121.py @@ -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, ) ] diff --git a/InvenTree/templates/InvenTree/settings/part.html b/InvenTree/templates/InvenTree/settings/part.html index f79c508d92..00d606c5b9 100644 --- a/InvenTree/templates/InvenTree/settings/part.html +++ b/InvenTree/templates/InvenTree/settings/part.html @@ -55,19 +55,4 @@ -
- -

{% trans "Part Parameter Templates" %}

- {% include "spacer.html" %} -
- -
-
-
- - -
- {% endblock content %} diff --git a/InvenTree/templates/InvenTree/settings/part_parameters.html b/InvenTree/templates/InvenTree/settings/part_parameters.html new file mode 100644 index 0000000000..d2b15edf87 --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/part_parameters.html @@ -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 %} + +{% endblock actions %} + +{% block content %} +
+
+ {% include "filter_list.html" with id="parameter-templates" %} +
+
+ +
+ +{% endblock content %} diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index b6e8e59eb3..14e918a6f0 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -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" %} diff --git a/InvenTree/templates/InvenTree/settings/settings_staff_js.html b/InvenTree/templates/InvenTree/settings/settings_staff_js.html index 2c3a984ccb..98d13c5a38 100644 --- a/InvenTree/templates/InvenTree/settings/settings_staff_js.html +++ b/InvenTree/templates/InvenTree/settings/settings_staff_js.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 = ""; - var bDel = ""; +// Javascript for the Part parameters settings panel +onPanelLoad('part-parameters', function() { - var html = "
" + bEdit + bDel + "
"; - - 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 = ` -
- {% trans "Any parameters which reference this template will also be deleted" %} -
`; - - 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", {}); }); diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html index 9db6f6620f..177a4e0a9b 100644 --- a/InvenTree/templates/InvenTree/settings/sidebar.html +++ b/InvenTree/templates/InvenTree/settings/sidebar.html @@ -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 %} diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 0898c0c591..9c629caf62 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -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 `${row.data}`; + } 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 = ` +
+ {% trans "Any parameters which reference this template will also be deleted" %} +
`; + + 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; diff --git a/InvenTree/templates/js/translated/status_codes.js b/InvenTree/templates/js/translated/status_codes.js index 3b4c38e6df..8702944730 100644 --- a/InvenTree/templates/js/translated/status_codes.js +++ b/InvenTree/templates/js/translated/status_codes.js @@ -55,7 +55,6 @@ function renderStatusLabel(key, codes, options={}) { return `${text}`; } - {% 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 %} diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index c501baa091..f9b925bd0f 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -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': diff --git a/docs/docs/assets/images/part/part_invalid_units.png b/docs/docs/assets/images/part/part_invalid_units.png new file mode 100644 index 0000000000..014fe4b93e Binary files /dev/null and b/docs/docs/assets/images/part/part_invalid_units.png differ diff --git a/docs/docs/assets/images/part/part_sort_by_param.png b/docs/docs/assets/images/part/part_sort_by_param.png new file mode 100644 index 0000000000..7f89cdc719 Binary files /dev/null and b/docs/docs/assets/images/part/part_sort_by_param.png differ diff --git a/docs/docs/assets/images/part/part_sorting_units.png b/docs/docs/assets/images/part/part_sorting_units.png new file mode 100644 index 0000000000..1564a9732a Binary files /dev/null and b/docs/docs/assets/images/part/part_sorting_units.png differ diff --git a/docs/docs/credits.md b/docs/docs/credits.md index f24a29a32e..312856ef68 100644 --- a/docs/docs/credits.md +++ b/docs/docs/credits.md @@ -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 diff --git a/docs/docs/part/parameter.md b/docs/docs/part/parameter.md index 8b60af024d..bffa321843 100644 --- a/docs/docs/part/parameter.md +++ b/docs/docs/part/parameter.md @@ -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 %} diff --git a/requirements.in b/requirements.in index 1853bac647..1a27bb6b6e 100644 --- a/requirements.in +++ b/requirements.in @@ -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 diff --git a/requirements.txt b/requirements.txt index 2b8149a19f..8204d3b0e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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