Arthur 1 Minggu lalu
induk
melakukan
585c6a74e5

+ 1 - 1
docs/models/dcim/coolingport.md

@@ -37,7 +37,7 @@ The connector diameter, expressed as a numeric value with a selectable unit (mil
 
 
 ### Maximum Flow
 ### Maximum Flow
 
 
-The maximum coolant flow rate this port supports, in litres per minute (L/min).
+The maximum coolant flow rate this port supports, expressed as a numeric value with a selectable unit (litres per minute, cubic meters per hour, or gallons per minute).
 
 
 ### Heat Capacity
 ### Heat Capacity
 
 

+ 13 - 3
netbox/dcim/filtersets.py

@@ -1015,12 +1015,17 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
 
 
 @register_filterset
 @register_filterset
 class CoolingPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 class CoolingPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
+    maximum_flow_unit = django_filters.MultipleChoiceFilter(
+        choices=FlowRateUnitChoices,
+        distinct=False,
+        null_value=None
+    )
 
 
     class Meta:
     class Meta:
         model = CoolingPortTemplate
         model = CoolingPortTemplate
         fields = (
         fields = (
             'id', 'name', 'label', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow',
             'id', 'name', 'label', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow',
-            'heat_capacity', 'description',
+            'maximum_flow_unit', 'heat_capacity', 'description',
         )
         )
 
 
 
 
@@ -2084,12 +2089,17 @@ class CoolingPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
         distinct=False,
         distinct=False,
         null_value=None
         null_value=None
     )
     )
+    maximum_flow_unit = django_filters.MultipleChoiceFilter(
+        choices=FlowRateUnitChoices,
+        distinct=False,
+        null_value=None
+    )
 
 
     class Meta:
     class Meta:
         model = CoolingPort
         model = CoolingPort
         fields = (
         fields = (
-            'id', 'name', 'label', 'diameter', 'diameter_unit', 'maximum_flow', 'heat_capacity', 'description',
-            'mark_connected', 'cable_end', 'cable_connector',
+            'id', 'name', 'label', 'diameter', 'diameter_unit', 'maximum_flow', 'maximum_flow_unit', 'heat_capacity',
+            'description', 'mark_connected', 'cable_end', 'cable_connector',
         )
         )
 
 
 
 

+ 6 - 3
netbox/dcim/forms/bulk_create.py

@@ -86,14 +86,17 @@ class PowerOutletBulkCreateForm(
 class CoolingPortBulkCreateForm(
 class CoolingPortBulkCreateForm(
     form_from_model(
     form_from_model(
         CoolingPort,
         CoolingPort,
-        ['type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow', 'heat_capacity', 'mark_connected']
+        [
+            'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow', 'maximum_flow_unit',
+            'heat_capacity', 'mark_connected'
+        ]
     ),
     ),
     DeviceBulkAddComponentForm
     DeviceBulkAddComponentForm
 ):
 ):
     model = CoolingPort
     model = CoolingPort
     field_order = (
     field_order = (
-        'name', 'label', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow', 'heat_capacity',
-        'mark_connected', 'description', 'tags',
+        'name', 'label', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow', 'maximum_flow_unit',
+        'heat_capacity', 'mark_connected', 'description', 'tags',
     )
     )
 
 
 
 

+ 13 - 5
netbox/dcim/forms/bulk_edit.py

@@ -1799,8 +1799,8 @@ class PowerOutletBulkEditForm(
 class CoolingPortBulkEditForm(
 class CoolingPortBulkEditForm(
     ComponentBulkEditForm,
     ComponentBulkEditForm,
     form_from_model(CoolingPort, [
     form_from_model(CoolingPort, [
-        'label', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow', 'heat_capacity',
-        'mark_connected', 'description'
+        'label', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow', 'maximum_flow_unit',
+        'heat_capacity', 'mark_connected', 'description'
     ])
     ])
 ):
 ):
     mark_connected = forms.NullBooleanField(
     mark_connected = forms.NullBooleanField(
@@ -1808,6 +1808,11 @@ class CoolingPortBulkEditForm(
         required=False,
         required=False,
         widget=BulkEditNullBooleanSelect
         widget=BulkEditNullBooleanSelect
     )
     )
+    maximum_flow_unit = forms.ChoiceField(
+        label=_('Maximum flow unit'),
+        choices=add_blank_choice(FlowRateUnitChoices),
+        required=False
+    )
 
 
     model = CoolingPort
     model = CoolingPort
     fieldsets = (
     fieldsets = (
@@ -1816,11 +1821,14 @@ class CoolingPortBulkEditForm(
             InlineFields('diameter', 'diameter_unit', label=_('Diameter')),
             InlineFields('diameter', 'diameter_unit', label=_('Diameter')),
             'label', 'description', 'mark_connected',
             'label', 'description', 'mark_connected',
         ),
         ),
-        FieldSet('maximum_flow', 'heat_capacity', name=_('Characteristics')),
+        FieldSet(
+            InlineFields('maximum_flow', 'maximum_flow_unit', label=_('Maximum flow')),
+            'heat_capacity', name=_('Characteristics')
+        ),
     )
     )
     nullable_fields = (
     nullable_fields = (
-        'module', 'label', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow', 'heat_capacity',
-        'description',
+        'module', 'label', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow', 'maximum_flow_unit',
+        'heat_capacity', 'description',
     )
     )
 
 
 
 

+ 6 - 1
netbox/dcim/forms/bulk_import.py

@@ -1000,12 +1000,17 @@ class CoolingPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         required=False,
         required=False,
         help_text=_('Diameter unit')
         help_text=_('Diameter unit')
     )
     )
+    maximum_flow_unit = CSVChoiceField(
+        choices=FlowRateUnitChoices,
+        required=False,
+        help_text=_('Unit for maximum flow')
+    )
 
 
     class Meta:
     class Meta:
         model = CoolingPort
         model = CoolingPort
         fields = (
         fields = (
             'device', 'name', 'label', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow',
             'device', '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',
         )
         )
 
 
 
 

+ 18 - 2
netbox/dcim/forms/filtersets.py

@@ -1847,7 +1847,10 @@ class CoolingPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = CoolingPort
     model = CoolingPort
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('name', 'label', 'type', 'connector_type', 'diameter', 'diameter_unit', name=_('Attributes')),
+        FieldSet(
+            'name', 'label', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow_unit',
+            name=_('Attributes')
+        ),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
             'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
@@ -1875,6 +1878,11 @@ class CoolingPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         choices=add_blank_choice(DiameterUnitChoices),
         choices=add_blank_choice(DiameterUnitChoices),
         required=False
         required=False
     )
     )
+    maximum_flow_unit = forms.MultipleChoiceField(
+        label=_('Maximum flow unit'),
+        choices=FlowRateUnitChoices,
+        required=False
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
@@ -1882,7 +1890,10 @@ class CoolingPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
     model = CoolingPortTemplate
     model = CoolingPortTemplate
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('name', 'label', 'type', 'connector_type', 'diameter', 'diameter_unit', name=_('Attributes')),
+        FieldSet(
+            'name', 'label', 'type', 'connector_type', 'diameter', 'diameter_unit', 'maximum_flow_unit',
+            name=_('Attributes')
+        ),
         FieldSet('device_type_id', 'module_type_id', name=_('Device')),
         FieldSet('device_type_id', 'module_type_id', name=_('Device')),
     )
     )
     type = forms.MultipleChoiceField(
     type = forms.MultipleChoiceField(
@@ -1904,6 +1915,11 @@ class CoolingPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
         choices=add_blank_choice(DiameterUnitChoices),
         choices=add_blank_choice(DiameterUnitChoices),
         required=False
         required=False
     )
     )
+    maximum_flow_unit = forms.MultipleChoiceField(
+        label=_('Maximum flow unit'),
+        choices=FlowRateUnitChoices,
+        required=False
+    )
 
 
 
 
 class CoolingOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class CoolingOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):

+ 1 - 1
netbox/dcim/forms/object_import.py

@@ -88,7 +88,7 @@ class CoolingPortTemplateImportForm(forms.ModelForm):
         model = CoolingPortTemplate
         model = CoolingPortTemplate
         fields = [
         fields = [
             'device_type', 'module_type', 'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow',
             'device_type', 'module_type', 'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow',
-            'heat_capacity', 'description',
+            'maximum_flow_unit', 'heat_capacity', 'description',
         ]
         ]
 
 
 
 

+ 6 - 0
netbox/dcim/graphql/filters.py

@@ -1050,6 +1050,9 @@ class CoolingPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixi
     maximum_flow: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     maximum_flow: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
+    maximum_flow_unit: BaseFilterLookup[
+        Annotated['FlowRateUnitEnum', strawberry.lazy('dcim.graphql.enums')]
+    ] | None = strawberry_django.filter_field()
     heat_capacity: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     heat_capacity: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -1072,6 +1075,9 @@ class CoolingPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLogge
     maximum_flow: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     maximum_flow: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
+    maximum_flow_unit: BaseFilterLookup[
+        Annotated['FlowRateUnitEnum', strawberry.lazy('dcim.graphql.enums')]
+    ] | None = strawberry_django.filter_field()
     heat_capacity: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     heat_capacity: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )

+ 2 - 2
netbox/dcim/graphql/types.py

@@ -828,7 +828,7 @@ class CoolingOutletTemplateType(ModularComponentTemplateType):
 
 
 @strawberry_django.type(
 @strawberry_django.type(
     models.CoolingPort,
     models.CoolingPort,
-    exclude=['_path', '_abs_diameter'],
+    exclude=['_path', '_abs_diameter', '_abs_maximum_flow'],
     filters=CoolingPortFilter,
     filters=CoolingPortFilter,
     pagination=True
     pagination=True
 )
 )
@@ -839,7 +839,7 @@ class CoolingPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin
 
 
 @strawberry_django.type(
 @strawberry_django.type(
     models.CoolingPortTemplate,
     models.CoolingPortTemplate,
-    exclude=['_abs_diameter'],
+    exclude=['_abs_diameter', '_abs_maximum_flow'],
     filters=CoolingPortTemplateFilter,
     filters=CoolingPortTemplateFilter,
     pagination=True
     pagination=True
 )
 )

+ 12 - 0
netbox/dcim/migrations/0241_device_cooling_method_device_cooling_outlet_count_and_more.py

@@ -10,6 +10,7 @@ import netbox.models.deletion
 import utilities.fields
 import utilities.fields
 import utilities.json
 import utilities.json
 import utilities.tracking
 import utilities.tracking
+from utilities.migration import InstallDenormalizationTrigger
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
@@ -1042,4 +1043,15 @@ class Migration(migrations.Migration):
                 name="dcim_coolingfeed_unique_cooling_source_name",
                 name="dcim_coolingfeed_unique_cooling_source_name",
             ),
             ),
         ),
         ),
+        # Install denormalized device → component triggers for the cooling device components,
+        # mirroring the triggers created for the other device components in migration 0239.
+        *[
+            InstallDenormalizationTrigger(
+                dependent_table=table,
+                source_table="dcim_device",
+                fk_column="device_id",
+                mappings={"_site_id": "site_id", "_location_id": "location_id", "_rack_id": "rack_id"},
+            )
+            for table in ("dcim_coolingport", "dcim_coolingoutlet")
+        ],
     ]
     ]

+ 0 - 32
netbox/dcim/migrations/0242_cooling_denormalization_triggers.py

@@ -1,32 +0,0 @@
-"""
-Install denormalized device → component triggers for the cooling device components (CoolingPort,
-CoolingOutlet), mirroring the triggers created for the other device components in migration 0239.
-"""
-from django.db import migrations
-
-from utilities.migration import InstallDenormalizationTrigger
-
-# Cooling device component tables carrying _site/_location/_rack denormalized from their parent Device.
-COMPONENT_TABLES = (
-    'dcim_coolingport',
-    'dcim_coolingoutlet',
-)
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('dcim', '0241_device_cooling_method_device_cooling_outlet_count_and_more'),
-    ]
-
-    operations = [
-        *[
-            InstallDenormalizationTrigger(
-                dependent_table=table,
-                source_table='dcim_device',
-                fk_column='device_id',
-                mappings={'_site_id': 'site_id', '_location_id': 'location_id', '_rack_id': 'rack_id'},
-            )
-            for table in COMPONENT_TABLES
-        ],
-    ]

+ 18 - 7
netbox/dcim/tables/cooling.py

@@ -193,7 +193,11 @@ class CoolingPortTable(ModularDeviceComponentTable, PathEndpointTable):
         order_by=('_abs_diameter', 'diameter_unit')
         order_by=('_abs_diameter', 'diameter_unit')
     )
     )
     maximum_flow = tables.Column(
     maximum_flow = tables.Column(
-        verbose_name=_('Maximum flow (L/min)')
+        verbose_name=_('Maximum flow'),
+        order_by=('_abs_maximum_flow',)
+    )
+    maximum_flow_unit = columns.ChoiceFieldColumn(
+        verbose_name=_('Maximum Flow Unit')
     )
     )
     heat_capacity = tables.Column(
     heat_capacity = tables.Column(
         verbose_name=_('Heat capacity (kW)')
         verbose_name=_('Heat capacity (kW)')
@@ -206,8 +210,8 @@ class CoolingPortTable(ModularDeviceComponentTable, PathEndpointTable):
         model = models.CoolingPort
         model = models.CoolingPort
         fields = (
         fields = (
             'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'connector_type', 'diameter',
             'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'connector_type', 'diameter',
-            'description', 'mark_connected', 'maximum_flow', 'heat_capacity', 'cable', 'cable_color', 'link_peer',
-            'connection', 'inventory_items', 'tags', 'created', 'last_updated',
+            'description', 'mark_connected', 'maximum_flow', 'maximum_flow_unit', 'heat_capacity', 'cable',
+            'cable_color', 'link_peer', 'connection', 'inventory_items', 'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'device', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity',
             'pk', 'name', 'device', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity',
@@ -270,6 +274,13 @@ class CoolingPortTemplateTable(ComponentTemplateTable):
         template_code=DIAMETER,
         template_code=DIAMETER,
         order_by=('_abs_diameter', 'diameter_unit')
         order_by=('_abs_diameter', 'diameter_unit')
     )
     )
+    maximum_flow = tables.Column(
+        verbose_name=_('Maximum flow'),
+        order_by=('_abs_maximum_flow',)
+    )
+    maximum_flow_unit = columns.ChoiceFieldColumn(
+        verbose_name=_('Maximum Flow Unit')
+    )
     actions = columns.ActionsColumn(
     actions = columns.ActionsColumn(
         actions=('edit', 'delete'),
         actions=('edit', 'delete'),
         extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
         extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
@@ -278,8 +289,8 @@ class CoolingPortTemplateTable(ComponentTemplateTable):
     class Meta(ComponentTemplateTable.Meta):
     class Meta(ComponentTemplateTable.Meta):
         model = models.CoolingPortTemplate
         model = models.CoolingPortTemplate
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity',
-            'description', 'actions',
+            'pk', 'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'maximum_flow_unit',
+            'heat_capacity', 'description', 'actions',
         )
         )
         empty_text = "None"
         empty_text = "None"
 
 
@@ -329,8 +340,8 @@ class DeviceCoolingPortTable(CoolingPortTable):
         model = models.CoolingPort
         model = models.CoolingPort
         fields = (
         fields = (
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow',
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow',
-            'heat_capacity', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
-            'actions',
+            'maximum_flow_unit', 'heat_capacity', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer',
+            'connection', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity',
             'pk', 'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity',

+ 18 - 0
netbox/dcim/tests/test_filtersets.py

@@ -2066,6 +2066,7 @@ class CoolingPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTest
                 diameter=Decimal('25'),
                 diameter=Decimal('25'),
                 diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
                 diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
                 maximum_flow=100,
                 maximum_flow=100,
+                maximum_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
                 heat_capacity=50,
                 heat_capacity=50,
                 description='foobar1'
                 description='foobar1'
             ),
             ),
@@ -2077,6 +2078,7 @@ class CoolingPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTest
                 diameter=Decimal('32'),
                 diameter=Decimal('32'),
                 diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
                 diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
                 maximum_flow=200,
                 maximum_flow=200,
+                maximum_flow_unit=FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR,
                 heat_capacity=100,
                 heat_capacity=100,
                 description='foobar2'
                 description='foobar2'
             ),
             ),
@@ -2088,6 +2090,7 @@ class CoolingPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTest
                 diameter=Decimal('40'),
                 diameter=Decimal('40'),
                 diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
                 diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
                 maximum_flow=300,
                 maximum_flow=300,
+                maximum_flow_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE,
                 heat_capacity=150,
                 heat_capacity=150,
                 description='foobar3'
                 description='foobar3'
             ),
             ),
@@ -2113,6 +2116,12 @@ class CoolingPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTest
         params = {'maximum_flow': [100, 200]}
         params = {'maximum_flow': [100, 200]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_maximum_flow_unit(self):
+        params = {'maximum_flow_unit': [
+            FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE, FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR
+        ]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_heat_capacity(self):
     def test_heat_capacity(self):
         params = {'heat_capacity': [50, 100]}
         params = {'heat_capacity': [50, 100]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -4816,6 +4825,7 @@ class CoolingPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
                 diameter=Decimal('25'),
                 diameter=Decimal('25'),
                 diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
                 diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
                 maximum_flow=100,
                 maximum_flow=100,
+                maximum_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
                 heat_capacity=50,
                 heat_capacity=50,
                 description='First',
                 description='First',
                 _site=devices[0].site,
                 _site=devices[0].site,
@@ -4832,6 +4842,7 @@ class CoolingPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
                 diameter=Decimal('32'),
                 diameter=Decimal('32'),
                 diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
                 diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
                 maximum_flow=200,
                 maximum_flow=200,
+                maximum_flow_unit=FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR,
                 heat_capacity=100,
                 heat_capacity=100,
                 description='Second',
                 description='Second',
                 _site=devices[1].site,
                 _site=devices[1].site,
@@ -4848,6 +4859,7 @@ class CoolingPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
                 diameter=Decimal('40'),
                 diameter=Decimal('40'),
                 diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
                 diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
                 maximum_flow=300,
                 maximum_flow=300,
+                maximum_flow_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE,
                 heat_capacity=150,
                 heat_capacity=150,
                 description='Third',
                 description='Third',
                 _site=devices[2].site,
                 _site=devices[2].site,
@@ -4890,6 +4902,12 @@ class CoolingPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
         params = {'maximum_flow': [100, 200]}
         params = {'maximum_flow': [100, 200]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_maximum_flow_unit(self):
+        params = {'maximum_flow_unit': [
+            FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE, FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR
+        ]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_heat_capacity(self):
     def test_heat_capacity(self):
         params = {'heat_capacity': [50, 100]}
         params = {'heat_capacity': [50, 100]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 3 - 1
netbox/dcim/tests/test_models.py

@@ -11,7 +11,7 @@ from dcim.models import *
 from extras.events import serialize_for_event
 from extras.events import serialize_for_event
 from extras.models import CustomField
 from extras.models import CustomField
 from ipam.models import Prefix
 from ipam.models import Prefix
-from netbox.choices import DiameterUnitChoices, TemperatureUnitChoices, WeightUnitChoices
+from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices, TemperatureUnitChoices, WeightUnitChoices
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.data import drange
 from utilities.data import drange
 from virtualization.models import Cluster, ClusterType
 from virtualization.models import Cluster, ClusterType
@@ -2864,6 +2864,7 @@ class CoolingComponentTestCase(TestCase):
             diameter=Decimal('25'),
             diameter=Decimal('25'),
             diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
             diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
             maximum_flow=100,
             maximum_flow=100,
+            maximum_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
             heat_capacity=50
             heat_capacity=50
         )
         )
         CoolingOutletTemplate.objects.create(
         CoolingOutletTemplate.objects.create(
@@ -2890,6 +2891,7 @@ class CoolingComponentTestCase(TestCase):
             diameter=Decimal('25'),
             diameter=Decimal('25'),
             diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
             diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
             maximum_flow=100,
             maximum_flow=100,
+            maximum_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
             heat_capacity=50
             heat_capacity=50
         )
         )
         self.assertEqual(cooling_port_template.maximum_flow, cooling_port.maximum_flow)
         self.assertEqual(cooling_port_template.maximum_flow, cooling_port.maximum_flow)

+ 6 - 0
netbox/dcim/tests/test_views.py

@@ -4263,6 +4263,7 @@ class CoolingPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
             'diameter': Decimal('25'),
             'diameter': Decimal('25'),
             'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
             'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
             'maximum_flow': 100,
             'maximum_flow': 100,
+            'maximum_flow_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
             'heat_capacity': 50,
             'heat_capacity': 50,
         }
         }
 
 
@@ -4274,6 +4275,7 @@ class CoolingPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
             'diameter': Decimal('25'),
             'diameter': Decimal('25'),
             'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
             'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
             'maximum_flow': 100,
             'maximum_flow': 100,
+            'maximum_flow_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
             'heat_capacity': 50,
             'heat_capacity': 50,
         }
         }
 
 
@@ -4283,6 +4285,7 @@ class CoolingPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
             'diameter': Decimal('25'),
             'diameter': Decimal('25'),
             'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
             'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
             'maximum_flow': 100,
             'maximum_flow': 100,
+            'maximum_flow_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
             'heat_capacity': 50,
             'heat_capacity': 50,
         }
         }
 
 
@@ -4360,6 +4363,7 @@ class CoolingPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'diameter': Decimal('25'),
             'diameter': Decimal('25'),
             'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
             'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
             'maximum_flow': 100,
             'maximum_flow': 100,
+            'maximum_flow_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
             'heat_capacity': 50,
             'heat_capacity': 50,
             'description': 'A cooling port',
             'description': 'A cooling port',
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
@@ -4373,6 +4377,7 @@ class CoolingPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'diameter': Decimal('25'),
             'diameter': Decimal('25'),
             'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
             'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
             'maximum_flow': 100,
             'maximum_flow': 100,
+            'maximum_flow_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
             'heat_capacity': 50,
             'heat_capacity': 50,
             'description': 'A cooling port',
             'description': 'A cooling port',
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
@@ -4384,6 +4389,7 @@ class CoolingPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'diameter': Decimal('25'),
             'diameter': Decimal('25'),
             'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
             'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
             'maximum_flow': 100,
             'maximum_flow': 100,
+            'maximum_flow_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
             'heat_capacity': 50,
             'heat_capacity': 50,
             'description': 'New description',
             'description': 'New description',
         }
         }

+ 1 - 1
netbox/dcim/ui/panels.py

@@ -253,7 +253,7 @@ class CoolingPortPanel(panels.ObjectAttributesPanel):
     connector_type = attrs.ChoiceAttr('connector_type')
     connector_type = attrs.ChoiceAttr('connector_type')
     diameter = attrs.NumericAttr('diameter', unit_accessor='get_diameter_unit_display')
     diameter = attrs.NumericAttr('diameter', unit_accessor='get_diameter_unit_display')
     description = attrs.TextAttr('description')
     description = attrs.TextAttr('description')
-    maximum_flow = attrs.TextAttr('maximum_flow', format_string=_('{} L/min'))
+    maximum_flow = attrs.NumericAttr('maximum_flow', unit_accessor='get_maximum_flow_unit_display')
     heat_capacity = attrs.TextAttr('heat_capacity', format_string=_('{} kW'))
     heat_capacity = attrs.TextAttr('heat_capacity', format_string=_('{} kW'))
 
 
 
 

+ 82 - 2
netbox/utilities/tests/test_conversions.py

@@ -1,8 +1,21 @@
 from decimal import Decimal
 from decimal import Decimal
 
 
 from dcim.choices import CableLengthUnitChoices
 from dcim.choices import CableLengthUnitChoices
-from netbox.choices import WeightUnitChoices
-from utilities.conversion import to_grams, to_meters
+from netbox.choices import (
+    DiameterUnitChoices,
+    FlowRateUnitChoices,
+    PressureUnitChoices,
+    TemperatureUnitChoices,
+    WeightUnitChoices,
+)
+from utilities.conversion import (
+    to_celsius,
+    to_grams,
+    to_kilopascals,
+    to_liters_per_minute,
+    to_meters,
+    to_millimeters,
+)
 from utilities.testing.base import TestCase
 from utilities.testing.base import TestCase
 
 
 
 
@@ -51,3 +64,70 @@ class ConversionsTestCase(TestCase):
             to_meters(1, CableLengthUnitChoices.UNIT_INCH),
             to_meters(1, CableLengthUnitChoices.UNIT_INCH),
             Decimal('0.0254')
             Decimal('0.0254')
         )
         )
+
+    def test_to_celsius(self):
+        self.assertEqual(
+            to_celsius(20, TemperatureUnitChoices.UNIT_CELSIUS),
+            Decimal('20')
+        )
+        self.assertEqual(
+            to_celsius(68, TemperatureUnitChoices.UNIT_FAHRENHEIT),
+            Decimal('20')
+        )
+        self.assertEqual(
+            to_celsius(-4, TemperatureUnitChoices.UNIT_FAHRENHEIT),
+            Decimal('-20')
+        )
+        with self.assertRaises(ValueError):
+            to_celsius(20, 'invalid')
+
+    def test_to_millimeters(self):
+        self.assertEqual(
+            to_millimeters(1, DiameterUnitChoices.UNIT_MILLIMETER),
+            Decimal('1')
+        )
+        self.assertEqual(
+            to_millimeters(1, DiameterUnitChoices.UNIT_CENTIMETER),
+            Decimal('10')
+        )
+        self.assertEqual(
+            to_millimeters(1, DiameterUnitChoices.UNIT_INCH),
+            Decimal('25.4')
+        )
+        with self.assertRaises(ValueError):
+            to_millimeters(1, 'invalid')
+
+    def test_to_liters_per_minute(self):
+        self.assertEqual(
+            to_liters_per_minute(10, FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE),
+            Decimal('10')
+        )
+        self.assertAlmostEqual(
+            to_liters_per_minute(6, FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR),
+            Decimal('100'),
+            places=4
+        )
+        self.assertAlmostEqual(
+            to_liters_per_minute(10, FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE),
+            Decimal('37.8541'),
+            places=4
+        )
+        with self.assertRaises(ValueError):
+            to_liters_per_minute(10, 'invalid')
+
+    def test_to_kilopascals(self):
+        self.assertEqual(
+            to_kilopascals(1, PressureUnitChoices.UNIT_KILOPASCAL),
+            Decimal('1')
+        )
+        self.assertEqual(
+            to_kilopascals(1, PressureUnitChoices.UNIT_BAR),
+            Decimal('100')
+        )
+        self.assertAlmostEqual(
+            to_kilopascals(30, PressureUnitChoices.UNIT_PSI),
+            Decimal('206.8427'),
+            places=4
+        )
+        with self.assertRaises(ValueError):
+            to_kilopascals(30, 'invalid')