Arthur 2 giorni fa
parent
commit
b85afe5788

+ 3 - 8
netbox/circuits/migrations/0061_generated_fields.py

@@ -7,6 +7,8 @@ import django.db.models.expressions
 import django.db.models.functions.comparison
 import django.db.models.functions.comparison
 from django.db import migrations, models
 from django.db import migrations, models
 
 
+import utilities.conversion
+
 
 
 # PostgreSQL cannot convert an existing column to a generated column in place, so each normalized
 # 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.
 # column is dropped and re-added. The database computes the values for all existing rows.
@@ -24,7 +26,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="circuit",
             model_name="circuit",
             name="_abs_distance",
             name="_abs_distance",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=models.Case(
                 expression=models.Case(
                     models.When(
                     models.When(
@@ -105,11 +107,4 @@ class Migration(migrations.Migration):
                 validators=[django.core.validators.MinValueValidator(0)],
                 validators=[django.core.validators.MinValueValidator(0)],
             ),
             ),
         ),
         ),
-        migrations.AddConstraint(
-            model_name="circuit",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_distance__gte", 0)),
-                name="circuits_circuit_abs_distance_non_negative",
-            ),
-        ),
     ]
     ]

+ 0 - 2
netbox/circuits/models/circuits.py

@@ -19,7 +19,6 @@ from netbox.models.features import (
     TagsMixin,
     TagsMixin,
 )
 )
 from netbox.models.mixins import DistanceMixin
 from netbox.models.mixins import DistanceMixin
-from utilities.conversion import non_negative_constraint
 from utilities.data import normalize_update_fields
 from utilities.data import normalize_update_fields
 from utilities.string import title
 from utilities.string import title
 
 
@@ -148,7 +147,6 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel)
                 fields=('provider_account', 'cid'),
                 fields=('provider_account', 'cid'),
                 name='%(app_label)s_%(class)s_unique_provideraccount_cid'
                 name='%(app_label)s_%(class)s_unique_provideraccount_cid'
             ),
             ),
-            non_negative_constraint('_abs_distance'),
         )
         )
         indexes = (
         indexes = (
             models.Index(fields=('provider', 'provider_account', 'cid')),  # Default ordering
             models.Index(fields=('provider', 'provider_account', 'cid')),  # Default ordering

+ 16 - 112
netbox/dcim/migrations/0252_generated_fields.py

@@ -8,6 +8,8 @@ import django.db.models.functions.comparison
 import django.db.models.functions.math
 import django.db.models.functions.math
 from django.db import migrations, models
 from django.db import migrations, models
 
 
+import utilities.conversion
+
 
 
 # PostgreSQL cannot convert an existing column to a generated column in place, so each normalized
 # 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.
 # column is dropped and re-added. The database computes the values for all existing rows.
@@ -25,7 +27,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="cable",
             model_name="cable",
             name="_abs_length",
             name="_abs_length",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=models.Case(
                 expression=models.Case(
                     models.When(
                     models.When(
@@ -102,7 +104,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="coolingfeed",
             model_name="coolingfeed",
             name="_abs_max_flow",
             name="_abs_max_flow",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=models.Case(
                 expression=models.Case(
                     models.When(
                     models.When(
@@ -149,7 +151,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="coolingintake",
             model_name="coolingintake",
             name="_abs_diameter",
             name="_abs_diameter",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=models.Case(
                 expression=models.Case(
                     models.When(
                     models.When(
@@ -196,7 +198,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="coolingintake",
             model_name="coolingintake",
             name="_abs_max_flow",
             name="_abs_max_flow",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=models.Case(
                 expression=models.Case(
                     models.When(
                     models.When(
@@ -243,7 +245,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="coolingintaketemplate",
             model_name="coolingintaketemplate",
             name="_abs_diameter",
             name="_abs_diameter",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=models.Case(
                 expression=models.Case(
                     models.When(
                     models.When(
@@ -290,7 +292,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="coolingintaketemplate",
             model_name="coolingintaketemplate",
             name="_abs_max_flow",
             name="_abs_max_flow",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=models.Case(
                 expression=models.Case(
                     models.When(
                     models.When(
@@ -337,7 +339,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="coolingoutflow",
             model_name="coolingoutflow",
             name="_abs_diameter",
             name="_abs_diameter",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=models.Case(
                 expression=models.Case(
                     models.When(
                     models.When(
@@ -384,7 +386,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="coolingoutflowtemplate",
             model_name="coolingoutflowtemplate",
             name="_abs_diameter",
             name="_abs_diameter",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=models.Case(
                 expression=models.Case(
                     models.When(
                     models.When(
@@ -431,7 +433,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="devicetype",
             model_name="devicetype",
             name="_abs_weight",
             name="_abs_weight",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=django.db.models.functions.comparison.Cast(
                 expression=django.db.models.functions.comparison.Cast(
                     django.db.models.functions.math.Floor(
                     django.db.models.functions.math.Floor(
@@ -495,7 +497,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="moduletype",
             model_name="moduletype",
             name="_abs_weight",
             name="_abs_weight",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=django.db.models.functions.comparison.Cast(
                 expression=django.db.models.functions.comparison.Cast(
                     django.db.models.functions.math.Floor(
                     django.db.models.functions.math.Floor(
@@ -559,7 +561,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="rack",
             model_name="rack",
             name="_abs_max_weight",
             name="_abs_max_weight",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=django.db.models.functions.comparison.Cast(
                 expression=django.db.models.functions.comparison.Cast(
                     django.db.models.functions.math.Floor(
                     django.db.models.functions.math.Floor(
@@ -623,7 +625,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="rack",
             model_name="rack",
             name="_abs_weight",
             name="_abs_weight",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=django.db.models.functions.comparison.Cast(
                 expression=django.db.models.functions.comparison.Cast(
                     django.db.models.functions.math.Floor(
                     django.db.models.functions.math.Floor(
@@ -687,7 +689,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="racktype",
             model_name="racktype",
             name="_abs_max_weight",
             name="_abs_max_weight",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=django.db.models.functions.comparison.Cast(
                 expression=django.db.models.functions.comparison.Cast(
                     django.db.models.functions.math.Floor(
                     django.db.models.functions.math.Floor(
@@ -751,7 +753,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="racktype",
             model_name="racktype",
             name="_abs_weight",
             name="_abs_weight",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=django.db.models.functions.comparison.Cast(
                 expression=django.db.models.functions.comparison.Cast(
                     django.db.models.functions.math.Floor(
                     django.db.models.functions.math.Floor(
@@ -863,102 +865,4 @@ class Migration(migrations.Migration):
                 validators=[django.core.validators.MinValueValidator(0)],
                 validators=[django.core.validators.MinValueValidator(0)],
             ),
             ),
         ),
         ),
-        migrations.AddConstraint(
-            model_name="cable",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_length__gte", 0)),
-                name="dcim_cable_abs_length_non_negative",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="coolingfeed",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_max_flow__gte", 0)),
-                name="dcim_coolingfeed_abs_max_flow_non_negative",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="coolingintake",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_diameter__gte", 0)),
-                name="dcim_coolingintake_abs_diameter_non_negative",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="coolingintake",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_max_flow__gte", 0)),
-                name="dcim_coolingintake_abs_max_flow_non_negative",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="coolingintaketemplate",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_diameter__gte", 0)),
-                name="dcim_coolingintaketemplate_abs_diameter_non_negative",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="coolingintaketemplate",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_max_flow__gte", 0)),
-                name="dcim_coolingintaketemplate_abs_max_flow_non_negative",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="coolingoutflow",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_diameter__gte", 0)),
-                name="dcim_coolingoutflow_abs_diameter_non_negative",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="coolingoutflowtemplate",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_diameter__gte", 0)),
-                name="dcim_coolingoutflowtemplate_abs_diameter_non_negative",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="devicetype",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_weight__gte", 0)),
-                name="dcim_devicetype_abs_weight_non_negative",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="moduletype",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_weight__gte", 0)),
-                name="dcim_moduletype_abs_weight_non_negative",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="rack",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_weight__gte", 0)),
-                name="dcim_rack_abs_weight_non_negative",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="rack",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_max_weight__gte", 0)),
-                name="dcim_rack_abs_max_weight_non_negative",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="racktype",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_weight__gte", 0)),
-                name="dcim_racktype_abs_weight_non_negative",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="racktype",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_max_weight__gte", 0)),
-                name="dcim_racktype_abs_max_weight_non_negative",
-            ),
-        ),
     ]
     ]

+ 2 - 5
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 LENGTH_TO_METERS, non_negative_constraint, normalized_unit_expression
+from utilities.conversion import LENGTH_TO_METERS, NonNegativeGeneratedField, 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
@@ -152,7 +152,7 @@ class Cable(PrimaryModel):
         null=True
         null=True
     )
     )
     # Normalized length (in meters), computed by the database for ordering and path tracing
     # Normalized length (in meters), computed by the database for ordering and path tracing
-    _abs_length = models.GeneratedField(
+    _abs_length = NonNegativeGeneratedField(
         expression=normalized_unit_expression('length', 'length_unit', LENGTH_TO_METERS),
         expression=normalized_unit_expression('length', 'length_unit', LENGTH_TO_METERS),
         output_field=models.DecimalField(max_digits=14, decimal_places=4, blank=True, null=True),
         output_field=models.DecimalField(max_digits=14, decimal_places=4, blank=True, null=True),
         db_persist=True,
         db_persist=True,
@@ -173,9 +173,6 @@ class Cable(PrimaryModel):
 
 
     class Meta:
     class Meta:
         ordering = ('pk',)
         ordering = ('pk',)
-        constraints = (
-            non_negative_constraint('_abs_length'),
-        )
         verbose_name = _('cable')
         verbose_name = _('cable')
         verbose_name_plural = _('cables')
         verbose_name_plural = _('cables')
 
 

+ 0 - 2
netbox/dcim/models/cooling.py

@@ -7,7 +7,6 @@ from dcim.choices import *
 from dcim.models.mixins import MaxFlowMixin
 from dcim.models.mixins import MaxFlowMixin
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
-from utilities.conversion import non_negative_constraint
 
 
 __all__ = (
 __all__ = (
     'CoolingFeed',
     'CoolingFeed',
@@ -165,7 +164,6 @@ class CoolingFeed(MaxFlowMixin, PrimaryModel):
                 fields=('cooling_source', 'name'),
                 fields=('cooling_source', 'name'),
                 name='%(app_label)s_%(class)s_unique_cooling_source_name'
                 name='%(app_label)s_%(class)s_unique_cooling_source_name'
             ),
             ),
-            non_negative_constraint('_abs_max_flow'),
         )
         )
         verbose_name = _('cooling feed')
         verbose_name = _('cooling feed')
         verbose_name_plural = _('cooling feeds')
         verbose_name_plural = _('cooling feeds')

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

@@ -13,7 +13,6 @@ from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import ChangeLoggingMixin
 from netbox.models.features import ChangeLoggingMixin
 from netbox.models.ltree import LtreeManager, LtreeModel
 from netbox.models.ltree import LtreeManager, LtreeModel
-from utilities.conversion import non_negative_constraint
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.ordering import naturalize_interface
 from utilities.ordering import naturalize_interface
@@ -460,11 +459,6 @@ class CoolingIntakeTemplate(DiameterMixin, MaxFlowMixin, ModularComponentTemplat
     component_model = CoolingIntake
     component_model = CoolingIntake
 
 
     class Meta(ModularComponentTemplateModel.Meta):
     class Meta(ModularComponentTemplateModel.Meta):
-        constraints = (
-            *ModularComponentTemplateModel.Meta.constraints,
-            non_negative_constraint('_abs_diameter'),
-            non_negative_constraint('_abs_max_flow'),
-        )
         verbose_name = _('cooling intake template')
         verbose_name = _('cooling intake template')
         verbose_name_plural = _('cooling intake templates')
         verbose_name_plural = _('cooling intake templates')
 
 
@@ -521,10 +515,6 @@ class CoolingOutflowTemplate(DiameterMixin, ModularComponentTemplateModel):
     component_model = CoolingOutflow
     component_model = CoolingOutflow
 
 
     class Meta(ModularComponentTemplateModel.Meta):
     class Meta(ModularComponentTemplateModel.Meta):
-        constraints = (
-            *ModularComponentTemplateModel.Meta.constraints,
-            non_negative_constraint('_abs_diameter'),
-        )
         verbose_name = _('cooling outflow template')
         verbose_name = _('cooling outflow template')
         verbose_name_plural = _('cooling outflow templates')
         verbose_name_plural = _('cooling outflow templates')
 
 

+ 0 - 10
netbox/dcim/models/device_components.py

@@ -24,7 +24,6 @@ from netbox.models import NetBoxModel, OrganizationalModel
 from netbox.models.features import ChangeLoggingMixin
 from netbox.models.features import ChangeLoggingMixin
 from netbox.models.ltree import LtreeManager, LtreeModel, SortPathField
 from netbox.models.ltree import LtreeManager, LtreeModel, SortPathField
 from netbox.models.mixins import OwnerMixin
 from netbox.models.mixins import OwnerMixin
-from utilities.conversion import non_negative_constraint
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.ordering import naturalize_interface
 from utilities.ordering import naturalize_interface
 from utilities.query_functions import CollateAsChar
 from utilities.query_functions import CollateAsChar
@@ -735,11 +734,6 @@ class CoolingIntake(
     upstream_field = 'cooling_outflow'
     upstream_field = 'cooling_outflow'
 
 
     class Meta(ModularComponentModel.Meta):
     class Meta(ModularComponentModel.Meta):
-        constraints = (
-            *ModularComponentModel.Meta.constraints,
-            non_negative_constraint('_abs_diameter'),
-            non_negative_constraint('_abs_max_flow'),
-        )
         verbose_name = _('cooling intake')
         verbose_name = _('cooling intake')
         verbose_name_plural = _('cooling intakes')
         verbose_name_plural = _('cooling intakes')
 
 
@@ -776,10 +770,6 @@ class CoolingOutflow(CoolingLoopValidationMixin, DiameterMixin, ModularComponent
     upstream_field = 'cooling_intake'
     upstream_field = 'cooling_intake'
 
 
     class Meta(ModularComponentModel.Meta):
     class Meta(ModularComponentModel.Meta):
-        constraints = (
-            *ModularComponentModel.Meta.constraints,
-            non_negative_constraint('_abs_diameter'),
-        )
         verbose_name = _('cooling outflow')
         verbose_name = _('cooling outflow')
         verbose_name_plural = _('cooling outflows')
         verbose_name_plural = _('cooling outflows')
 
 

+ 0 - 2
netbox/dcim/models/devices.py

@@ -27,7 +27,6 @@ from netbox.config import ConfigItem
 from netbox.models import NestedLtreeGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models import NestedLtreeGroupModel, 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 non_negative_constraint
 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, CounterCacheField
 from utilities.fields import ColorField, CounterCacheField
@@ -233,7 +232,6 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
                 fields=('manufacturer', 'slug'),
                 fields=('manufacturer', 'slug'),
                 name='%(app_label)s_%(class)s_unique_manufacturer_slug'
                 name='%(app_label)s_%(class)s_unique_manufacturer_slug'
             ),
             ),
-            non_negative_constraint('_abs_weight'),
         )
         )
         verbose_name = _('device type')
         verbose_name = _('device type')
         verbose_name_plural = _('device types')
         verbose_name_plural = _('device types')

+ 3 - 2
netbox/dcim/models/mixins.py

@@ -13,6 +13,7 @@ from netbox.choices import *
 from utilities.conversion import (
 from utilities.conversion import (
     DIAMETER_TO_MILLIMETERS,
     DIAMETER_TO_MILLIMETERS,
     FLOW_RATE_TO_LITERS_PER_MINUTE,
     FLOW_RATE_TO_LITERS_PER_MINUTE,
+    NonNegativeGeneratedField,
     normalized_unit_expression,
     normalized_unit_expression,
 )
 )
 from utilities.data import normalize_update_fields
 from utilities.data import normalize_update_fields
@@ -408,7 +409,7 @@ class DiameterMixin(models.Model):
         null=True,
         null=True,
     )
     )
     # Normalized diameter (in millimeters), computed by the database for ordering
     # Normalized diameter (in millimeters), computed by the database for ordering
-    _abs_diameter = models.GeneratedField(
+    _abs_diameter = NonNegativeGeneratedField(
         expression=normalized_unit_expression('diameter', 'diameter_unit', DIAMETER_TO_MILLIMETERS),
         expression=normalized_unit_expression('diameter', 'diameter_unit', DIAMETER_TO_MILLIMETERS),
         output_field=models.DecimalField(max_digits=13, decimal_places=4, blank=True, null=True),
         output_field=models.DecimalField(max_digits=13, decimal_places=4, blank=True, null=True),
         db_persist=True,
         db_persist=True,
@@ -465,7 +466,7 @@ class MaxFlowMixin(models.Model):
         null=True,
         null=True,
     )
     )
     # Normalized max flow (in liters per minute), computed by the database for ordering
     # Normalized max flow (in liters per minute), computed by the database for ordering
-    _abs_max_flow = models.GeneratedField(
+    _abs_max_flow = NonNegativeGeneratedField(
         expression=normalized_unit_expression('max_flow', 'max_flow_unit', FLOW_RATE_TO_LITERS_PER_MINUTE),
         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),
         output_field=models.DecimalField(max_digits=13, decimal_places=4, blank=True, null=True),
         db_persist=True,
         db_persist=True,

+ 0 - 2
netbox/dcim/models/modules.py

@@ -15,7 +15,6 @@ from extras.models import CustomField
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
 from netbox.models.features import ImageAttachmentsMixin
 from netbox.models.features import ImageAttachmentsMixin
 from netbox.models.mixins import WeightMixin
 from netbox.models.mixins import WeightMixin
-from utilities.conversion import non_negative_constraint
 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, CounterCacheField
 from utilities.fields import ColorField, CounterCacheField
@@ -236,7 +235,6 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
                 fields=('manufacturer', 'model'),
                 fields=('manufacturer', 'model'),
                 name='%(app_label)s_%(class)s_unique_manufacturer_model'
                 name='%(app_label)s_%(class)s_unique_manufacturer_model'
             ),
             ),
-            non_negative_constraint('_abs_weight'),
         )
         )
         indexes = (
         indexes = (
             models.Index(fields=('profile', 'manufacturer', 'model')),  # Default ordering
             models.Index(fields=('profile', 'manufacturer', 'model')),  # Default ordering

+ 2 - 6
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 WEIGHT_TO_GRAMS, non_negative_constraint, normalized_unit_expression
+from utilities.conversion import WEIGHT_TO_GRAMS, NonNegativeGeneratedField, 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
@@ -130,7 +130,7 @@ class RackBase(WeightMixin, PrimaryModel):
         help_text=_('Maximum load capacity for the rack')
         help_text=_('Maximum load capacity for the rack')
     )
     )
     # Normalized max weight (in grams), computed by the database for ordering
     # Normalized max weight (in grams), computed by the database for ordering
-    _abs_max_weight = models.GeneratedField(
+    _abs_max_weight = NonNegativeGeneratedField(
         expression=normalized_unit_expression('max_weight', 'weight_unit', WEIGHT_TO_GRAMS, integer=True),
         expression=normalized_unit_expression('max_weight', 'weight_unit', WEIGHT_TO_GRAMS, integer=True),
         output_field=models.BigIntegerField(blank=True, null=True),
         output_field=models.BigIntegerField(blank=True, null=True),
         db_persist=True,
         db_persist=True,
@@ -215,8 +215,6 @@ class RackType(ImageAttachmentsMixin, RackBase):
                 fields=('manufacturer', 'slug'),
                 fields=('manufacturer', 'slug'),
                 name='%(app_label)s_%(class)s_unique_manufacturer_slug'
                 name='%(app_label)s_%(class)s_unique_manufacturer_slug'
             ),
             ),
-            non_negative_constraint('_abs_weight'),
-            non_negative_constraint('_abs_max_weight'),
         )
         )
         verbose_name = _('rack type')
         verbose_name = _('rack type')
         verbose_name_plural = _('rack types')
         verbose_name_plural = _('rack types')
@@ -410,8 +408,6 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
                 fields=('location', 'facility_id'),
                 fields=('location', 'facility_id'),
                 name='%(app_label)s_%(class)s_unique_location_facility_id'
                 name='%(app_label)s_%(class)s_unique_location_facility_id'
             ),
             ),
-            non_negative_constraint('_abs_weight'),
-            non_negative_constraint('_abs_max_weight'),
         )
         )
         indexes = (
         indexes = (
             models.Index(fields=('site', 'location', 'name', 'id')),  # Default ordering
             models.Index(fields=('site', 'location', 'name', 'id')),  # Default ordering

+ 8 - 3
netbox/netbox/models/mixins.py

@@ -4,7 +4,12 @@ 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 LENGTH_TO_METERS, WEIGHT_TO_GRAMS, normalized_unit_expression
+from utilities.conversion import (
+    LENGTH_TO_METERS,
+    WEIGHT_TO_GRAMS,
+    NonNegativeGeneratedField,
+    normalized_unit_expression,
+)
 
 
 __all__ = (
 __all__ = (
     'DistanceMixin',
     'DistanceMixin',
@@ -46,7 +51,7 @@ class WeightMixin(models.Model):
         null=True,
         null=True,
     )
     )
     # Normalized weight (in grams), computed by the database for ordering
     # Normalized weight (in grams), computed by the database for ordering
-    _abs_weight = models.GeneratedField(
+    _abs_weight = NonNegativeGeneratedField(
         expression=normalized_unit_expression('weight', 'weight_unit', WEIGHT_TO_GRAMS, integer=True),
         expression=normalized_unit_expression('weight', 'weight_unit', WEIGHT_TO_GRAMS, integer=True),
         output_field=models.BigIntegerField(blank=True, null=True),
         output_field=models.BigIntegerField(blank=True, null=True),
         db_persist=True,
         db_persist=True,
@@ -86,7 +91,7 @@ class DistanceMixin(models.Model):
         null=True,
         null=True,
     )
     )
     # Normalized distance (in meters), computed by the database for ordering
     # Normalized distance (in meters), computed by the database for ordering
-    _abs_distance = models.GeneratedField(
+    _abs_distance = NonNegativeGeneratedField(
         expression=normalized_unit_expression('distance', 'distance_unit', LENGTH_TO_METERS),
         expression=normalized_unit_expression('distance', 'distance_unit', LENGTH_TO_METERS),
         output_field=models.DecimalField(max_digits=13, decimal_places=4, blank=True, null=True),
         output_field=models.DecimalField(max_digits=13, decimal_places=4, blank=True, null=True),
         db_persist=True,
         db_persist=True,

+ 14 - 13
netbox/utilities/conversion.py

@@ -1,6 +1,6 @@
 from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
 from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
 
 
-from django.db.models import BigIntegerField, Case, CheckConstraint, DecimalField, F, Q, Value, When
+from django.db.models import BigIntegerField, Case, DecimalField, F, GeneratedField, Value, When
 from django.db.models.functions import Cast, Floor
 from django.db.models.functions import Cast, Floor
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
@@ -16,7 +16,7 @@ __all__ = (
     'FLOW_RATE_TO_LITERS_PER_MINUTE',
     'FLOW_RATE_TO_LITERS_PER_MINUTE',
     'LENGTH_TO_METERS',
     'LENGTH_TO_METERS',
     'WEIGHT_TO_GRAMS',
     'WEIGHT_TO_GRAMS',
-    'non_negative_constraint',
+    'NonNegativeGeneratedField',
     'normalized_unit_expression',
     'normalized_unit_expression',
     'to_grams',
     'to_grams',
     'to_liters_per_minute',
     'to_liters_per_minute',
@@ -66,6 +66,18 @@ FLOW_RATE_TO_LITERS_PER_MINUTE = {
 # Database expressions
 # Database expressions
 #
 #
 
 
+class NonNegativeGeneratedField(GeneratedField):
+    """
+    A generated column which the database constrains to non-negative values via an inline CHECK, as it would a
+    PositiveIntegerField. Django emits no check for a GeneratedField's output_field because that field is never
+    bound to a column, so the check is supplied here from the generated column itself.
+    """
+    def db_parameters(self, connection):
+        params = super().db_parameters(connection)
+        params['check'] = f'{connection.ops.quote_name(self.column)} >= 0'
+        return params
+
+
 def normalized_unit_expression(value_field, unit_field, factors, integer=False):
 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`,
     Return a database expression converting `value_field` to its base unit according to the value of `unit_field`,
@@ -83,17 +95,6 @@ def normalized_unit_expression(value_field, unit_field, factors, integer=False):
     return expression
     return expression
 
 
 
 
-def non_negative_constraint(field_name):
-    """
-    Return a CheckConstraint rejecting a negative value in the given normalized column. Field validators run only
-    in full_clean(), so this keeps negative measurements out of the database on every write path.
-    """
-    return CheckConstraint(
-        condition=Q(**{f'{field_name}__gte': 0}),
-        name=f"%(app_label)s_%(class)s_{field_name.lstrip('_')}_non_negative",
-    )
-
-
 #
 #
 # Python conversions
 # Python conversions
 #
 #

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

@@ -1,6 +1,9 @@
 from decimal import Decimal
 from decimal import Decimal
 
 
+from django.db import connection
+
 from dcim.choices import CableLengthUnitChoices
 from dcim.choices import CableLengthUnitChoices
+from dcim.models import DeviceType
 from netbox.choices import (
 from netbox.choices import (
     DiameterUnitChoices,
     DiameterUnitChoices,
     DistanceUnitChoices,
     DistanceUnitChoices,
@@ -39,6 +42,14 @@ class ConversionsTestCase(TestCase):
                 for factor in factors.values():
                 for factor in factors.values():
                     self.assertIsInstance(factor, Decimal)
                     self.assertIsInstance(factor, Decimal)
 
 
+    def test_normalized_columns_are_checked_non_negative(self):
+        """
+        Each generated column must carry an inline database check so that a negative measurement cannot be stored
+        through any write path.
+        """
+        field = DeviceType._meta.get_field('_abs_weight')
+        self.assertEqual(field.db_parameters(connection)['check'], '"_abs_weight" >= 0')
+
     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),

+ 3 - 8
netbox/wireless/migrations/0025_generated_fields.py

@@ -7,6 +7,8 @@ import django.db.models.expressions
 import django.db.models.functions.comparison
 import django.db.models.functions.comparison
 from django.db import migrations, models
 from django.db import migrations, models
 
 
+import utilities.conversion
+
 
 
 # PostgreSQL cannot convert an existing column to a generated column in place, so each normalized
 # 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.
 # column is dropped and re-added. The database computes the values for all existing rows.
@@ -24,7 +26,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name="wirelesslink",
             model_name="wirelesslink",
             name="_abs_distance",
             name="_abs_distance",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 db_persist=True,
                 expression=models.Case(
                 expression=models.Case(
                     models.When(
                     models.When(
@@ -105,11 +107,4 @@ class Migration(migrations.Migration):
                 validators=[django.core.validators.MinValueValidator(0)],
                 validators=[django.core.validators.MinValueValidator(0)],
             ),
             ),
         ),
         ),
-        migrations.AddConstraint(
-            model_name="wirelesslink",
-            constraint=models.CheckConstraint(
-                condition=models.Q(("_abs_distance__gte", 0)),
-                name="wireless_wirelesslink_abs_distance_non_negative",
-            ),
-        ),
     ]
     ]

+ 0 - 2
netbox/wireless/models.py

@@ -8,7 +8,6 @@ from dcim.constants import WIRELESS_IFACE_TYPES
 from dcim.models.mixins import CachedScopeMixin
 from dcim.models.mixins import CachedScopeMixin
 from netbox.models import NestedLtreeGroupModel, PrimaryModel
 from netbox.models import NestedLtreeGroupModel, PrimaryModel
 from netbox.models.mixins import DistanceMixin
 from netbox.models.mixins import DistanceMixin
-from utilities.conversion import non_negative_constraint
 
 
 from .choices import *
 from .choices import *
 from .constants import *
 from .constants import *
@@ -190,7 +189,6 @@ class WirelessLink(WirelessAuthenticationBase, DistanceMixin, PrimaryModel):
                 fields=('interface_a', 'interface_b'),
                 fields=('interface_a', 'interface_b'),
                 name='%(app_label)s_%(class)s_unique_interfaces'
                 name='%(app_label)s_%(class)s_unique_interfaces'
             ),
             ),
-            non_negative_constraint('_abs_distance'),
         )
         )
         verbose_name = _('wireless link')
         verbose_name = _('wireless link')
         verbose_name_plural = _('wireless links')
         verbose_name_plural = _('wireless links')