فهرست منبع

use custom field

Arthur 2 روز پیش
والد
کامیت
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
 from django.db import migrations, models
 
+import utilities.conversion
+
 
 # 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.
@@ -24,7 +26,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="circuit",
             name="_abs_distance",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=models.Case(
                     models.When(
@@ -105,11 +107,4 @@ class Migration(migrations.Migration):
                 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,
 )
 from netbox.models.mixins import DistanceMixin
-from utilities.conversion import non_negative_constraint
 from utilities.data import normalize_update_fields
 from utilities.string import title
 
@@ -148,7 +147,6 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel)
                 fields=('provider_account', 'cid'),
                 name='%(app_label)s_%(class)s_unique_provideraccount_cid'
             ),
-            non_negative_constraint('_abs_distance'),
         )
         indexes = (
             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
 from django.db import migrations, models
 
+import utilities.conversion
+
 
 # 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.
@@ -25,7 +27,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="cable",
             name="_abs_length",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=models.Case(
                     models.When(
@@ -102,7 +104,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="coolingfeed",
             name="_abs_max_flow",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=models.Case(
                     models.When(
@@ -149,7 +151,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="coolingintake",
             name="_abs_diameter",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=models.Case(
                     models.When(
@@ -196,7 +198,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="coolingintake",
             name="_abs_max_flow",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=models.Case(
                     models.When(
@@ -243,7 +245,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="coolingintaketemplate",
             name="_abs_diameter",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=models.Case(
                     models.When(
@@ -290,7 +292,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="coolingintaketemplate",
             name="_abs_max_flow",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=models.Case(
                     models.When(
@@ -337,7 +339,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="coolingoutflow",
             name="_abs_diameter",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=models.Case(
                     models.When(
@@ -384,7 +386,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="coolingoutflowtemplate",
             name="_abs_diameter",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=models.Case(
                     models.When(
@@ -431,7 +433,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="devicetype",
             name="_abs_weight",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=django.db.models.functions.comparison.Cast(
                     django.db.models.functions.math.Floor(
@@ -495,7 +497,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="moduletype",
             name="_abs_weight",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=django.db.models.functions.comparison.Cast(
                     django.db.models.functions.math.Floor(
@@ -559,7 +561,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="rack",
             name="_abs_max_weight",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=django.db.models.functions.comparison.Cast(
                     django.db.models.functions.math.Floor(
@@ -623,7 +625,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="rack",
             name="_abs_weight",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=django.db.models.functions.comparison.Cast(
                     django.db.models.functions.math.Floor(
@@ -687,7 +689,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="racktype",
             name="_abs_max_weight",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=django.db.models.functions.comparison.Cast(
                     django.db.models.functions.math.Floor(
@@ -751,7 +753,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="racktype",
             name="_abs_weight",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=django.db.models.functions.comparison.Cast(
                     django.db.models.functions.math.Floor(
@@ -863,102 +865,4 @@ class Migration(migrations.Migration):
                 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 netbox.choices import ColorChoices
 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.exceptions import AbortRequest
 from utilities.fields import ColorField, GenericArrayForeignKey
@@ -152,7 +152,7 @@ class Cable(PrimaryModel):
         null=True
     )
     # 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),
         output_field=models.DecimalField(max_digits=14, decimal_places=4, blank=True, null=True),
         db_persist=True,
@@ -173,9 +173,6 @@ class Cable(PrimaryModel):
 
     class Meta:
         ordering = ('pk',)
-        constraints = (
-            non_negative_constraint('_abs_length'),
-        )
         verbose_name = _('cable')
         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 netbox.models import PrimaryModel
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
-from utilities.conversion import non_negative_constraint
 
 __all__ = (
     'CoolingFeed',
@@ -165,7 +164,6 @@ class CoolingFeed(MaxFlowMixin, PrimaryModel):
                 fields=('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_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.features import ChangeLoggingMixin
 from netbox.models.ltree import LtreeManager, LtreeModel
-from utilities.conversion import non_negative_constraint
 from utilities.exceptions import AbortRequest
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.ordering import naturalize_interface
@@ -460,11 +459,6 @@ class CoolingIntakeTemplate(DiameterMixin, MaxFlowMixin, ModularComponentTemplat
     component_model = CoolingIntake
 
     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_plural = _('cooling intake templates')
 
@@ -521,10 +515,6 @@ class CoolingOutflowTemplate(DiameterMixin, ModularComponentTemplateModel):
     component_model = CoolingOutflow
 
     class Meta(ModularComponentTemplateModel.Meta):
-        constraints = (
-            *ModularComponentTemplateModel.Meta.constraints,
-            non_negative_constraint('_abs_diameter'),
-        )
         verbose_name = _('cooling outflow template')
         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.ltree import LtreeManager, LtreeModel, SortPathField
 from netbox.models.mixins import OwnerMixin
-from utilities.conversion import non_negative_constraint
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.ordering import naturalize_interface
 from utilities.query_functions import CollateAsChar
@@ -735,11 +734,6 @@ class CoolingIntake(
     upstream_field = 'cooling_outflow'
 
     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_plural = _('cooling intakes')
 
@@ -776,10 +770,6 @@ class CoolingOutflow(CoolingLoopValidationMixin, DiameterMixin, ModularComponent
     upstream_field = 'cooling_intake'
 
     class Meta(ModularComponentModel.Meta):
-        constraints = (
-            *ModularComponentModel.Meta.constraints,
-            non_negative_constraint('_abs_diameter'),
-        )
         verbose_name = _('cooling outflow')
         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.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.mixins import WeightMixin
-from utilities.conversion import non_negative_constraint
 from utilities.data import normalize_update_fields
 from utilities.exceptions import AbortRequest
 from utilities.fields import ColorField, CounterCacheField
@@ -233,7 +232,6 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
                 fields=('manufacturer', 'slug'),
                 name='%(app_label)s_%(class)s_unique_manufacturer_slug'
             ),
-            non_negative_constraint('_abs_weight'),
         )
         verbose_name = _('device type')
         verbose_name_plural = _('device types')

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

@@ -13,6 +13,7 @@ from netbox.choices import *
 from utilities.conversion import (
     DIAMETER_TO_MILLIMETERS,
     FLOW_RATE_TO_LITERS_PER_MINUTE,
+    NonNegativeGeneratedField,
     normalized_unit_expression,
 )
 from utilities.data import normalize_update_fields
@@ -408,7 +409,7 @@ class DiameterMixin(models.Model):
         null=True,
     )
     # 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),
         output_field=models.DecimalField(max_digits=13, decimal_places=4, blank=True, null=True),
         db_persist=True,
@@ -465,7 +466,7 @@ class MaxFlowMixin(models.Model):
         null=True,
     )
     # 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),
         output_field=models.DecimalField(max_digits=13, decimal_places=4, blank=True, null=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.features import ImageAttachmentsMixin
 from netbox.models.mixins import WeightMixin
-from utilities.conversion import non_negative_constraint
 from utilities.data import normalize_update_fields
 from utilities.exceptions import AbortRequest
 from utilities.fields import ColorField, CounterCacheField
@@ -236,7 +235,6 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
                 fields=('manufacturer', 'model'),
                 name='%(app_label)s_%(class)s_unique_manufacturer_model'
             ),
-            non_negative_constraint('_abs_weight'),
         )
         indexes = (
             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.features import ContactsMixin, ImageAttachmentsMixin
 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.fields import ColorField, CounterCacheField
 from utilities.tracking import TrackingModelMixin
@@ -130,7 +130,7 @@ class RackBase(WeightMixin, PrimaryModel):
         help_text=_('Maximum load capacity for the rack')
     )
     # 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),
         output_field=models.BigIntegerField(blank=True, null=True),
         db_persist=True,
@@ -215,8 +215,6 @@ class RackType(ImageAttachmentsMixin, RackBase):
                 fields=('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_plural = _('rack types')
@@ -410,8 +408,6 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
                 fields=('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 = (
             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 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__ = (
     'DistanceMixin',
@@ -46,7 +51,7 @@ class WeightMixin(models.Model):
         null=True,
     )
     # 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),
         output_field=models.BigIntegerField(blank=True, null=True),
         db_persist=True,
@@ -86,7 +91,7 @@ class DistanceMixin(models.Model):
         null=True,
     )
     # 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),
         output_field=models.DecimalField(max_digits=13, decimal_places=4, blank=True, null=True),
         db_persist=True,

+ 14 - 13
netbox/utilities/conversion.py

@@ -1,6 +1,6 @@
 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.utils.translation import gettext as _
 
@@ -16,7 +16,7 @@ __all__ = (
     'FLOW_RATE_TO_LITERS_PER_MINUTE',
     'LENGTH_TO_METERS',
     'WEIGHT_TO_GRAMS',
-    'non_negative_constraint',
+    'NonNegativeGeneratedField',
     'normalized_unit_expression',
     'to_grams',
     'to_liters_per_minute',
@@ -66,6 +66,18 @@ FLOW_RATE_TO_LITERS_PER_MINUTE = {
 # 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):
     """
     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
 
 
-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
 #

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

@@ -1,6 +1,9 @@
 from decimal import Decimal
 
+from django.db import connection
+
 from dcim.choices import CableLengthUnitChoices
+from dcim.models import DeviceType
 from netbox.choices import (
     DiameterUnitChoices,
     DistanceUnitChoices,
@@ -39,6 +42,14 @@ class ConversionsTestCase(TestCase):
                 for factor in factors.values():
                     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):
         self.assertEqual(
             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
 from django.db import migrations, models
 
+import utilities.conversion
+
 
 # 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.
@@ -24,7 +26,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name="wirelesslink",
             name="_abs_distance",
-            field=models.GeneratedField(
+            field=utilities.conversion.NonNegativeGeneratedField(
                 db_persist=True,
                 expression=models.Case(
                     models.When(
@@ -105,11 +107,4 @@ class Migration(migrations.Migration):
                 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 netbox.models import NestedLtreeGroupModel, PrimaryModel
 from netbox.models.mixins import DistanceMixin
-from utilities.conversion import non_negative_constraint
 
 from .choices import *
 from .constants import *
@@ -190,7 +189,6 @@ class WirelessLink(WirelessAuthenticationBase, DistanceMixin, PrimaryModel):
                 fields=('interface_a', 'interface_b'),
                 name='%(app_label)s_%(class)s_unique_interfaces'
             ),
-            non_negative_constraint('_abs_distance'),
         )
         verbose_name = _('wireless link')
         verbose_name_plural = _('wireless links')