Arthur 1 روز پیش
والد
کامیت
0e34157c92

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

@@ -2,6 +2,7 @@
 
 from decimal import Decimal
 
+import django.core.validators
 import django.db.models.expressions
 import django.db.models.functions.comparison
 from django.db import migrations, models
@@ -93,4 +94,15 @@ class Migration(migrations.Migration):
                 ),
             ),
         ),
+        migrations.AlterField(
+            model_name="circuit",
+            name="distance",
+            field=models.DecimalField(
+                blank=True,
+                decimal_places=2,
+                max_digits=8,
+                null=True,
+                validators=[django.core.validators.MinValueValidator(0)],
+            ),
+        ),
     ]

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

@@ -2,6 +2,7 @@
 
 from decimal import Decimal
 
+import django.core.validators
 import django.db.models.expressions
 import django.db.models.functions.comparison
 import django.db.models.functions.math
@@ -807,4 +808,59 @@ class Migration(migrations.Migration):
                 output_field=models.BigIntegerField(blank=True, null=True),
             ),
         ),
+        migrations.AlterField(
+            model_name="cable",
+            name="length",
+            field=models.DecimalField(
+                blank=True,
+                decimal_places=2,
+                max_digits=8,
+                null=True,
+                validators=[django.core.validators.MinValueValidator(0)],
+            ),
+        ),
+        migrations.AlterField(
+            model_name="devicetype",
+            name="weight",
+            field=models.DecimalField(
+                blank=True,
+                decimal_places=2,
+                max_digits=8,
+                null=True,
+                validators=[django.core.validators.MinValueValidator(0)],
+            ),
+        ),
+        migrations.AlterField(
+            model_name="moduletype",
+            name="weight",
+            field=models.DecimalField(
+                blank=True,
+                decimal_places=2,
+                max_digits=8,
+                null=True,
+                validators=[django.core.validators.MinValueValidator(0)],
+            ),
+        ),
+        migrations.AlterField(
+            model_name="rack",
+            name="weight",
+            field=models.DecimalField(
+                blank=True,
+                decimal_places=2,
+                max_digits=8,
+                null=True,
+                validators=[django.core.validators.MinValueValidator(0)],
+            ),
+        ),
+        migrations.AlterField(
+            model_name="racktype",
+            name="weight",
+            field=models.DecimalField(
+                blank=True,
+                decimal_places=2,
+                max_digits=8,
+                null=True,
+                validators=[django.core.validators.MinValueValidator(0)],
+            ),
+        ),
     ]

+ 2 - 1
netbox/dcim/models/cables.py

@@ -141,7 +141,8 @@ class Cable(PrimaryModel):
         max_digits=8,
         decimal_places=2,
         blank=True,
-        null=True
+        null=True,
+        validators=[MinValueValidator(0)],
     )
     length_unit = models.CharField(
         verbose_name=_('length unit'),

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

@@ -473,6 +473,9 @@ class CoolingIntakeTemplate(DiameterMixin, MaxFlowMixin, ModularComponentTemplat
             max_flow_unit=self.max_flow_unit,
             **kwargs
         )
+        # bulk_create() bypasses save(), so clear any unit lacking a value here
+        component.normalize_diameter()
+        component.normalize_max_flow()
         return component
     instantiate.do_not_call_in_templates = True
 
@@ -548,6 +551,8 @@ class CoolingOutflowTemplate(DiameterMixin, ModularComponentTemplateModel):
             cooling_intake=cooling_intake,
             **kwargs
         )
+        # bulk_create() bypasses save(), so clear any unit lacking a value here
+        component.normalize_diameter()
         return component
     instantiate.do_not_call_in_templates = True
 

+ 79 - 0
netbox/dcim/tests/test_models.py

@@ -24,8 +24,10 @@ from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices, WeightUnitC
 from netbox.context_managers import event_tracking
 from tenancy.models import Tenant
 from users.models import User
+from utilities.conversion import to_grams
 from utilities.data import drange
 from utilities.testing import create_test_device, create_test_image
+from utilities.testing.mixins import NormalizedMeasurementTestMixin
 from virtualization.models import Cluster, ClusterType
 
 
@@ -225,6 +227,28 @@ class DeviceTypeTestCase(TestCase):
         self.assertEqual(device_type.interface_template_count, 1)
 
 
+class DeviceTypeWeightTestCase(NormalizedMeasurementTestMixin, TestCase):
+    model = DeviceType
+    value_field = 'weight'
+    unit_field = 'weight_unit'
+    normalized_field = '_abs_weight'
+    unit_choices = WeightUnitChoices
+    converter = staticmethod(to_grams)
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+
+    def create_instance(self, **attrs):
+        index = next(self.sequence)
+        return DeviceType(
+            manufacturer=self.manufacturer,
+            model=f'Device Type {index}',
+            slug=f'device-type-{index}',
+            **attrs
+        )
+
+
 class DeviceTypeImageTestCase(TestCase):
     """
     Test the handling of DeviceType front/rear image files.
@@ -581,6 +605,24 @@ class RackTypeTestCase(TestCase):
         self.assertEqual(rack.cooling_capacity, rack_type.cooling_capacity)
 
 
+class RackMaxWeightTestCase(NormalizedMeasurementTestMixin, TestCase):
+    model = Rack
+    value_field = 'max_weight'
+    unit_field = 'weight_unit'
+    normalized_field = '_abs_max_weight'
+    unit_choices = WeightUnitChoices
+    converter = staticmethod(to_grams)
+    # The largest value the integer column holds; multiplying it as an integer would overflow
+    value = 2147483647
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site = Site.objects.create(name='Site 1', slug='site-1')
+
+    def create_instance(self, **attrs):
+        return Rack(site=self.site, name=f'Rack {next(self.sequence)}', **attrs)
+
+
 class RackTestCase(TestCase):
 
     @classmethod
@@ -4681,3 +4723,40 @@ class CoolingComponentTestCase(TestCase):
         cooling_outflow.cooling_intake = cooling_intake
         with self.assertRaises(ValidationError):
             cooling_outflow.full_clean()
+
+    def test_instantiate_clears_unit_without_value(self):
+        """
+        bulk_create() bypasses save(), so instantiate() must itself clear a unit whose value is unset.
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer, model='Device Type 8', slug='device-type-8'
+        )
+        intake_template = CoolingIntakeTemplate.objects.create(
+            device_type=device_type,
+            name='Cooling Port 1',
+            diameter=Decimal('10'),
+            diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
+            max_flow=Decimal('5'),
+            max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
+        )
+        outflow_template = CoolingOutflowTemplate.objects.create(
+            device_type=device_type,
+            name='Cooling Outlet 1',
+            diameter=Decimal('10'),
+            diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
+        )
+        # Leave the units dangling without passing through save()
+        CoolingIntakeTemplate.objects.filter(pk=intake_template.pk).update(diameter=None, max_flow=None)
+        CoolingOutflowTemplate.objects.filter(pk=outflow_template.pk).update(diameter=None)
+
+        device = Device.objects.create(
+            site=self.site, device_type=device_type, role=self.role, name='Device F'
+        )
+        cooling_intake = CoolingIntake.objects.get(device=device, name='Cooling Port 1')
+        self.assertIsNone(cooling_intake.diameter)
+        self.assertIsNone(cooling_intake.diameter_unit)
+        self.assertIsNone(cooling_intake.max_flow)
+        self.assertIsNone(cooling_intake.max_flow_unit)
+        cooling_outflow = CoolingOutflow.objects.get(device=device, name='Cooling Outlet 1')
+        self.assertIsNone(cooling_outflow.diameter)
+        self.assertIsNone(cooling_outflow.diameter_unit)

+ 4 - 1
netbox/netbox/models/mixins.py

@@ -1,4 +1,5 @@
 from django.core.exceptions import ValidationError
+from django.core.validators import MinValueValidator
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 
@@ -34,7 +35,8 @@ class WeightMixin(models.Model):
         max_digits=8,
         decimal_places=2,
         blank=True,
-        null=True
+        null=True,
+        validators=[MinValueValidator(0)],
     )
     weight_unit = models.CharField(
         verbose_name=_('weight unit'),
@@ -74,6 +76,7 @@ class DistanceMixin(models.Model):
         decimal_places=2,
         blank=True,
         null=True,
+        validators=[MinValueValidator(0)],
     )
     distance_unit = models.CharField(
         verbose_name=_('distance unit'),

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

@@ -1,6 +1,7 @@
 import itertools
 from decimal import Decimal
 
+from django.core.exceptions import ValidationError
 from django.test.testcases import SerializeMixin
 from django_rq import get_queue
 from django_rq.workers import get_worker
@@ -148,6 +149,15 @@ class NormalizedMeasurementTestMixin:
             with self.subTest(unit=unit):
                 self.assertNormalized(instance, self.value, unit)
 
+    def test_negative_value_rejected(self):
+        """
+        The database no longer rejects a negative measurement, so the source field's validator must.
+        """
+        instance = self.create_instance(**{self.value_field: -self.value, self.unit_field: self.units[0]})
+        with self.assertRaises(ValidationError) as cm:
+            instance.full_clean()
+        self.assertIn(self.value_field, cm.exception.error_dict)
+
     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})

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

@@ -2,6 +2,7 @@
 
 from decimal import Decimal
 
+import django.core.validators
 import django.db.models.expressions
 import django.db.models.functions.comparison
 from django.db import migrations, models
@@ -93,4 +94,15 @@ class Migration(migrations.Migration):
                 ),
             ),
         ),
+        migrations.AlterField(
+            model_name="wirelesslink",
+            name="distance",
+            field=models.DecimalField(
+                blank=True,
+                decimal_places=2,
+                max_digits=8,
+                null=True,
+                validators=[django.core.validators.MinValueValidator(0)],
+            ),
+        ),
     ]