From 3ea0be2ef4d9441bfa16fb6f0e3c9b975f90abe6 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 10 Nov 2025 11:06:01 +0000 Subject: [PATCH] Add definition for Parameter model --- .../migrations/0040_parametertemplate.py | 109 --------- .../0040_parametertemplate_parameter.py | 215 ++++++++++++++++++ .../migrations/0041_auto_20251028_1112.py | 80 ++++--- src/backend/InvenTree/common/models.py | 95 +++++++- 4 files changed, 350 insertions(+), 149 deletions(-) delete mode 100644 src/backend/InvenTree/common/migrations/0040_parametertemplate.py create mode 100644 src/backend/InvenTree/common/migrations/0040_parametertemplate_parameter.py diff --git a/src/backend/InvenTree/common/migrations/0040_parametertemplate.py b/src/backend/InvenTree/common/migrations/0040_parametertemplate.py deleted file mode 100644 index 99d8411c20..0000000000 --- a/src/backend/InvenTree/common/migrations/0040_parametertemplate.py +++ /dev/null @@ -1,109 +0,0 @@ -# Generated by Django 4.2.25 on 2025-10-28 11:11 - -import common.validators -import InvenTree.models -import InvenTree.validators -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("common", "0039_emailthread_emailmessage"), - ] - - operations = [ - migrations.CreateModel( - name="ParameterTemplate", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "metadata", - models.JSONField( - blank=True, - help_text="JSON metadata field, for use by external plugins", - null=True, - verbose_name="Plugin Metadata", - ), - ), - ( - "name", - models.CharField( - help_text="Parameter Name", - max_length=100, - unique=True, - verbose_name="Name", - ), - ), - ( - "units", - models.CharField( - blank=True, - help_text="Physical units for this parameter", - max_length=25, - validators=[InvenTree.validators.validate_physical_units], - verbose_name="Units", - ), - ), - ( - "description", - models.CharField( - blank=True, - help_text="Parameter description", - max_length=250, - verbose_name="Description", - ), - ), - ( - "model_type", - models.CharField( - blank=True, - default="", - help_text="Target model type for this parameter", - max_length=100, - validators=[common.validators.validate_parameter_template_model_type], - verbose_name="Model type", - ), - ), - ( - "checkbox", - models.BooleanField( - default=False, - help_text="Is this parameter a checkbox?", - verbose_name="Checkbox", - ), - ), - ( - "choices", - models.CharField( - blank=True, - help_text="Valid choices for this parameter (comma-separated)", - max_length=5000, - verbose_name="Choices", - ), - ), - ( - "selectionlist", - models.ForeignKey( - blank=True, - help_text="Selection list for this parameter", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="templates", - to="common.selectionlist", - verbose_name="Selection List", - ), - ), - ], - bases=(InvenTree.models.PluginValidationMixin, models.Model), - ), - ] diff --git a/src/backend/InvenTree/common/migrations/0040_parametertemplate_parameter.py b/src/backend/InvenTree/common/migrations/0040_parametertemplate_parameter.py new file mode 100644 index 0000000000..4ef37de772 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0040_parametertemplate_parameter.py @@ -0,0 +1,215 @@ +# Generated by Django 4.2.25 on 2025-11-10 10:51 + +import InvenTree.models +import InvenTree.validators +import common.validators +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("common", "0039_emailthread_emailmessage"), + ] + + operations = [ + migrations.CreateModel( + name="ParameterTemplate", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + help_text="JSON metadata field, for use by external plugins", + null=True, + verbose_name="Plugin Metadata", + ), + ), + ( + "model_type", + models.CharField( + blank=True, + default="", + help_text="Target model type for this parameter template", + max_length=100, + validators=[ + common.validators.validate_parameter_template_model_type + ], + verbose_name="Model type", + ), + ), + ( + "name", + models.CharField( + help_text="Parameter Name", + max_length=100, + unique=True, + verbose_name="Name", + ), + ), + ( + "units", + models.CharField( + blank=True, + help_text="Physical units for this parameter", + max_length=25, + validators=[InvenTree.validators.validate_physical_units], + verbose_name="Units", + ), + ), + ( + "description", + models.CharField( + blank=True, + help_text="Parameter description", + max_length=250, + verbose_name="Description", + ), + ), + ( + "checkbox", + models.BooleanField( + default=False, + help_text="Is this parameter a checkbox?", + verbose_name="Checkbox", + ), + ), + ( + "choices", + models.CharField( + blank=True, + help_text="Valid choices for this parameter (comma-separated)", + max_length=5000, + verbose_name="Choices", + ), + ), + ( + "selectionlist", + models.ForeignKey( + blank=True, + help_text="Selection list for this parameter", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="templates", + to="common.selectionlist", + verbose_name="Selection List", + ), + ), + ], + bases=(InvenTree.models.PluginValidationMixin, models.Model), + ), + migrations.CreateModel( + name="Parameter", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + help_text="JSON metadata field, for use by external plugins", + null=True, + verbose_name="Plugin Metadata", + ), + ), + ( + "updated", + models.DateTimeField( + blank=True, + default=None, + help_text="Timestamp of last update", + null=True, + verbose_name="Updated", + ), + ), + ( + "model_type", + models.CharField( + blank=True, + default="", + help_text="Target model type for this parameter", + max_length=100, + validators=[common.validators.validate_parameter_model_type], + verbose_name="Model type", + ), + ), + ( + "model_id", + models.PositiveIntegerField( + help_text="ID of the target model for this parameter", + verbose_name="Model ID", + ), + ), + ( + "data", + models.CharField( + help_text="Parameter Value", + max_length=500, + validators=[django.core.validators.MinLengthValidator(1)], + verbose_name="Data", + ), + ), + ( + "data_numeric", + models.FloatField(blank=True, default=None, null=True), + ), + ( + "note", + models.CharField( + blank=True, + help_text="Optional note field", + max_length=500, + verbose_name="Note", + ), + ), + ( + "template", + models.ForeignKey( + help_text="Parameter template", + on_delete=django.db.models.deletion.CASCADE, + related_name="parameters", + to="common.parametertemplate", + verbose_name="Template", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + help_text="User who last updated this object", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated", + to=settings.AUTH_USER_MODEL, + verbose_name="Update By", + ), + ), + ], + options={ + "verbose_name": "Parameter", + "verbose_name_plural": "Parameters", + "unique_together": {("model_type", "model_id", "template")}, + }, + bases=(InvenTree.models.PluginValidationMixin, models.Model), + ), + ] diff --git a/src/backend/InvenTree/common/migrations/0041_auto_20251028_1112.py b/src/backend/InvenTree/common/migrations/0041_auto_20251028_1112.py index 76693fd110..3b6b72afd8 100644 --- a/src/backend/InvenTree/common/migrations/0041_auto_20251028_1112.py +++ b/src/backend/InvenTree/common/migrations/0041_auto_20251028_1112.py @@ -3,60 +3,70 @@ from django.db import migrations -def copy_templates(old_model, new_model): - """Copy from one model type to another.""" - +def copy_part_parameters(apps, schema_editor): + """Forward migration: copy from PartParameterTemplate to ParameterTemplate.""" + PartParameterTemplate = apps.get_model("part", "PartParameterTemplate") + ParameterTemplate = apps.get_model("common", "ParameterTemplate") + + PartParameter = apps.get_model("part", "PartParameter") + Parameter = apps.get_model("common", "Parameter") + + # First, create a ParameterTemplate instance for each PartParameterTemplate templates = [] - # Clear out all existing instances - new_model.objects.all().delete() - - assert new_model.objects.count() == 0 - - for template in old_model.objects.all(): - templates.append(new_model( + for template in PartParameterTemplate.objects.all(): + templates.append(ParameterTemplate( name=template.name, description=template.description, units=template.units, checkbox=template.checkbox, choices=template.choices, selectionlist=template.selectionlist, + model_type='part' + )) + + if len(templates) > 0: + ParameterTemplate.objects.bulk_create(templates) + print(f"\nMigrated {len(templates)} PartParameterTemplate instances.") + + assert ParameterTemplate.objects.filter(model_type='part').count() == len(templates) + + # Next, copy PartParameter instances to Parameter instances + parameters = [] + + for parameter in PartParameter.objects.all(): + # Find the corresponding ParameterTemplate + template = ParameterTemplate.objects.get(name=parameter.template.name, model_type='part') + + parameters.append(Parameter( + template=template, + model_type='part', + model_id=parameter.part.id, + data=parameter.data, + data_numeric=parameter.data_numeric, + note=parameter.note, + updated=parameter.updated, + updated_by=parameter.updated_by )) + if len(parameters) > 0: + Parameter.objects.bulk_create(parameters) + print(f"\nMigrated {len(parameters)} PartParameter instances.") - if len(templates) > 0: - new_model.objects.bulk_create(templates) - print(f"Migrated {len(templates)} ParameterTemplate instances.") - - assert new_model.objects.count() == len(templates) - - -def forward_copy_templates(apps, schema_editor): - """Forward migration: copy from PartParameterTemplate to ParameterTemplate.""" - PartParameterTemplate = apps.get_model("part", "PartParameterTemplate") - ParameterTemplate = apps.get_model("common", "ParameterTemplate") - - copy_templates(PartParameterTemplate, ParameterTemplate) - - -def reverse_copy_templates(apps, schema_editor): - """Reverse migration: copy from ParameterTemplate to PartParameterTemplate.""" - ParameterTemplate = apps.get_model("common", "ParameterTemplate") - PartParameterTemplate = apps.get_model("part", "PartParameterTemplate") - - copy_templates(ParameterTemplate, PartParameterTemplate) + assert Parameter.objects.filter(model_type='part').count() == len(parameters) class Migration(migrations.Migration): dependencies = [ ("part", "0132_partparametertemplate_selectionlist"), - ("common", "0040_parametertemplate"), + ("common", "0040_parametertemplate_parameter"), ] operations = [ migrations.RunPython( - forward_copy_templates, - reverse_code=reverse_copy_templates - ) + copy_part_parameters, + reverse_code=migrations.RunPython.noop + ), + # TODO: Data migration for ManufacturerPartParameter ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 779fa6e5a8..f100a777bf 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -7,6 +7,7 @@ import base64 import hashlib import hmac import json +import math import os import uuid from datetime import timedelta, timezone @@ -28,7 +29,7 @@ from django.core.files.base import ContentFile from django.core.files.storage import default_storage from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail.utils import DNS_NAME -from django.core.validators import MinValueValidator +from django.core.validators import MinLengthValidator, MinValueValidator from django.db import models, transaction from django.db.models import enums from django.db.models.signals import post_delete, post_save @@ -48,6 +49,7 @@ from rest_framework.exceptions import PermissionDenied from taggit.managers import TaggableManager import common.validators +import InvenTree.conversion import InvenTree.fields import InvenTree.helpers import InvenTree.models @@ -2376,6 +2378,7 @@ class ParameterTemplate( Attributes: name: The name (key) of the template description: A description of the template + model_type: The type of model to which this template applies (e.g. 'part') units: The units associated with the template (if applicable) checkbox: Is this template a checkbox (boolean) type? choices: Comma-separated list of choices (if applicable) @@ -2478,7 +2481,7 @@ class ParameterTemplate( blank=True, validators=[common.validators.validate_parameter_template_model_type], verbose_name=_('Model type'), - help_text=_('Target model type for this parameter'), + help_text=_('Target model type for this parameter template'), ) name = models.CharField( @@ -2546,9 +2549,6 @@ class Parameter( class Meta: """Meta options for Parameter model.""" - # TODO: Make this non-abstract, actually implement... - abstract = True - verbose_name = _('Parameter') verbose_name_plural = _('Parameters') unique_together = [['model_type', 'model_id', 'template']] @@ -2574,6 +2574,11 @@ class Parameter( super().save(*args, **kwargs) + def delete(self): + """Perform custom delete checks before deleting a Parameter instance.""" + # TODO: Custom delete checks against the model type this is linked to... + super().delete() + def clean(self): """Validate the Parameter before saving to the database.""" super().clean() @@ -2589,6 +2594,86 @@ class Parameter( # TODO: Validate against plugins + 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: + self.data_numeric = InvenTree.conversion.convert_physical_value( + self.data, self.template.units + ) + 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 + + if self.data_numeric is not None and type(self.data_numeric) is float: + # Prevent out of range numbers, etc + # Ref: https://github.com/inventree/InvenTree/issues/7593 + if math.isnan(self.data_numeric) or math.isinf(self.data_numeric): + self.data_numeric = None + + model_type = models.CharField( + max_length=100, + default='', + blank=True, + validators=[common.validators.validate_parameter_model_type], + verbose_name=_('Model type'), + help_text=_('Target model type for this parameter'), + ) + + model_id = models.PositiveIntegerField( + verbose_name=_('Model ID'), + help_text=_('ID of the target model for this parameter'), + ) + + template = models.ForeignKey( + ParameterTemplate, + on_delete=models.CASCADE, + related_name='parameters', + 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) + + note = models.CharField( + max_length=500, + blank=True, + verbose_name=_('Note'), + help_text=_('Optional note field'), + ) + + @property + def units(self): + """Return the units associated with the template.""" + return self.template.units + + @property + def name(self): + """Return the name of the template.""" + return self.template.name + + @property + def description(self): + """Return the description of the template.""" + return self.template.description + class BarcodeScanResult(InvenTree.models.InvenTreeModel): """Model for storing barcode scans results."""