Browse Source

Closes #23337: Employ GeneratedField to store normalized unit values

Arthur 3 days ago
parent
commit
cbf133f373

+ 71 - 0
netbox/circuits/migrations/0061_generated_fields.py

@@ -0,0 +1,71 @@
+# Generated by Django 6.1.2 on 2026-10-13 23:27
+
+from decimal import Decimal
+
+import django.db.models.expressions
+from django.db import migrations, models
+
+
+# PostgreSQL cannot convert an existing column to a generated column in place, so each normalized
+# column is dropped and re-added. The database computes the values for all existing rows.
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("circuits", "0060_denormalization_triggers"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="circuit",
+            name="_abs_distance",
+        ),
+        migrations.AddField(
+            model_name="circuit",
+            name="_abs_distance",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        distance_unit="km",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("1000"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="m",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="cm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("0.01"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="mi",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("1609.344"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="ft",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("0.3048"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="in",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("0.0254"))
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+    ]

+ 25 - 0
netbox/circuits/tests/test_models.py

@@ -11,8 +11,11 @@ from core.models import ObjectChange
 from dcim.models import Location, Region, Site, SiteGroup
 from dcim.models import Location, Region, Site, SiteGroup
 from extras.choices import CustomFieldStatusChoices, CustomFieldTypeChoices
 from extras.choices import CustomFieldStatusChoices, CustomFieldTypeChoices
 from extras.models import CustomField
 from extras.models import CustomField
+from netbox.choices import DistanceUnitChoices
 from netbox.context_managers import event_tracking
 from netbox.context_managers import event_tracking
 from users.models import User
 from users.models import User
+from utilities.conversion import to_meters
+from utilities.testing.mixins import NormalizedMeasurementTestMixin
 
 
 
 
 class CircuitTerminationTestCase(TestCase):
 class CircuitTerminationTestCase(TestCase):
@@ -625,3 +628,25 @@ class CircuitTerminationChangeLoggingTestCase(TestCase):
         circuit.refresh_from_db()
         circuit.refresh_from_db()
         self.assertIsNone(circuit.termination_a_id)
         self.assertIsNone(circuit.termination_a_id)
         self.assertEqual(circuit.custom_field_data, {'probe_field': 'concurrent'})
         self.assertEqual(circuit.custom_field_data, {'probe_field': 'concurrent'})
+
+
+class CircuitDistanceTestCase(NormalizedMeasurementTestMixin, TestCase):
+    model = Circuit
+    value_field = 'distance'
+    unit_field = 'distance_unit'
+    normalized_field = '_abs_distance'
+    unit_choices = DistanceUnitChoices
+    converter = staticmethod(to_meters)
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        cls.circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
+
+    def create_instance(self, **attrs):
+        return Circuit(
+            provider=self.provider,
+            type=self.circuit_type,
+            cid=f'Circuit {next(self.sequence)}',
+            **attrs
+        )

+ 634 - 0
netbox/dcim/migrations/0252_generated_fields.py

@@ -0,0 +1,634 @@
+# Generated by Django 6.1.2 on 2026-10-13 23:27
+
+from decimal import Decimal
+
+import django.db.models.expressions
+import django.db.models.functions.comparison
+import django.db.models.functions.math
+from django.db import migrations, models
+
+
+# PostgreSQL cannot convert an existing column to a generated column in place, so each normalized
+# column is dropped and re-added. The database computes the values for all existing rows.
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("dcim", "0251_fix_ltree_cascade_triggers"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="cable",
+            name="_abs_length",
+        ),
+        migrations.AddField(
+            model_name="cable",
+            name="_abs_length",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        length_unit="km",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("length"), "*", models.Value(Decimal("1000"))
+                        ),
+                    ),
+                    models.When(
+                        length_unit="m",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("length"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        length_unit="cm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("length"), "*", models.Value(Decimal("0.01"))
+                        ),
+                    ),
+                    models.When(
+                        length_unit="mi",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("length"), "*", models.Value(Decimal("1609.344"))
+                        ),
+                    ),
+                    models.When(
+                        length_unit="ft",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("length"), "*", models.Value(Decimal("0.3048"))
+                        ),
+                    ),
+                    models.When(
+                        length_unit="in",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("length"), "*", models.Value(Decimal("0.0254"))
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=14, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="coolingfeed",
+            name="_abs_max_flow",
+        ),
+        migrations.AddField(
+            model_name="coolingfeed",
+            name="_abs_max_flow",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        max_flow_unit="lpm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        max_flow_unit="m3ph",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"),
+                            "*",
+                            models.Value(Decimal("16.66666666666666666666666667")),
+                        ),
+                    ),
+                    models.When(
+                        max_flow_unit="gpm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"),
+                            "*",
+                            models.Value(Decimal("3.785411784")),
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="coolingintake",
+            name="_abs_diameter",
+        ),
+        migrations.AddField(
+            model_name="coolingintake",
+            name="_abs_diameter",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        diameter_unit="mm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="cm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("10"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="in",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("25.4"))
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="coolingintake",
+            name="_abs_max_flow",
+        ),
+        migrations.AddField(
+            model_name="coolingintake",
+            name="_abs_max_flow",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        max_flow_unit="lpm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        max_flow_unit="m3ph",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"),
+                            "*",
+                            models.Value(Decimal("16.66666666666666666666666667")),
+                        ),
+                    ),
+                    models.When(
+                        max_flow_unit="gpm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"),
+                            "*",
+                            models.Value(Decimal("3.785411784")),
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="coolingintaketemplate",
+            name="_abs_diameter",
+        ),
+        migrations.AddField(
+            model_name="coolingintaketemplate",
+            name="_abs_diameter",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        diameter_unit="mm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="cm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("10"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="in",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("25.4"))
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="coolingintaketemplate",
+            name="_abs_max_flow",
+        ),
+        migrations.AddField(
+            model_name="coolingintaketemplate",
+            name="_abs_max_flow",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        max_flow_unit="lpm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        max_flow_unit="m3ph",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"),
+                            "*",
+                            models.Value(Decimal("16.66666666666666666666666667")),
+                        ),
+                    ),
+                    models.When(
+                        max_flow_unit="gpm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"),
+                            "*",
+                            models.Value(Decimal("3.785411784")),
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="coolingoutflow",
+            name="_abs_diameter",
+        ),
+        migrations.AddField(
+            model_name="coolingoutflow",
+            name="_abs_diameter",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        diameter_unit="mm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="cm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("10"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="in",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("25.4"))
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="coolingoutflowtemplate",
+            name="_abs_diameter",
+        ),
+        migrations.AddField(
+            model_name="coolingoutflowtemplate",
+            name="_abs_diameter",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        diameter_unit="mm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="cm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("10"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="in",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("25.4"))
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="devicetype",
+            name="_abs_weight",
+        ),
+        migrations.AddField(
+            model_name="devicetype",
+            name="_abs_weight",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=django.db.models.functions.comparison.Cast(
+                    django.db.models.functions.math.Floor(
+                        models.Case(
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("1000")),
+                                ),
+                                weight_unit="kg",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"), "*", models.Value(Decimal("1"))
+                                ),
+                                weight_unit="g",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("453.592")),
+                                ),
+                                weight_unit="lb",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("28.3495")),
+                                ),
+                                weight_unit="oz",
+                            ),
+                        )
+                    ),
+                    output_field=models.BigIntegerField(),
+                ),
+                null=True,
+                output_field=models.BigIntegerField(blank=True, null=True),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="moduletype",
+            name="_abs_weight",
+        ),
+        migrations.AddField(
+            model_name="moduletype",
+            name="_abs_weight",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=django.db.models.functions.comparison.Cast(
+                    django.db.models.functions.math.Floor(
+                        models.Case(
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("1000")),
+                                ),
+                                weight_unit="kg",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"), "*", models.Value(Decimal("1"))
+                                ),
+                                weight_unit="g",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("453.592")),
+                                ),
+                                weight_unit="lb",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("28.3495")),
+                                ),
+                                weight_unit="oz",
+                            ),
+                        )
+                    ),
+                    output_field=models.BigIntegerField(),
+                ),
+                null=True,
+                output_field=models.BigIntegerField(blank=True, null=True),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="rack",
+            name="_abs_max_weight",
+        ),
+        migrations.AddField(
+            model_name="rack",
+            name="_abs_max_weight",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=django.db.models.functions.comparison.Cast(
+                    django.db.models.functions.math.Floor(
+                        models.Case(
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("1000")),
+                                ),
+                                weight_unit="kg",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("1")),
+                                ),
+                                weight_unit="g",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("453.592")),
+                                ),
+                                weight_unit="lb",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("28.3495")),
+                                ),
+                                weight_unit="oz",
+                            ),
+                        )
+                    ),
+                    output_field=models.BigIntegerField(),
+                ),
+                null=True,
+                output_field=models.BigIntegerField(blank=True, null=True),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="rack",
+            name="_abs_weight",
+        ),
+        migrations.AddField(
+            model_name="rack",
+            name="_abs_weight",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=django.db.models.functions.comparison.Cast(
+                    django.db.models.functions.math.Floor(
+                        models.Case(
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("1000")),
+                                ),
+                                weight_unit="kg",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"), "*", models.Value(Decimal("1"))
+                                ),
+                                weight_unit="g",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("453.592")),
+                                ),
+                                weight_unit="lb",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("28.3495")),
+                                ),
+                                weight_unit="oz",
+                            ),
+                        )
+                    ),
+                    output_field=models.BigIntegerField(),
+                ),
+                null=True,
+                output_field=models.BigIntegerField(blank=True, null=True),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="racktype",
+            name="_abs_max_weight",
+        ),
+        migrations.AddField(
+            model_name="racktype",
+            name="_abs_max_weight",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=django.db.models.functions.comparison.Cast(
+                    django.db.models.functions.math.Floor(
+                        models.Case(
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("1000")),
+                                ),
+                                weight_unit="kg",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("1")),
+                                ),
+                                weight_unit="g",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("453.592")),
+                                ),
+                                weight_unit="lb",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("28.3495")),
+                                ),
+                                weight_unit="oz",
+                            ),
+                        )
+                    ),
+                    output_field=models.BigIntegerField(),
+                ),
+                null=True,
+                output_field=models.BigIntegerField(blank=True, null=True),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="racktype",
+            name="_abs_weight",
+        ),
+        migrations.AddField(
+            model_name="racktype",
+            name="_abs_weight",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=django.db.models.functions.comparison.Cast(
+                    django.db.models.functions.math.Floor(
+                        models.Case(
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("1000")),
+                                ),
+                                weight_unit="kg",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"), "*", models.Value(Decimal("1"))
+                                ),
+                                weight_unit="g",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("453.592")),
+                                ),
+                                weight_unit="lb",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("28.3495")),
+                                ),
+                                weight_unit="oz",
+                            ),
+                        )
+                    ),
+                    output_field=models.BigIntegerField(),
+                ),
+                null=True,
+                output_field=models.BigIntegerField(blank=True, null=True),
+            ),
+        ),
+    ]

+ 14 - 30
netbox/dcim/models/cables.py

@@ -22,7 +22,7 @@ from dcim.fields import PathField
 from dcim.utils import decompile_path_node, object_to_path_node, rebuild_cable_paths
 from dcim.utils import decompile_path_node, object_to_path_node, rebuild_cable_paths
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
 from netbox.models import ChangeLoggedModel, PrimaryModel
 from netbox.models import ChangeLoggedModel, PrimaryModel
-from utilities.conversion import to_meters
+from utilities.conversion import LENGTH_TO_METERS, normalized_unit_expression
 from utilities.data import normalize_update_fields
 from utilities.data import normalize_update_fields
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
 from utilities.fields import ColorField, GenericArrayForeignKey
 from utilities.fields import ColorField, GenericArrayForeignKey
@@ -150,12 +150,12 @@ class Cable(PrimaryModel):
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
-    # Stores the normalized length (in meters) for database ordering
-    _abs_length = models.DecimalField(
-        max_digits=14,
-        decimal_places=4,
-        blank=True,
-        null=True
+    # Normalized length (in meters), computed by the database for ordering and path tracing
+    _abs_length = models.GeneratedField(
+        expression=normalized_unit_expression('length', 'length_unit', LENGTH_TO_METERS),
+        output_field=models.DecimalField(max_digits=14, decimal_places=4, blank=True, null=True),
+        db_persist=True,
+        null=True,
     )
     )
     bundle = models.ForeignKey(
     bundle = models.ForeignKey(
         to='dcim.CableBundle',
         to='dcim.CableBundle',
@@ -353,33 +353,17 @@ class Cable(PrimaryModel):
         }
         }
         update_fields = normalize_update_fields(save_kwargs)
         update_fields = normalize_update_fields(save_kwargs)
 
 
-        length_written = update_fields is None or 'length' in update_fields
-        length_unit_written = update_fields is None or 'length_unit' in update_fields
-
-        if length_written or length_unit_written:
-            if length_written and length_unit_written:
-                stored = {}
+        # Clear length_unit if no length is defined. A save writing only the unit must check the stored length,
+        # reading from the database this save will write so a router cannot split the two.
+        if update_fields is None or 'length_unit' in update_fields:
+            if update_fields is None or 'length' in update_fields:
+                length = self.length
             else:
             else:
-                # Read from the database this save will write, so a router cannot split the two
                 db = using or router.db_for_write(Cable, instance=self)
                 db = using or router.db_for_write(Cable, instance=self)
-                stored = Cable.objects.using(db).filter(pk=self.pk).values('length', 'length_unit').first() or {}
-            length = self.length if length_written else stored.get('length')
-            length_unit = self.length_unit if length_unit_written else stored.get('length_unit')
-
-            # Clear length_unit if no length is defined
-            if length is None and length_unit_written:
+                length = Cable.objects.using(db).filter(pk=self.pk).values_list('length', flat=True).first()
+            if length is None:
                 self.length_unit = None
                 self.length_unit = None
 
 
-            # Store the given length (if any) in meters for use in database ordering
-            if length is not None and length_unit:
-                self._abs_length = to_meters(length, length_unit)
-            else:
-                self._abs_length = None
-
-            # _abs_length is a denormalized cache of length and length_unit, so persist them together
-            if update_fields is not None:
-                save_kwargs['update_fields'] = update_fields | {'_abs_length'}
-
         # A field counts as changed only when this save actually writes it
         # A field counts as changed only when this save actually writes it
         status_written = update_fields is None or 'status' in update_fields
         status_written = update_fields is None or 'status' in update_fields
         profile_written = update_fields is None or 'profile' in update_fields
         profile_written = update_fields is None or 'profile' in update_fields

+ 0 - 5
netbox/dcim/models/device_component_templates.py

@@ -473,9 +473,6 @@ class CoolingIntakeTemplate(DiameterMixin, MaxFlowMixin, ModularComponentTemplat
             max_flow_unit=self.max_flow_unit,
             max_flow_unit=self.max_flow_unit,
             **kwargs
             **kwargs
         )
         )
-        # bulk_create bypasses save(), so populate the normalized _abs_* fields here
-        component.normalize_diameter()
-        component.normalize_max_flow()
         return component
         return component
     instantiate.do_not_call_in_templates = True
     instantiate.do_not_call_in_templates = True
 
 
@@ -551,8 +548,6 @@ class CoolingOutflowTemplate(DiameterMixin, ModularComponentTemplateModel):
             cooling_intake=cooling_intake,
             cooling_intake=cooling_intake,
             **kwargs
             **kwargs
         )
         )
-        # bulk_create bypasses save(), so populate the normalized _abs_diameter here
-        component.normalize_diameter()
         return component
         return component
     instantiate.do_not_call_in_templates = True
     instantiate.do_not_call_in_templates = True
 
 

+ 17 - 26
netbox/dcim/models/mixins.py

@@ -11,8 +11,9 @@ from dcim.choices import InterfaceTypeChoices
 from dcim.constants import NONCONNECTABLE_IFACE_TYPES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
 from dcim.constants import NONCONNECTABLE_IFACE_TYPES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
 from netbox.choices import *
 from netbox.choices import *
 from utilities.conversion import (
 from utilities.conversion import (
-    to_liters_per_minute,
-    to_millimeters,
+    DIAMETER_TO_MILLIMETERS,
+    FLOW_RATE_TO_LITERS_PER_MINUTE,
+    normalized_unit_expression,
 )
 )
 from utilities.data import normalize_update_fields
 from utilities.data import normalize_update_fields
 
 
@@ -406,12 +407,12 @@ class DiameterMixin(models.Model):
         blank=True,
         blank=True,
         null=True,
         null=True,
     )
     )
-    # Stores the normalized diameter (in millimeters) for database ordering
-    _abs_diameter = models.DecimalField(
-        max_digits=13,
-        decimal_places=4,
-        blank=True,
-        null=True
+    # Normalized diameter (in millimeters), computed by the database for ordering
+    _abs_diameter = models.GeneratedField(
+        expression=normalized_unit_expression('diameter', 'diameter_unit', DIAMETER_TO_MILLIMETERS),
+        output_field=models.DecimalField(max_digits=13, decimal_places=4, blank=True, null=True),
+        db_persist=True,
+        null=True,
     )
     )
 
 
     class Meta:
     class Meta:
@@ -424,13 +425,8 @@ class DiameterMixin(models.Model):
 
 
     def normalize_diameter(self):
     def normalize_diameter(self):
         """
         """
-        Store the given diameter (if any) in millimeters for use in database ordering. Called by save(), and
-        directly by component instantiation, which bypasses save() via bulk_create().
+        Clear diameter_unit if no diameter is defined.
         """
         """
-        if self.diameter is not None and self.diameter_unit:
-            self._abs_diameter = to_millimeters(self.diameter, self.diameter_unit)
-        else:
-            self._abs_diameter = None
         if self.diameter is None:
         if self.diameter is None:
             self.diameter_unit = None
             self.diameter_unit = None
     normalize_diameter.alters_data = True
     normalize_diameter.alters_data = True
@@ -468,12 +464,12 @@ class MaxFlowMixin(models.Model):
         blank=True,
         blank=True,
         null=True,
         null=True,
     )
     )
-    # Stores the normalized max flow (in liters per minute) for database ordering
-    _abs_max_flow = models.DecimalField(
-        max_digits=13,
-        decimal_places=4,
-        blank=True,
-        null=True
+    # Normalized max flow (in liters per minute), computed by the database for ordering
+    _abs_max_flow = models.GeneratedField(
+        expression=normalized_unit_expression('max_flow', 'max_flow_unit', FLOW_RATE_TO_LITERS_PER_MINUTE),
+        output_field=models.DecimalField(max_digits=13, decimal_places=4, blank=True, null=True),
+        db_persist=True,
+        null=True,
     )
     )
 
 
     class Meta:
     class Meta:
@@ -486,13 +482,8 @@ class MaxFlowMixin(models.Model):
 
 
     def normalize_max_flow(self):
     def normalize_max_flow(self):
         """
         """
-        Store the given max flow (if any) in liters per minute for use in database ordering. Called by save(),
-        and directly by component instantiation, which bypasses save() via bulk_create().
+        Clear max_flow_unit if no max flow is defined.
         """
         """
-        if self.max_flow is not None and self.max_flow_unit:
-            self._abs_max_flow = to_liters_per_minute(self.max_flow, self.max_flow_unit)
-        else:
-            self._abs_max_flow = None
         if self.max_flow is None:
         if self.max_flow is None:
             self.max_flow_unit = None
             self.max_flow_unit = None
     normalize_max_flow.alters_data = True
     normalize_max_flow.alters_data = True

+ 7 - 17
netbox/dcim/models/racks.py

@@ -17,7 +17,7 @@ from netbox.choices import ColorChoices
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.mixins import WeightMixin
 from netbox.models.mixins import WeightMixin
-from utilities.conversion import to_grams
+from utilities.conversion import WEIGHT_TO_GRAMS, normalized_unit_expression
 from utilities.data import array_to_string, drange
 from utilities.data import array_to_string, drange
 from utilities.fields import ColorField, CounterCacheField
 from utilities.fields import ColorField, CounterCacheField
 from utilities.tracking import TrackingModelMixin
 from utilities.tracking import TrackingModelMixin
@@ -129,10 +129,12 @@ class RackBase(WeightMixin, PrimaryModel):
         null=True,
         null=True,
         help_text=_('Maximum load capacity for the rack')
         help_text=_('Maximum load capacity for the rack')
     )
     )
-    # Stores the normalized max weight (in grams) for database ordering
-    _abs_max_weight = models.PositiveBigIntegerField(
-        blank=True,
-        null=True
+    # Normalized max weight (in grams), computed by the database for ordering
+    _abs_max_weight = models.GeneratedField(
+        expression=normalized_unit_expression('max_weight', 'weight_unit', WEIGHT_TO_GRAMS, integer=True),
+        output_field=models.BigIntegerField(blank=True, null=True),
+        db_persist=True,
+        null=True,
     )
     )
 
 
     # Cooling
     # Cooling
@@ -236,12 +238,6 @@ class RackType(ImageAttachmentsMixin, RackBase):
             raise ValidationError(_("Must specify a unit when setting a maximum weight"))
             raise ValidationError(_("Must specify a unit when setting a maximum weight"))
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
-        # Store the given max weight (if any) in grams for use in database ordering
-        if self.max_weight and self.weight_unit:
-            self._abs_max_weight = to_grams(self.max_weight, self.weight_unit)
-        else:
-            self._abs_max_weight = None
-
         # Clear unit if outer width & depth are not set
         # Clear unit if outer width & depth are not set
         if not any([self.outer_width, self.outer_depth, self.outer_height]):
         if not any([self.outer_width, self.outer_depth, self.outer_height]):
             self.outer_unit = None
             self.outer_unit = None
@@ -475,12 +471,6 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         self.copy_racktype_attrs()
         self.copy_racktype_attrs()
 
 
-        # Store the given max weight (if any) in grams for use in database ordering
-        if self.max_weight and self.weight_unit:
-            self._abs_max_weight = to_grams(self.max_weight, self.weight_unit)
-        else:
-            self._abs_max_weight = None
-
         # Clear unit if outer width & depth are not set
         # Clear unit if outer width & depth are not set
         if not any([self.outer_width, self.outer_depth, self.outer_height]):
         if not any([self.outer_width, self.outer_depth, self.outer_height]):
             self.outer_unit = None
             self.outer_unit = None

+ 8 - 4
netbox/dcim/tests/test_models.py

@@ -3313,9 +3313,10 @@ class CableTestCase(TestCase):
         self.assertIsNone(cable.length_unit)
         self.assertIsNone(cable.length_unit)
         self.assertIsNone(cable._abs_length)
         self.assertIsNone(cable._abs_length)
 
 
-    def test_partial_save_reads_the_stored_pair_only_for_an_excluded_field(self):
+    def test_partial_save_reads_the_stored_length_only_for_an_unwritten_length(self):
         """
         """
-        A save writing both source fields must normalize without reading the row back.
+        Only a save writing length_unit without length must read the row back, to decide whether to clear the
+        unit; the database computes the normalized length itself.
         """
         """
         cable = Cable.objects.first()
         cable = Cable.objects.first()
         cable.length = Decimal('1')
         cable.length = Decimal('1')
@@ -3324,14 +3325,17 @@ class CableTestCase(TestCase):
 
 
         with CaptureQueriesContext(connection) as both_written:
         with CaptureQueriesContext(connection) as both_written:
             cable.save(update_fields=['length', 'length_unit'])
             cable.save(update_fields=['length', 'length_unit'])
-        with CaptureQueriesContext(connection) as one_written:
+        with CaptureQueriesContext(connection) as length_written:
             cable.save(update_fields=['length'])
             cable.save(update_fields=['length'])
+        with CaptureQueriesContext(connection) as unit_written:
+            cable.save(update_fields=['length_unit'])
 
 
         def cable_reads(queries):
         def cable_reads(queries):
             return [q for q in queries if q['sql'].startswith('SELECT') and 'FROM "dcim_cable"' in q['sql']]
             return [q for q in queries if q['sql'].startswith('SELECT') and 'FROM "dcim_cable"' in q['sql']]
 
 
         self.assertEqual(cable_reads(both_written), [])
         self.assertEqual(cable_reads(both_written), [])
-        self.assertEqual(len(cable_reads(one_written)), 1)
+        self.assertEqual(cable_reads(length_written), [])
+        self.assertEqual(len(cable_reads(unit_written)), 1)
 
 
 
 
 class CableTerminationTestCase(TestCase):
 class CableTerminationTestCase(TestCase):

+ 13 - 27
netbox/netbox/models/mixins.py

@@ -3,7 +3,7 @@ from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from netbox.choices import *
 from netbox.choices import *
-from utilities.conversion import to_grams, to_meters
+from utilities.conversion import LENGTH_TO_METERS, WEIGHT_TO_GRAMS, normalized_unit_expression
 
 
 __all__ = (
 __all__ = (
     'DistanceMixin',
     'DistanceMixin',
@@ -43,10 +43,12 @@ class WeightMixin(models.Model):
         blank=True,
         blank=True,
         null=True,
         null=True,
     )
     )
-    # Stores the normalized weight (in grams) for database ordering
-    _abs_weight = models.PositiveBigIntegerField(
-        blank=True,
-        null=True
+    # Normalized weight (in grams), computed by the database for ordering
+    _abs_weight = models.GeneratedField(
+        expression=normalized_unit_expression('weight', 'weight_unit', WEIGHT_TO_GRAMS, integer=True),
+        output_field=models.BigIntegerField(blank=True, null=True),
+        db_persist=True,
+        null=True,
     )
     )
 
 
     class Meta:
     class Meta:
@@ -57,16 +59,6 @@ class WeightMixin(models.Model):
         # Public alias for _abs_weight; Django templates cannot access underscore-prefixed attributes.
         # Public alias for _abs_weight; Django templates cannot access underscore-prefixed attributes.
         return self._abs_weight
         return self._abs_weight
 
 
-    def save(self, *args, **kwargs):
-
-        # Store the given weight (if any) in grams for use in database ordering
-        if self.weight and self.weight_unit:
-            self._abs_weight = to_grams(self.weight, self.weight_unit)
-        else:
-            self._abs_weight = None
-
-        super().save(*args, **kwargs)
-
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
@@ -90,12 +82,12 @@ class DistanceMixin(models.Model):
         blank=True,
         blank=True,
         null=True,
         null=True,
     )
     )
-    # Stores the normalized distance (in meters) for database ordering
-    _abs_distance = models.DecimalField(
-        max_digits=13,
-        decimal_places=4,
-        blank=True,
-        null=True
+    # Normalized distance (in meters), computed by the database for ordering
+    _abs_distance = models.GeneratedField(
+        expression=normalized_unit_expression('distance', 'distance_unit', LENGTH_TO_METERS),
+        output_field=models.DecimalField(max_digits=13, decimal_places=4, blank=True, null=True),
+        db_persist=True,
+        null=True,
     )
     )
 
 
     class Meta:
     class Meta:
@@ -107,12 +99,6 @@ class DistanceMixin(models.Model):
         return self._abs_distance
         return self._abs_distance
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
-        # Store the given distance (if any) in meters for use in database ordering
-        if self.distance is not None and self.distance_unit:
-            self._abs_distance = to_meters(self.distance, self.distance_unit)
-        else:
-            self._abs_distance = None
-
         # Clear distance_unit if no distance is defined
         # Clear distance_unit if no distance is defined
         if self.distance is None:
         if self.distance is None:
             self.distance_unit = None
             self.distance_unit = None

+ 78 - 31
netbox/utilities/conversion.py

@@ -1,5 +1,7 @@
 from decimal import Decimal, InvalidOperation
 from decimal import Decimal, InvalidOperation
 
 
+from django.db.models import BigIntegerField, Case, F, Value, When
+from django.db.models.functions import Cast, Floor
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from dcim.choices import CableLengthUnitChoices
 from dcim.choices import CableLengthUnitChoices
@@ -10,6 +12,11 @@ from netbox.choices import (
 )
 )
 
 
 __all__ = (
 __all__ = (
+    'DIAMETER_TO_MILLIMETERS',
+    'FLOW_RATE_TO_LITERS_PER_MINUTE',
+    'LENGTH_TO_METERS',
+    'WEIGHT_TO_GRAMS',
+    'normalized_unit_expression',
     'to_grams',
     'to_grams',
     'to_liters_per_minute',
     'to_liters_per_minute',
     'to_meters',
     'to_meters',
@@ -17,12 +24,72 @@ __all__ = (
 )
 )
 
 
 
 
-def _normalized_measurement(value, unit, converters, quantity, precision=4) -> Decimal:
+#
+# Conversion factors
+#
+
+# Each mapping gives the exact multiplier from a unit to its base unit. The to_*() helpers and the GeneratedField
+# expressions built by normalized_unit_expression() both read these, so changing a factor (or adding a unit) requires
+# a migration to drop and re-add the affected generated columns.
+WEIGHT_TO_GRAMS = {
+    WeightUnitChoices.UNIT_KILOGRAM: Decimal('1000'),
+    WeightUnitChoices.UNIT_GRAM: Decimal('1'),
+    WeightUnitChoices.UNIT_POUND: Decimal('453.592'),
+    WeightUnitChoices.UNIT_OUNCE: Decimal('28.3495'),
+}
+
+# Shared by cable lengths and distances; DistanceUnitChoices is a subset of CableLengthUnitChoices
+LENGTH_TO_METERS = {
+    CableLengthUnitChoices.UNIT_KILOMETER: Decimal('1000'),
+    CableLengthUnitChoices.UNIT_METER: Decimal('1'),
+    CableLengthUnitChoices.UNIT_CENTIMETER: Decimal('0.01'),
+    CableLengthUnitChoices.UNIT_MILE: Decimal('1609.344'),
+    CableLengthUnitChoices.UNIT_FOOT: Decimal('0.3048'),
+    CableLengthUnitChoices.UNIT_INCH: Decimal('0.0254'),
+}
+
+DIAMETER_TO_MILLIMETERS = {
+    DiameterUnitChoices.UNIT_MILLIMETER: Decimal('1'),
+    DiameterUnitChoices.UNIT_CENTIMETER: Decimal('10'),
+    DiameterUnitChoices.UNIT_INCH: Decimal('25.4'),
+}
+
+FLOW_RATE_TO_LITERS_PER_MINUTE = {
+    FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE: Decimal('1'),
+    FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR: Decimal('1000') / Decimal('60'),
+    FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE: Decimal('3.785411784'),
+}
+
+
+#
+# Database expressions
+#
+
+def normalized_unit_expression(value_field, unit_field, factors, integer=False):
+    """
+    Return a database expression converting `value_field` to its base unit according to the value of `unit_field`,
+    per the given mapping of unit to factor. Evaluates to NULL if either field is NULL or the unit is unrecognized.
+    With `integer=True`, the result is truncated to a whole number (as to_grams() does) and cast to a bigint.
+    """
+    expression = Case(*[
+        When(**{unit_field: unit}, then=F(value_field) * Value(factor))
+        for unit, factor in factors.items()
+    ])
+    if integer:
+        expression = Cast(Floor(expression), output_field=BigIntegerField())
+    return expression
+
+
+#
+# Python conversions
+#
+
+def _normalized_measurement(value, unit, factors, quantity, precision=4) -> Decimal:
     """
     """
     Shared implementation for the unit-normalization helpers below. Coerce `value` to a non-negative
     Shared implementation for the unit-normalization helpers below. Coerce `value` to a non-negative
-    Decimal, apply the matching per-unit converter, and round the result to `precision` decimal places.
-    `converters` maps each valid unit to a callable receiving the Decimal value; `quantity` labels the
-    value in error messages. Pass `precision=None` to return the unrounded result.
+    Decimal, multiply by the factor for `unit`, and round the result to `precision` decimal places.
+    `factors` maps each valid unit to its multiplier; `quantity` labels the value in error messages.
+    Pass `precision=None` to return the unrounded result.
     """
     """
     try:
     try:
         value = Decimal(value)
         value = Decimal(value)
@@ -32,14 +99,14 @@ def _normalized_measurement(value, unit, converters, quantity, precision=4) -> D
         )
         )
     if value < 0:
     if value < 0:
         raise ValueError(_("Invalid value for {quantity}: must be a positive number").format(quantity=quantity))
         raise ValueError(_("Invalid value for {quantity}: must be a positive number").format(quantity=quantity))
-    if unit not in converters:
+    if unit not in factors:
         raise ValueError(
         raise ValueError(
             _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
             _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
                 unit=unit,
                 unit=unit,
-                valid_units=', '.join(converters)
+                valid_units=', '.join(factors)
             )
             )
         )
         )
-    result = converters[unit](value)
+    result = value * factors[unit]
     return result if precision is None else round(result, precision)
     return result if precision is None else round(result, precision)
 
 
 
 
@@ -49,45 +116,25 @@ def to_grams(weight, unit) -> int:
     """
     """
     # Rounding is suppressed so that the result is truncated exactly as the caller's int() expects; rounding
     # Rounding is suppressed so that the result is truncated exactly as the caller's int() expects; rounding
     # first could nudge a value across an integer boundary.
     # first could nudge a value across an integer boundary.
-    return int(_normalized_measurement(weight, unit, {
-        WeightUnitChoices.UNIT_KILOGRAM: lambda v: v * 1000,
-        WeightUnitChoices.UNIT_GRAM: lambda v: v,
-        WeightUnitChoices.UNIT_POUND: lambda v: v * Decimal(453.592),
-        WeightUnitChoices.UNIT_OUNCE: lambda v: v * Decimal(28.3495),
-    }, _('weight'), precision=None))
+    return int(_normalized_measurement(weight, unit, WEIGHT_TO_GRAMS, _('weight'), precision=None))
 
 
 
 
 def to_meters(length, unit) -> Decimal:
 def to_meters(length, unit) -> Decimal:
     """
     """
     Convert the given length to meters, returning a Decimal value.
     Convert the given length to meters, returning a Decimal value.
     """
     """
-    return _normalized_measurement(length, unit, {
-        CableLengthUnitChoices.UNIT_KILOMETER: lambda v: v * 1000,
-        CableLengthUnitChoices.UNIT_METER: lambda v: v,
-        CableLengthUnitChoices.UNIT_CENTIMETER: lambda v: v / 100,
-        CableLengthUnitChoices.UNIT_MILE: lambda v: v * Decimal(1609.344),
-        CableLengthUnitChoices.UNIT_FOOT: lambda v: v * Decimal(0.3048),
-        CableLengthUnitChoices.UNIT_INCH: lambda v: v * Decimal(0.0254),
-    }, _('length'))
+    return _normalized_measurement(length, unit, LENGTH_TO_METERS, _('length'))
 
 
 
 
 def to_millimeters(diameter, unit) -> Decimal:
 def to_millimeters(diameter, unit) -> Decimal:
     """
     """
     Convert the given diameter to millimeters, returning a Decimal value.
     Convert the given diameter to millimeters, returning a Decimal value.
     """
     """
-    return _normalized_measurement(diameter, unit, {
-        DiameterUnitChoices.UNIT_MILLIMETER: lambda v: v,
-        DiameterUnitChoices.UNIT_CENTIMETER: lambda v: v * 10,
-        DiameterUnitChoices.UNIT_INCH: lambda v: v * Decimal('25.4'),
-    }, _('diameter'))
+    return _normalized_measurement(diameter, unit, DIAMETER_TO_MILLIMETERS, _('diameter'))
 
 
 
 
 def to_liters_per_minute(flow_rate, unit) -> Decimal:
 def to_liters_per_minute(flow_rate, unit) -> Decimal:
     """
     """
     Convert the given flow rate to liters per minute, returning a Decimal value.
     Convert the given flow rate to liters per minute, returning a Decimal value.
     """
     """
-    return _normalized_measurement(flow_rate, unit, {
-        FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE: lambda v: v,
-        FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR: lambda v: v * Decimal(1000) / Decimal(60),
-        FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE: lambda v: v * Decimal('3.785411784'),
-    }, _('flow rate'))
+    return _normalized_measurement(flow_rate, unit, FLOW_RATE_TO_LITERS_PER_MINUTE, _('flow rate'))

+ 93 - 0
netbox/utilities/testing/mixins.py

@@ -1,9 +1,13 @@
+import itertools
+from decimal import Decimal
+
 from django.test.testcases import SerializeMixin
 from django.test.testcases import SerializeMixin
 from django_rq import get_queue
 from django_rq import get_queue
 from django_rq.workers import get_worker
 from django_rq.workers import get_worker
 from rq import SimpleWorker
 from rq import SimpleWorker
 
 
 __all__ = (
 __all__ = (
+    'NormalizedMeasurementTestMixin',
     'RQQueueTestMixin',
     'RQQueueTestMixin',
 )
 )
 
 
@@ -54,3 +58,92 @@ class RQQueueTestMixin(SerializeMixin):
             self.clear_rq_queues()
             self.clear_rq_queues()
         finally:
         finally:
             super().tearDown()
             super().tearDown()
+
+
+class NormalizedMeasurementTestMixin:
+    """
+    Exercise a database-generated column that normalizes a measurement to its base unit. The column is checked
+    against the matching to_*() helper for every unit choice, across each write path: save(), QuerySet.update(),
+    bulk_update(), and bulk_create(). Subclasses set the attributes below and implement create_instance().
+    """
+    model = None
+    value_field = None       # e.g. 'distance'
+    unit_field = None        # e.g. 'distance_unit'
+    normalized_field = None  # e.g. '_abs_distance'
+    unit_choices = None      # e.g. DistanceUnitChoices
+    converter = None         # e.g. staticmethod(to_meters)
+    value = Decimal('123.45')
+
+    def setUp(self):
+        super().setUp()
+        self.sequence = itertools.count(1)
+
+    def create_instance(self, **attrs):
+        """
+        Return an unsaved instance of the model with the given attributes set. Each call must yield an instance
+        which can be saved alongside those from previous calls; next(self.sequence) supplies a unique number.
+        """
+        raise NotImplementedError
+
+    @property
+    def units(self):
+        return self.unit_choices.values()
+
+    def assertNormalized(self, instance, value, unit):
+        """
+        Assert that the stored normalized value matches the converter's result for the given measurement.
+        """
+        expected = self.converter(value, unit) if value is not None and unit else None
+        instance.refresh_from_db()
+        self.assertEqual(getattr(instance, self.normalized_field), expected)
+
+    def test_save(self):
+        for unit in self.units:
+            with self.subTest(unit=unit):
+                instance = self.create_instance(**{self.value_field: self.value, self.unit_field: unit})
+                instance.save()
+                # The database returns the computed value on save, so it is available without a refresh
+                self.assertEqual(getattr(instance, self.normalized_field), self.converter(self.value, unit))
+                self.assertNormalized(instance, self.value, unit)
+
+    def test_update(self):
+        instance = self.create_instance()
+        instance.save()
+        for unit in self.units:
+            with self.subTest(unit=unit):
+                self.model.objects.filter(pk=instance.pk).update(**{
+                    self.value_field: self.value,
+                    self.unit_field: unit,
+                })
+                self.assertNormalized(instance, self.value, unit)
+
+    def test_bulk_update(self):
+        instance = self.create_instance()
+        instance.save()
+        for unit in self.units:
+            with self.subTest(unit=unit):
+                setattr(instance, self.value_field, self.value)
+                setattr(instance, self.unit_field, unit)
+                self.model.objects.bulk_update([instance], [self.value_field, self.unit_field])
+                self.assertNormalized(instance, self.value, unit)
+
+    def test_bulk_create(self):
+        instances = self.model.objects.bulk_create([
+            self.create_instance(**{self.value_field: self.value, self.unit_field: unit})
+            for unit in self.units
+        ])
+        for instance, unit in zip(instances, self.units):
+            with self.subTest(unit=unit):
+                self.assertNormalized(instance, self.value, unit)
+
+    def test_null_without_value_or_unit(self):
+        unit = self.units[0]
+        instance = self.create_instance(**{self.value_field: self.value, self.unit_field: unit})
+        instance.save()
+        queryset = self.model.objects.filter(pk=instance.pk)
+
+        queryset.update(**{self.value_field: None})
+        self.assertNormalized(instance, None, unit)
+
+        queryset.update(**{self.value_field: self.value, self.unit_field: None})
+        self.assertNormalized(instance, self.value, None)

+ 22 - 0
netbox/utilities/tests/test_conversions.py

@@ -3,10 +3,15 @@ from decimal import Decimal
 from dcim.choices import CableLengthUnitChoices
 from dcim.choices import CableLengthUnitChoices
 from netbox.choices import (
 from netbox.choices import (
     DiameterUnitChoices,
     DiameterUnitChoices,
+    DistanceUnitChoices,
     FlowRateUnitChoices,
     FlowRateUnitChoices,
     WeightUnitChoices,
     WeightUnitChoices,
 )
 )
 from utilities.conversion import (
 from utilities.conversion import (
+    DIAMETER_TO_MILLIMETERS,
+    FLOW_RATE_TO_LITERS_PER_MINUTE,
+    LENGTH_TO_METERS,
+    WEIGHT_TO_GRAMS,
     to_grams,
     to_grams,
     to_liters_per_minute,
     to_liters_per_minute,
     to_meters,
     to_meters,
@@ -17,6 +22,23 @@ from utilities.testing.base import TestCase
 
 
 class ConversionsTestCase(TestCase):
 class ConversionsTestCase(TestCase):
 
 
+    def test_factors_cover_all_units(self):
+        """
+        Every unit choice must have a conversion factor, as the factors drive both the to_*() helpers and the
+        generated columns which normalize measurements.
+        """
+        for factors, choices in (
+            (WEIGHT_TO_GRAMS, WeightUnitChoices),
+            (LENGTH_TO_METERS, CableLengthUnitChoices),
+            (LENGTH_TO_METERS, DistanceUnitChoices),
+            (DIAMETER_TO_MILLIMETERS, DiameterUnitChoices),
+            (FLOW_RATE_TO_LITERS_PER_MINUTE, FlowRateUnitChoices),
+        ):
+            with self.subTest(choices=choices.__name__):
+                self.assertTrue(set(choices.values()) <= set(factors))
+                for factor in factors.values():
+                    self.assertIsInstance(factor, Decimal)
+
     def test_to_grams(self):
     def test_to_grams(self):
         self.assertEqual(
         self.assertEqual(
             to_grams(1, WeightUnitChoices.UNIT_KILOGRAM),
             to_grams(1, WeightUnitChoices.UNIT_KILOGRAM),

+ 71 - 0
netbox/wireless/migrations/0025_generated_fields.py

@@ -0,0 +1,71 @@
+# Generated by Django 6.1.2 on 2026-10-13 23:27
+
+from decimal import Decimal
+
+import django.db.models.expressions
+from django.db import migrations, models
+
+
+# PostgreSQL cannot convert an existing column to a generated column in place, so each normalized
+# column is dropped and re-added. The database computes the values for all existing rows.
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("wireless", "0024_fix_ltree_cascade_triggers"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="wirelesslink",
+            name="_abs_distance",
+        ),
+        migrations.AddField(
+            model_name="wirelesslink",
+            name="_abs_distance",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        distance_unit="km",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("1000"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="m",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="cm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("0.01"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="mi",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("1609.344"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="ft",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("0.3048"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="in",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("0.0254"))
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+    ]

+ 26 - 0
netbox/wireless/tests/test_models.py

@@ -6,9 +6,12 @@ from django.test import RequestFactory, TestCase
 from core.models import ObjectChange
 from core.models import ObjectChange
 from dcim.choices import InterfaceTypeChoices
 from dcim.choices import InterfaceTypeChoices
 from dcim.models import Interface, Region, Site, SiteGroup
 from dcim.models import Interface, Region, Site, SiteGroup
+from netbox.choices import DistanceUnitChoices
 from netbox.context_managers import event_tracking
 from netbox.context_managers import event_tracking
 from users.models import User
 from users.models import User
+from utilities.conversion import to_meters
 from utilities.testing import create_test_device
 from utilities.testing import create_test_device
+from utilities.testing.mixins import NormalizedMeasurementTestMixin
 from wireless.models import WirelessLAN, WirelessLink
 from wireless.models import WirelessLAN, WirelessLink
 
 
 
 
@@ -106,3 +109,26 @@ class WirelessLinkTestCase(TestCase):
                 changed_object_id__in=(interface_a.pk, interface_b.pk),
                 changed_object_id__in=(interface_a.pk, interface_b.pk),
             ).exists()
             ).exists()
         )
         )
+
+
+class WirelessLinkDistanceTestCase(NormalizedMeasurementTestMixin, TestCase):
+    model = WirelessLink
+    value_field = 'distance'
+    unit_field = 'distance_unit'
+    normalized_field = '_abs_distance'
+    unit_choices = DistanceUnitChoices
+    converter = staticmethod(to_meters)
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.device = create_test_device('Device 1')
+
+    def create_instance(self, **attrs):
+        index = next(self.sequence)
+        interface_a = Interface.objects.create(
+            device=self.device, name=f'Wireless {index}A', type=InterfaceTypeChoices.TYPE_80211A
+        )
+        interface_b = Interface.objects.create(
+            device=self.device, name=f'Wireless {index}B', type=InterfaceTypeChoices.TYPE_80211A
+        )
+        return WirelessLink(interface_a=interface_a, interface_b=interface_b, **attrs)