Arthur 1 неделя назад
Родитель
Сommit
4fcc6a24b8

+ 9 - 2
netbox/dcim/api/serializers_/device_components.py

@@ -26,7 +26,7 @@ from ipam.models import VLAN
 from netbox.api.fields import ChoiceField, ContentTypeField, RestrictedPrimaryKeyRelatedField, SerializedPKRelatedField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.serializers import NetBoxModelSerializer
-from netbox.choices import DiameterUnitChoices
+from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices
 from users.api.serializers_.mixins import OwnerMixin
 from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
 from wireless.api.serializers_.nested import NestedWirelessLinkSerializer
@@ -232,12 +232,19 @@ class CoolingPortSerializer(
         required=False,
         allow_null=True
     )
+    maximum_flow_unit = ChoiceField(
+        choices=FlowRateUnitChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
 
     class Meta:
         model = CoolingPort
         fields = [
             'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'connector_type',
-            'diameter', 'diameter_unit', 'maximum_flow', 'heat_capacity', 'description', 'mark_connected', 'cable',
+            'diameter', 'diameter_unit', 'maximum_flow', 'maximum_flow_unit', 'heat_capacity', 'description',
+            'mark_connected', 'cable',
             'cable_end',
             'link_peers', 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type',
             'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',

+ 9 - 2
netbox/dcim/api/serializers_/devicetype_components.py

@@ -21,7 +21,7 @@ from dcim.models import (
 from netbox.api.fields import ChoiceField, ContentTypeField, RestrictedPrimaryKeyRelatedField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
-from netbox.choices import DiameterUnitChoices
+from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices
 from wireless.choices import *
 
 from .base import PortSerializer
@@ -206,12 +206,19 @@ class CoolingPortTemplateSerializer(ComponentTemplateSerializer):
         required=False,
         allow_null=True
     )
+    maximum_flow_unit = ChoiceField(
+        choices=FlowRateUnitChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
 
     class Meta:
         model = CoolingPortTemplate
         fields = [
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'connector_type',
-            'diameter', 'diameter_unit', 'maximum_flow', 'heat_capacity', 'description', 'created', 'last_updated',
+            'diameter', 'diameter_unit', 'maximum_flow', 'maximum_flow_unit', 'heat_capacity', 'description',
+            'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 

+ 9 - 3
netbox/dcim/forms/bulk_edit.py

@@ -1396,6 +1396,11 @@ class CoolingPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         min_value=0,
         required=False
     )
+    maximum_flow_unit = forms.ChoiceField(
+        label=_('Maximum flow unit'),
+        choices=add_blank_choice(FlowRateUnitChoices),
+        required=False
+    )
     heat_capacity = forms.DecimalField(
         label=_('Heat capacity'),
         min_value=0,
@@ -1410,12 +1415,13 @@ class CoolingPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         FieldSet(
             'label', 'type', 'connector_type',
             InlineFields('diameter', 'diameter_unit', label=_('Diameter')),
-            'maximum_flow', 'heat_capacity', 'description',
+            InlineFields('maximum_flow', 'maximum_flow_unit', label=_('Maximum flow')),
+            'heat_capacity', 'description',
         ),
     )
     nullable_fields = (
-        'label', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow', 'heat_capacity',
-        'description',
+        'label', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow', 'maximum_flow_unit',
+        'heat_capacity', 'description',
     )
 
 

+ 6 - 4
netbox/dcim/forms/model_forms.py

@@ -1260,7 +1260,8 @@ class CoolingPortTemplateForm(ModularComponentTemplateForm):
             ),
             'name', 'label', 'type', 'connector_type',
             InlineFields('diameter', 'diameter_unit', label=_('Diameter')),
-            'maximum_flow', 'heat_capacity', 'description',
+            InlineFields('maximum_flow', 'maximum_flow_unit', label=_('Maximum flow')),
+            'heat_capacity', 'description',
         ),
     )
 
@@ -1268,7 +1269,7 @@ class CoolingPortTemplateForm(ModularComponentTemplateForm):
         model = CoolingPortTemplate
         fields = [
             'device_type', 'module_type', 'name', 'label', 'type', 'connector_type', 'diameter', 'diameter_unit',
-            'maximum_flow', 'heat_capacity', 'description',
+            'maximum_flow', 'maximum_flow_unit', 'heat_capacity', 'description',
         ]
 
 
@@ -1670,7 +1671,8 @@ class CoolingPortForm(ModularDeviceComponentForm):
         FieldSet(
             'device', 'module', 'name', 'label', 'type', 'connector_type',
             InlineFields('diameter', 'diameter_unit', label=_('Diameter')),
-            'maximum_flow', 'heat_capacity', 'mark_connected', 'description', 'tags',
+            InlineFields('maximum_flow', 'maximum_flow_unit', label=_('Maximum flow')),
+            'heat_capacity', 'mark_connected', 'description', 'tags',
         ),
     )
 
@@ -1678,7 +1680,7 @@ class CoolingPortForm(ModularDeviceComponentForm):
         model = CoolingPort
         fields = [
             'device', 'module', 'name', 'label', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow',
-            'heat_capacity', 'mark_connected', 'description', 'owner', 'tags',
+            'maximum_flow_unit', 'heat_capacity', 'mark_connected', 'description', 'owner', 'tags',
         ]
 
 

+ 69 - 49
netbox/dcim/migrations/0241_device_cooling_method_device_cooling_outlet_count_and_more.py

@@ -1,4 +1,4 @@
-# Generated by Django 6.0.6 on 2026-06-24 21:45
+# Generated by Django 6.0.6 on 2026-06-25 22:43
 
 import django.contrib.postgres.fields
 import django.core.validators
@@ -161,6 +161,26 @@ class Migration(migrations.Migration):
                         blank=True, decimal_places=4, max_digits=13, null=True
                     ),
                 ),
+                (
+                    "maximum_flow",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(0)],
+                    ),
+                ),
+                (
+                    "maximum_flow_unit",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                (
+                    "_abs_maximum_flow",
+                    models.DecimalField(
+                        blank=True, decimal_places=4, max_digits=13, null=True
+                    ),
+                ),
                 ("name", models.CharField(db_collation="natural_sort", max_length=64)),
                 ("label", models.CharField(blank=True, max_length=64)),
                 ("description", models.CharField(blank=True, max_length=200)),
@@ -195,16 +215,6 @@ class Migration(migrations.Migration):
                     "connector_type",
                     models.CharField(blank=True, max_length=50, null=True),
                 ),
-                (
-                    "maximum_flow",
-                    models.DecimalField(
-                        blank=True,
-                        decimal_places=2,
-                        max_digits=8,
-                        null=True,
-                        validators=[django.core.validators.MinValueValidator(0)],
-                    ),
-                ),
                 (
                     "heat_capacity",
                     models.DecimalField(
@@ -514,14 +524,6 @@ class Migration(migrations.Migration):
                         blank=True, decimal_places=4, max_digits=13, null=True
                     ),
                 ),
-                ("name", models.CharField(db_collation="natural_sort", max_length=64)),
-                ("label", models.CharField(blank=True, max_length=64)),
-                ("description", models.CharField(blank=True, max_length=200)),
-                ("type", models.CharField(blank=True, max_length=50, null=True)),
-                (
-                    "connector_type",
-                    models.CharField(blank=True, max_length=50, null=True),
-                ),
                 (
                     "maximum_flow",
                     models.DecimalField(
@@ -532,6 +534,24 @@ class Migration(migrations.Migration):
                         validators=[django.core.validators.MinValueValidator(0)],
                     ),
                 ),
+                (
+                    "maximum_flow_unit",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                (
+                    "_abs_maximum_flow",
+                    models.DecimalField(
+                        blank=True, decimal_places=4, max_digits=13, null=True
+                    ),
+                ),
+                ("name", models.CharField(db_collation="natural_sort", max_length=64)),
+                ("label", models.CharField(blank=True, max_length=64)),
+                ("description", models.CharField(blank=True, max_length=200)),
+                ("type", models.CharField(blank=True, max_length=50, null=True)),
+                (
+                    "connector_type",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
                 (
                     "heat_capacity",
                     models.DecimalField(
@@ -673,8 +693,6 @@ class Migration(migrations.Migration):
                         encoder=utilities.json.CustomFieldJSONEncoder,
                     ),
                 ),
-                ("description", models.CharField(blank=True, max_length=200)),
-                ("comments", models.TextField(blank=True)),
                 (
                     "supply_temperature",
                     models.DecimalField(
@@ -703,6 +721,8 @@ class Migration(migrations.Migration):
                         blank=True, decimal_places=4, max_digits=8, null=True
                     ),
                 ),
+                ("description", models.CharField(blank=True, max_length=200)),
+                ("comments", models.TextField(blank=True)),
                 ("name", models.CharField(db_collation="natural_sort", max_length=100)),
                 ("type", models.CharField(max_length=50)),
                 ("status", models.CharField(default="active", max_length=50)),
@@ -814,34 +834,6 @@ class Migration(migrations.Migration):
                         blank=True, decimal_places=4, max_digits=13, null=True
                     ),
                 ),
-                ("description", models.CharField(blank=True, max_length=200)),
-                ("comments", models.TextField(blank=True)),
-                ("cable_end", models.CharField(blank=True, max_length=1, null=True)),
-                (
-                    "cable_connector",
-                    models.PositiveSmallIntegerField(
-                        blank=True,
-                        null=True,
-                        validators=[
-                            django.core.validators.MinValueValidator(1),
-                            django.core.validators.MaxValueValidator(256),
-                        ],
-                    ),
-                ),
-                (
-                    "cable_positions",
-                    django.contrib.postgres.fields.ArrayField(
-                        base_field=models.PositiveSmallIntegerField(
-                            validators=[
-                                django.core.validators.MinValueValidator(1),
-                                django.core.validators.MaxValueValidator(1024),
-                            ]
-                        ),
-                        blank=True,
-                        null=True,
-                    ),
-                ),
-                ("mark_connected", models.BooleanField(default=False)),
                 (
                     "supply_temperature",
                     models.DecimalField(
@@ -870,6 +862,34 @@ class Migration(migrations.Migration):
                         blank=True, decimal_places=4, max_digits=8, null=True
                     ),
                 ),
+                ("description", models.CharField(blank=True, max_length=200)),
+                ("comments", models.TextField(blank=True)),
+                ("cable_end", models.CharField(blank=True, max_length=1, null=True)),
+                (
+                    "cable_connector",
+                    models.PositiveSmallIntegerField(
+                        blank=True,
+                        null=True,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(256),
+                        ],
+                    ),
+                ),
+                (
+                    "cable_positions",
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.PositiveSmallIntegerField(
+                            validators=[
+                                django.core.validators.MinValueValidator(1),
+                                django.core.validators.MaxValueValidator(1024),
+                            ]
+                        ),
+                        blank=True,
+                        null=True,
+                    ),
+                ),
+                ("mark_connected", models.BooleanField(default=False)),
                 ("name", models.CharField(db_collation="natural_sort", max_length=100)),
                 ("status", models.CharField(default="active", max_length=50)),
                 ("type", models.CharField(default="supply", max_length=50)),

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

@@ -12,7 +12,7 @@ from dcim.models.mixins import InterfaceValidationMixin
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from netbox.models import ChangeLoggedModel
 from netbox.models.ltree import LtreeManager, LtreeModel
-from netbox.models.mixins import DiameterMixin
+from netbox.models.mixins import DiameterMixin, MaximumFlowMixin
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.ordering import naturalize_interface
 from utilities.tracking import TrackingModelMixin
@@ -437,7 +437,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
         }
 
 
-class CoolingPortTemplate(DiameterMixin, ModularComponentTemplateModel):
+class CoolingPortTemplate(DiameterMixin, MaximumFlowMixin, ModularComponentTemplateModel):
     """
     A template for a CoolingPort to be created for a new Device.
     """
@@ -456,15 +456,7 @@ class CoolingPortTemplate(DiameterMixin, ModularComponentTemplateModel):
         null=True
     )
     # diameter, diameter_unit, _abs_diameter provided by DiameterMixin
-    maximum_flow = models.DecimalField(
-        verbose_name=_('maximum flow'),
-        max_digits=8,
-        decimal_places=2,
-        blank=True,
-        null=True,
-        validators=[MinValueValidator(0)],
-        help_text=_('Maximum coolant flow rate (L/min)')
-    )
+    # maximum_flow, maximum_flow_unit, _abs_maximum_flow provided by MaximumFlowMixin
     heat_capacity = models.DecimalField(
         verbose_name=_('heat capacity'),
         max_digits=8,
@@ -490,6 +482,7 @@ class CoolingPortTemplate(DiameterMixin, ModularComponentTemplateModel):
             diameter=self.diameter,
             diameter_unit=self.diameter_unit,
             maximum_flow=self.maximum_flow,
+            maximum_flow_unit=self.maximum_flow_unit,
             heat_capacity=self.heat_capacity,
             **kwargs
         )
@@ -503,6 +496,7 @@ class CoolingPortTemplate(DiameterMixin, ModularComponentTemplateModel):
             'diameter': float(self.diameter) if self.diameter is not None else None,
             'diameter_unit': self.diameter_unit,
             'maximum_flow': float(self.maximum_flow) if self.maximum_flow is not None else None,
+            'maximum_flow_unit': self.maximum_flow_unit,
             'heat_capacity': float(self.heat_capacity) if self.heat_capacity is not None else None,
             'label': self.label,
             'description': self.description,

+ 7 - 12
netbox/dcim/models/device_components.py

@@ -16,7 +16,7 @@ from dcim.models.mixins import InterfaceValidationMixin
 from netbox.choices import ColorChoices
 from netbox.models import NetBoxModel, OrganizationalModel
 from netbox.models.ltree import LtreeManager, LtreeModel, SortPathField
-from netbox.models.mixins import DiameterMixin, OwnerMixin
+from netbox.models.mixins import DiameterMixin, MaximumFlowMixin, OwnerMixin
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.ordering import naturalize_interface
 from utilities.query_functions import CollateAsChar
@@ -689,7 +689,9 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
 # Cooling components
 #
 
-class CoolingPort(DiameterMixin, ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
+class CoolingPort(
+    DiameterMixin, MaximumFlowMixin, ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin
+):
     """
     A coolant intake/outlet port within a Device (e.g. a server cold-plate inlet or CDU intake).
     CoolingPorts connect to CoolingOutlets.
@@ -711,15 +713,7 @@ class CoolingPort(DiameterMixin, ModularComponentModel, CabledObjectModel, PathE
         help_text=_('Physical connector type')
     )
     # diameter, diameter_unit, _abs_diameter provided by DiameterMixin
-    maximum_flow = models.DecimalField(
-        verbose_name=_('maximum flow'),
-        max_digits=8,
-        decimal_places=2,
-        blank=True,
-        null=True,
-        validators=[MinValueValidator(0)],
-        help_text=_('Maximum coolant flow rate (L/min)')
-    )
+    # maximum_flow, maximum_flow_unit, _abs_maximum_flow provided by MaximumFlowMixin
     heat_capacity = models.DecimalField(
         verbose_name=_('heat capacity'),
         max_digits=8,
@@ -731,7 +725,8 @@ class CoolingPort(DiameterMixin, ModularComponentModel, CabledObjectModel, PathE
     )
 
     clone_fields = (
-        'device', 'module', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow', 'heat_capacity',
+        'device', 'module', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow',
+        'maximum_flow_unit', 'heat_capacity',
     )
 
     class Meta(ModularComponentModel.Meta):

+ 54 - 0
netbox/netbox/models/mixins.py

@@ -18,6 +18,7 @@ __all__ = (
     'DiameterMixin',
     'DistanceMixin',
     'FlowRateMixin',
+    'MaximumFlowMixin',
     'OwnerMixin',
     'PressureMixin',
     'WeightMixin',
@@ -192,6 +193,59 @@ class FlowRateMixin(models.Model):
             raise ValidationError(_("Must specify a unit when setting a flow rate"))
 
 
+class MaximumFlowMixin(models.Model):
+    maximum_flow = models.DecimalField(
+        verbose_name=_('maximum flow'),
+        max_digits=8,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(0)],
+    )
+    maximum_flow_unit = models.CharField(
+        verbose_name=_('maximum flow unit'),
+        max_length=50,
+        choices=FlowRateUnitChoices,
+        blank=True,
+        null=True,
+    )
+    # Stores the normalized maximum flow (in liters per minute) for database ordering
+    _abs_maximum_flow = models.DecimalField(
+        max_digits=13,
+        decimal_places=4,
+        blank=True,
+        null=True
+    )
+
+    class Meta:
+        abstract = True
+
+    @property
+    def abs_maximum_flow(self):
+        # Public alias for _abs_maximum_flow; Django templates cannot access underscore-prefixed attributes.
+        return self._abs_maximum_flow
+
+    def save(self, *args, **kwargs):
+        # Store the given maximum flow (if any) in liters per minute for use in database ordering
+        if self.maximum_flow is not None and self.maximum_flow_unit:
+            self._abs_maximum_flow = to_liters_per_minute(self.maximum_flow, self.maximum_flow_unit)
+        else:
+            self._abs_maximum_flow = None
+
+        # Clear maximum_flow_unit if no maximum flow is defined
+        if self.maximum_flow is None:
+            self.maximum_flow_unit = None
+
+        super().save(*args, **kwargs)
+
+    def clean(self):
+        super().clean()
+
+        # Validate maximum_flow and maximum_flow_unit
+        if self.maximum_flow is not None and not self.maximum_flow_unit:
+            raise ValidationError(_("Must specify a unit when setting a maximum flow"))
+
+
 class PressureMixin(models.Model):
     pressure = models.DecimalField(
         verbose_name=_('pressure'),