Просмотр исходного кода

metric/standard for flow and pressure

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

+ 2 - 2
docs/models/dcim/coolingfeed.md

@@ -39,11 +39,11 @@ The heat-removal capacity of the feed, in kilowatts (kW).
 
 ### Flow Rate
 
-The coolant flow rate, in litres per minute (L/min).
+The coolant flow rate, expressed as a numeric value with a selectable unit (L/min, m³/h, or GPM).
 
 ### Pressure
 
-The operating pressure of the loop, in kilopascals (kPa).
+The operating pressure of the loop, expressed as a numeric value with a selectable unit (kPa, bar, or PSI).
 
 ### Mark Connected
 

+ 14 - 2
netbox/dcim/api/serializers_/cooling.py

@@ -2,7 +2,7 @@ from dcim.choices import *
 from dcim.models import CoolingFeed, CoolingSource
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
 from netbox.api.serializers import PrimaryModelSerializer
-from netbox.choices import TemperatureUnitChoices
+from netbox.choices import FlowRateUnitChoices, PressureUnitChoices, TemperatureUnitChoices
 from tenancy.api.serializers_.tenants import TenantSerializer
 
 from .base import ConnectedEndpointsSerializer
@@ -73,6 +73,18 @@ class CoolingFeedSerializer(PrimaryModelSerializer, CabledObjectSerializer, Conn
         required=False,
         allow_null=True
     )
+    flow_rate_unit = ChoiceField(
+        choices=FlowRateUnitChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    pressure_unit = ChoiceField(
+        choices=PressureUnitChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
     tenant = TenantSerializer(
         nested=True,
         required=False,
@@ -83,7 +95,7 @@ class CoolingFeedSerializer(PrimaryModelSerializer, CabledObjectSerializer, Conn
         model = CoolingFeed
         fields = [
             'id', 'url', 'display_url', 'display', 'cooling_source', 'rack', 'name', 'status', 'type', 'fluid_type',
-            'cooling_capacity', 'flow_rate', 'pressure', 'mark_connected',
+            'cooling_capacity', 'flow_rate', 'flow_rate_unit', 'pressure', 'pressure_unit', 'mark_connected',
             'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type',
             'connected_endpoints_reachable', 'description', 'tenant', 'owner', 'comments', 'tags', 'custom_fields',
             'created', 'last_updated', '_occupied',

+ 12 - 2
netbox/dcim/filtersets.py

@@ -12,7 +12,7 @@ from extras.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate
 from ipam.filtersets import PrimaryIPFilterSet
 from ipam.models import ASN, VRF, IPAddress, VLANTranslationPolicy
-from netbox.choices import ColorChoices, TemperatureUnitChoices
+from netbox.choices import ColorChoices, FlowRateUnitChoices, PressureUnitChoices, TemperatureUnitChoices
 from netbox.filtersets import (
     AttributeFiltersMixin,
     BaseFilterSet,
@@ -3314,11 +3314,21 @@ class CoolingFeedFilterSet(PrimaryModelFilterSet, CabledObjectFilterSet, PathEnd
         distinct=False,
         null_value=None
     )
+    flow_rate_unit = django_filters.MultipleChoiceFilter(
+        choices=FlowRateUnitChoices,
+        distinct=False,
+        null_value=None
+    )
+    pressure_unit = django_filters.MultipleChoiceFilter(
+        choices=PressureUnitChoices,
+        distinct=False,
+        null_value=None
+    )
 
     class Meta:
         model = CoolingFeed
         fields = (
-            'id', 'name', 'cooling_capacity', 'flow_rate', 'pressure',
+            'id', 'name', 'cooling_capacity', 'flow_rate', 'flow_rate_unit', 'pressure', 'pressure_unit',
             'mark_connected', 'cable_end', 'cable_connector', 'description',
         )
 

+ 14 - 2
netbox/dcim/forms/bulk_edit.py

@@ -1165,11 +1165,23 @@ class CoolingFeedBulkEditForm(PrimaryModelBulkEditForm):
         min_value=0,
         required=False
     )
+    flow_rate_unit = forms.ChoiceField(
+        label=_('Flow rate unit'),
+        choices=add_blank_choice(FlowRateUnitChoices),
+        required=False,
+        initial=''
+    )
     pressure = forms.DecimalField(
         label=_('Pressure'),
         min_value=0,
         required=False
     )
+    pressure_unit = forms.ChoiceField(
+        label=_('Pressure unit'),
+        choices=add_blank_choice(PressureUnitChoices),
+        required=False,
+        initial=''
+    )
     mark_connected = forms.NullBooleanField(
         label=_('Mark connected'),
         required=False,
@@ -1184,12 +1196,12 @@ class CoolingFeedBulkEditForm(PrimaryModelBulkEditForm):
     fieldsets = (
         FieldSet('cooling_source', 'rack', 'status', 'type', 'fluid_type', 'mark_connected', 'description', 'tenant'),
         FieldSet(
-            'cooling_capacity', 'flow_rate', 'pressure',
+            'cooling_capacity', 'flow_rate', 'flow_rate_unit', 'pressure', 'pressure_unit',
             name=_('Characteristics')
         ),
     )
     nullable_fields = (
-        'rack', 'fluid_type', 'cooling_capacity', 'flow_rate', 'pressure',
+        'rack', 'fluid_type', 'cooling_capacity', 'flow_rate', 'flow_rate_unit', 'pressure', 'pressure_unit',
         'tenant', 'description', 'comments',
     )
 

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

@@ -2025,12 +2025,24 @@ class CoolingFeedImportForm(PrimaryModelImportForm):
         required=False,
         help_text=_('Coolant fluid type')
     )
+    flow_rate_unit = CSVChoiceField(
+        label=_('Flow rate unit'),
+        choices=FlowRateUnitChoices,
+        required=False,
+        help_text=_('Unit for flow rate')
+    )
+    pressure_unit = CSVChoiceField(
+        label=_('Pressure unit'),
+        choices=PressureUnitChoices,
+        required=False,
+        help_text=_('Unit for pressure')
+    )
 
     class Meta:
         model = CoolingFeed
         fields = (
             'site', 'cooling_source', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'fluid_type',
-            'cooling_capacity', 'flow_rate', 'pressure', 'tenant',
+            'cooling_capacity', 'flow_rate', 'flow_rate_unit', 'pressure', 'pressure_unit', 'tenant',
             'description', 'owner', 'comments', 'tags',
         )
 

+ 25 - 0
netbox/dcim/forms/filtersets.py

@@ -1540,6 +1540,9 @@ class CoolingFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', 'cooling_source_id', 'rack_id', name=_('Location')),
         FieldSet('status', 'type', 'fluid_type', name=_('Attributes')),
+        FieldSet(
+            'cooling_capacity', 'flow_rate', 'flow_rate_unit', 'pressure', 'pressure_unit', name=_('Characteristics')
+        ),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
@@ -1594,6 +1597,28 @@ class CoolingFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
         choices=add_blank_choice(FluidTypeChoices),
         required=False
     )
+    cooling_capacity = forms.DecimalField(
+        label=_('Cooling capacity'),
+        required=False
+    )
+    flow_rate = forms.DecimalField(
+        label=_('Flow rate'),
+        required=False
+    )
+    flow_rate_unit = forms.MultipleChoiceField(
+        label=_('Flow rate unit'),
+        choices=FlowRateUnitChoices,
+        required=False
+    )
+    pressure = forms.DecimalField(
+        label=_('Pressure'),
+        required=False
+    )
+    pressure_unit = forms.MultipleChoiceField(
+        label=_('Pressure unit'),
+        choices=PressureUnitChoices,
+        required=False
+    )
     tag = TagFilterField(model)
 
 

+ 5 - 2
netbox/dcim/forms/model_forms.py

@@ -1020,7 +1020,9 @@ class CoolingFeedForm(TenancyForm, PrimaryModelForm):
             name=_('Cooling Feed')
         ),
         FieldSet(
-            'cooling_capacity', 'flow_rate', 'pressure',
+            'cooling_capacity',
+            InlineFields('flow_rate', 'flow_rate_unit', label=_('Flow rate')),
+            InlineFields('pressure', 'pressure_unit', label=_('Pressure')),
             name=_('Characteristics')
         ),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
@@ -1030,7 +1032,8 @@ class CoolingFeedForm(TenancyForm, PrimaryModelForm):
         model = CoolingFeed
         fields = [
             'cooling_source', 'rack', 'name', 'status', 'type', 'mark_connected', 'fluid_type', 'cooling_capacity',
-            'flow_rate', 'pressure', 'tenant_group', 'tenant', 'description', 'owner', 'comments', 'tags',
+            'flow_rate', 'flow_rate_unit', 'pressure', 'pressure_unit', 'tenant_group', 'tenant', 'description',
+            'owner', 'comments', 'tags',
         ]
 
 

+ 5 - 1
netbox/dcim/graphql/enums.py

@@ -1,7 +1,7 @@
 import strawberry
 
 from dcim.choices import *
-from netbox.choices import DiameterUnitChoices, TemperatureUnitChoices
+from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices, PressureUnitChoices, TemperatureUnitChoices
 
 __all__ = (
     'CableEndEnum',
@@ -19,6 +19,7 @@ __all__ = (
     'DeviceFaceEnum',
     'DeviceStatusEnum',
     'DiameterUnitEnum',
+    'FlowRateUnitEnum',
     'FluidTypeEnum',
     'InterfaceDuplexEnum',
     'InterfaceKindEnum',
@@ -40,6 +41,7 @@ __all__ = (
     'PowerOutletStatusEnum',
     'PowerOutletTypeEnum',
     'PowerPortTypeEnum',
+    'PressureUnitEnum',
     'RackAirflowEnum',
     'RackDimensionUnitEnum',
     'RackFormFactorEnum',
@@ -67,6 +69,7 @@ DeviceAirflowEnum = strawberry.enum(DeviceAirflowChoices.as_enum(prefix='airflow
 DeviceFaceEnum = strawberry.enum(DeviceFaceChoices.as_enum(prefix='face'))
 DeviceStatusEnum = strawberry.enum(DeviceStatusChoices.as_enum(prefix='status'))
 DiameterUnitEnum = strawberry.enum(DiameterUnitChoices.as_enum(prefix='unit'))
+FlowRateUnitEnum = strawberry.enum(FlowRateUnitChoices.as_enum(prefix='unit'))
 FluidTypeEnum = strawberry.enum(FluidTypeChoices.as_enum(prefix='fluid'))
 InterfaceDuplexEnum = strawberry.enum(InterfaceDuplexChoices.as_enum(prefix='duplex'))
 InterfaceKindEnum = strawberry.enum(InterfaceKindChoices.as_enum(prefix='kind'))
@@ -88,6 +91,7 @@ PowerOutletFeedLegEnum = strawberry.enum(PowerOutletFeedLegChoices.as_enum(prefi
 PowerOutletStatusEnum = strawberry.enum(PowerOutletStatusChoices.as_enum(prefix='status'))
 PowerOutletTypeEnum = strawberry.enum(PowerOutletTypeChoices.as_enum(prefix='type'))
 PowerPortTypeEnum = strawberry.enum(PowerPortTypeChoices.as_enum(prefix='type'))
+PressureUnitEnum = strawberry.enum(PressureUnitChoices.as_enum(prefix='unit'))
 RackAirflowEnum = strawberry.enum(RackAirflowChoices.as_enum())
 RackDimensionUnitEnum = strawberry.enum(RackDimensionUnitChoices.as_enum(prefix='unit'))
 RackFormFactorEnum = strawberry.enum(RackFormFactorChoices.as_enum(prefix='type'))

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

@@ -961,9 +961,15 @@ class CoolingFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, Primar
     flow_rate: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
+    flow_rate_unit: (
+        BaseFilterLookup[Annotated['FlowRateUnitEnum', strawberry.lazy('dcim.graphql.enums')]] | None
+    ) = strawberry_django.filter_field()
     pressure: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
+    pressure_unit: (
+        BaseFilterLookup[Annotated['PressureUnitEnum', strawberry.lazy('dcim.graphql.enums')]] | None
+    ) = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.CoolingOutlet, lookups=True)

+ 41 - 21
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:08
+# Generated by Django 6.0.6 on 2026-06-24 21:29
 
 import django.contrib.postgres.fields
 import django.core.validators
@@ -774,6 +774,46 @@ class Migration(migrations.Migration):
                         encoder=utilities.json.CustomFieldJSONEncoder,
                     ),
                 ),
+                (
+                    "flow_rate",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(0)],
+                    ),
+                ),
+                (
+                    "flow_rate_unit",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                (
+                    "_abs_flow_rate",
+                    models.DecimalField(
+                        blank=True, decimal_places=4, max_digits=13, null=True
+                    ),
+                ),
+                (
+                    "pressure",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(0)],
+                    ),
+                ),
+                (
+                    "pressure_unit",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                (
+                    "_abs_pressure",
+                    models.DecimalField(
+                        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)),
@@ -816,26 +856,6 @@ class Migration(migrations.Migration):
                         validators=[django.core.validators.MinValueValidator(0)],
                     ),
                 ),
-                (
-                    "flow_rate",
-                    models.DecimalField(
-                        blank=True,
-                        decimal_places=2,
-                        max_digits=8,
-                        null=True,
-                        validators=[django.core.validators.MinValueValidator(0)],
-                    ),
-                ),
-                (
-                    "pressure",
-                    models.DecimalField(
-                        blank=True,
-                        decimal_places=2,
-                        max_digits=8,
-                        null=True,
-                        validators=[django.core.validators.MinValueValidator(0)],
-                    ),
-                ),
                 (
                     "_path",
                     models.ForeignKey(

+ 5 - 20
netbox/dcim/models/cooling.py

@@ -7,6 +7,7 @@ from dcim.choices import *
 from netbox.choices import TemperatureUnitChoices
 from netbox.models import PrimaryModel
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
+from netbox.models.mixins import FlowRateMixin, PressureMixin
 from utilities.conversion import to_celsius
 
 from .device_components import CabledObjectModel, PathEndpoint
@@ -168,7 +169,7 @@ class CoolingSource(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
             raise ValidationError(_("Must specify a unit when setting a temperature"))
 
 
-class CoolingFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
+class CoolingFeed(FlowRateMixin, PressureMixin, PrimaryModel, PathEndpoint, CabledObjectModel):
     """
     A coolant loop delivered from a CoolingSource to a rack or CDU. Supply and return loops are
     represented as separate feeds so each can be traced independently.
@@ -218,24 +219,8 @@ class CoolingFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
         validators=[MinValueValidator(0)],
         help_text=_('Cooling capacity (kW)')
     )
-    flow_rate = models.DecimalField(
-        verbose_name=_('flow rate'),
-        max_digits=8,
-        decimal_places=2,
-        blank=True,
-        null=True,
-        validators=[MinValueValidator(0)],
-        help_text=_('Coolant flow rate (L/min)')
-    )
-    pressure = models.DecimalField(
-        verbose_name=_('pressure'),
-        max_digits=8,
-        decimal_places=2,
-        blank=True,
-        null=True,
-        validators=[MinValueValidator(0)],
-        help_text=_('Operating pressure (kPa)')
-    )
+    # flow_rate, flow_rate_unit, _abs_flow_rate provided by FlowRateMixin
+    # pressure, pressure_unit, _abs_pressure provided by PressureMixin
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         on_delete=models.PROTECT,
@@ -246,7 +231,7 @@ class CoolingFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
 
     clone_fields = (
         'cooling_source', 'rack', 'status', 'type', 'mark_connected', 'fluid_type', 'cooling_capacity', 'flow_rate',
-        'pressure', 'tenant',
+        'flow_rate_unit', 'pressure', 'pressure_unit', 'tenant',
     )
     prerequisite_models = (
         'dcim.CoolingSource',

+ 12 - 4
netbox/dcim/tables/cooling.py

@@ -118,10 +118,18 @@ class CoolingFeedTable(TenancyColumnsMixin, CableTerminationTable, PrimaryModelT
         verbose_name=_('Cooling Capacity (kW)')
     )
     flow_rate = tables.Column(
-        verbose_name=_('Flow Rate (L/min)')
+        verbose_name=_('Flow Rate'),
+        order_by=('_abs_flow_rate',)
+    )
+    flow_rate_unit = columns.ChoiceFieldColumn(
+        verbose_name=_('Flow Rate Unit'),
     )
     pressure = tables.Column(
-        verbose_name=_('Pressure (kPa)')
+        verbose_name=_('Pressure'),
+        order_by=('_abs_pressure',)
+    )
+    pressure_unit = columns.ChoiceFieldColumn(
+        verbose_name=_('Pressure Unit'),
     )
     tenant = tables.Column(
         linkify=True,
@@ -140,8 +148,8 @@ class CoolingFeedTable(TenancyColumnsMixin, CableTerminationTable, PrimaryModelT
         model = CoolingFeed
         fields = (
             'pk', 'id', 'name', 'cooling_source', 'site', 'rack', 'status', 'type', 'fluid_type', 'cooling_capacity',
-            'flow_rate', 'pressure', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tenant', 'tenant_group',
-            'description', 'comments', 'tags', 'created', 'last_updated',
+            'flow_rate', 'flow_rate_unit', 'pressure', 'pressure_unit', 'mark_connected', 'cable', 'cable_color',
+            'link_peer', 'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'name', 'cooling_source', 'rack', 'status', 'type', 'fluid_type', 'cooling_capacity', 'flow_rate',

+ 25 - 2
netbox/dcim/tests/test_filtersets.py

@@ -10,7 +10,14 @@ from dcim.filtersets import *
 from dcim.models import *
 from ipam.choices import VLANQinQRoleChoices
 from ipam.models import ASN, RIR, VLAN, VRF, IPAddress, VLANTranslationPolicy
-from netbox.choices import ColorChoices, DiameterUnitChoices, TemperatureUnitChoices, WeightUnitChoices
+from netbox.choices import (
+    ColorChoices,
+    DiameterUnitChoices,
+    FlowRateUnitChoices,
+    PressureUnitChoices,
+    TemperatureUnitChoices,
+    WeightUnitChoices,
+)
 from tenancy.models import Tenant, TenantGroup
 from users.models import User
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
@@ -8408,7 +8415,9 @@ class CoolingFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
                 fluid_type=FluidTypeChoices.FLUID_WATER,
                 cooling_capacity=100,
                 flow_rate=10,
+                flow_rate_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
                 pressure=100,
+                pressure_unit=PressureUnitChoices.UNIT_KILOPASCAL,
                 description='foobar1'
             ),
             CoolingFeed(
@@ -8421,7 +8430,9 @@ class CoolingFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
                 fluid_type=FluidTypeChoices.FLUID_WATER,
                 cooling_capacity=200,
                 flow_rate=20,
+                flow_rate_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
                 pressure=200,
+                pressure_unit=PressureUnitChoices.UNIT_KILOPASCAL,
                 description='foobar2'
             ),
             CoolingFeed(
@@ -8434,11 +8445,15 @@ class CoolingFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
                 fluid_type=FluidTypeChoices.FLUID_DIELECTRIC,
                 cooling_capacity=300,
                 flow_rate=30,
+                flow_rate_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE,
                 pressure=300,
+                pressure_unit=PressureUnitChoices.UNIT_PSI,
                 description='foobar3'
             ),
         )
-        CoolingFeed.objects.bulk_create(cooling_feeds)
+        # Use save() rather than bulk_create() so the normalized _abs_* fields are populated
+        for cooling_feed in cooling_feeds:
+            cooling_feed.save()
 
         manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
         device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model', slug='model')
@@ -8480,10 +8495,18 @@ class CoolingFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'flow_rate': [10, 20]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_flow_rate_unit(self):
+        params = {'flow_rate_unit': [FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_pressure(self):
         params = {'pressure': [100, 200]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_pressure_unit(self):
+        params = {'pressure_unit': [PressureUnitChoices.UNIT_KILOPASCAL]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_description(self):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 12 - 1
netbox/dcim/tests/test_views.py

@@ -18,7 +18,14 @@ from dcim.constants import *
 from dcim.models import *
 from extras.models import ConfigTemplate
 from ipam.models import ASN, RIR, VLAN, VRF
-from netbox.choices import CSVDelimiterChoices, DiameterUnitChoices, ImportFormatChoices, WeightUnitChoices
+from netbox.choices import (
+    CSVDelimiterChoices,
+    DiameterUnitChoices,
+    FlowRateUnitChoices,
+    ImportFormatChoices,
+    PressureUnitChoices,
+    WeightUnitChoices,
+)
 from tenancy.models import Tenant
 from users.models import ObjectPermission, User
 from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
@@ -4598,7 +4605,9 @@ class CoolingFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'fluid_type': FluidTypeChoices.FLUID_WATER,
             'cooling_capacity': 100,
             'flow_rate': 50,
+            'flow_rate_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
             'pressure': 200,
+            'pressure_unit': PressureUnitChoices.UNIT_KILOPASCAL,
             'comments': 'New comments',
             'tags': [t.pk for t in tags],
         }
@@ -4625,7 +4634,9 @@ class CoolingFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'fluid_type': FluidTypeChoices.FLUID_WATER,
             'cooling_capacity': 100,
             'flow_rate': 50,
+            'flow_rate_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
             'pressure': 200,
+            'pressure_unit': PressureUnitChoices.UNIT_KILOPASCAL,
             'comments': 'New comments',
         }
 

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

@@ -425,8 +425,8 @@ class CoolingFeedElectricalPanel(panels.ObjectAttributesPanel):
     title = _('Cooling Characteristics')
 
     cooling_capacity = attrs.TextAttr('cooling_capacity', format_string=_('{} kW'))
-    flow_rate = attrs.TextAttr('flow_rate', format_string=_('{} L/min'))
-    pressure = attrs.TextAttr('pressure', format_string=_('{} kPa'))
+    flow_rate = attrs.NumericAttr('flow_rate', unit_accessor='get_flow_rate_unit_display')
+    pressure = attrs.NumericAttr('pressure', unit_accessor='get_pressure_unit_display')
 
 
 class VirtualDeviceContextPanel(panels.ObjectAttributesPanel):

+ 34 - 0
netbox/netbox/choices.py

@@ -9,8 +9,10 @@ __all__ = (
     'ColorChoices',
     'DiameterUnitChoices',
     'DistanceUnitChoices',
+    'FlowRateUnitChoices',
     'ImportFormatChoices',
     'ImportMethodChoices',
+    'PressureUnitChoices',
     'TemperatureUnitChoices',
     'WeightUnitChoices',
 )
@@ -229,3 +231,35 @@ class TemperatureUnitChoices(ChoiceSet):
         (UNIT_CELSIUS, _('Celsius')),
         (UNIT_FAHRENHEIT, _('Fahrenheit')),
     )
+
+
+class FlowRateUnitChoices(ChoiceSet):
+
+    # Metric
+    UNIT_LITERS_PER_MINUTE = 'lpm'
+    UNIT_CUBIC_METERS_PER_HOUR = 'm3h'
+
+    # Imperial
+    UNIT_GALLONS_PER_MINUTE = 'gpm'
+
+    CHOICES = (
+        (UNIT_LITERS_PER_MINUTE, _('Liters per minute (L/min)')),
+        (UNIT_CUBIC_METERS_PER_HOUR, _('Cubic meters per hour (m³/h)')),
+        (UNIT_GALLONS_PER_MINUTE, _('Gallons per minute (GPM)')),
+    )
+
+
+class PressureUnitChoices(ChoiceSet):
+
+    # Metric
+    UNIT_KILOPASCAL = 'kpa'
+    UNIT_BAR = 'bar'
+
+    # Imperial
+    UNIT_PSI = 'psi'
+
+    CHOICES = (
+        (UNIT_KILOPASCAL, _('Kilopascals (kPa)')),
+        (UNIT_BAR, _('Bar')),
+        (UNIT_PSI, _('Pounds per square inch (PSI)')),
+    )

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

@@ -1,14 +1,17 @@
 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 _
 
 from netbox.choices import *
-from utilities.conversion import to_grams, to_meters, to_millimeters
+from utilities.conversion import to_grams, to_kilopascals, to_liters_per_minute, to_meters, to_millimeters
 
 __all__ = (
     'DiameterMixin',
     'DistanceMixin',
+    'FlowRateMixin',
     'OwnerMixin',
+    'PressureMixin',
     'WeightMixin',
 )
 
@@ -128,6 +131,112 @@ class DiameterMixin(models.Model):
             raise ValidationError(_("Must specify a unit when setting a diameter"))
 
 
+class FlowRateMixin(models.Model):
+    flow_rate = models.DecimalField(
+        verbose_name=_('flow rate'),
+        max_digits=8,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(0)],
+    )
+    flow_rate_unit = models.CharField(
+        verbose_name=_('flow rate unit'),
+        max_length=50,
+        choices=FlowRateUnitChoices,
+        blank=True,
+        null=True,
+    )
+    # Stores the normalized flow rate (in liters per minute) for database ordering
+    _abs_flow_rate = models.DecimalField(
+        max_digits=13,
+        decimal_places=4,
+        blank=True,
+        null=True
+    )
+
+    class Meta:
+        abstract = True
+
+    @property
+    def abs_flow_rate(self):
+        # Public alias for _abs_flow_rate; Django templates cannot access underscore-prefixed attributes.
+        return self._abs_flow_rate
+
+    def save(self, *args, **kwargs):
+        # Store the given flow rate (if any) in liters per minute for use in database ordering
+        if self.flow_rate is not None and self.flow_rate_unit:
+            self._abs_flow_rate = to_liters_per_minute(self.flow_rate, self.flow_rate_unit)
+        else:
+            self._abs_flow_rate = None
+
+        # Clear flow_rate_unit if no flow rate is defined
+        if self.flow_rate is None:
+            self.flow_rate_unit = None
+
+        super().save(*args, **kwargs)
+
+    def clean(self):
+        super().clean()
+
+        # Validate flow_rate and flow_rate_unit
+        if self.flow_rate is not None and not self.flow_rate_unit:
+            raise ValidationError(_("Must specify a unit when setting a flow rate"))
+
+
+class PressureMixin(models.Model):
+    pressure = models.DecimalField(
+        verbose_name=_('pressure'),
+        max_digits=8,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(0)],
+    )
+    pressure_unit = models.CharField(
+        verbose_name=_('pressure unit'),
+        max_length=50,
+        choices=PressureUnitChoices,
+        blank=True,
+        null=True,
+    )
+    # Stores the normalized pressure (in kilopascals) for database ordering
+    _abs_pressure = models.DecimalField(
+        max_digits=13,
+        decimal_places=4,
+        blank=True,
+        null=True
+    )
+
+    class Meta:
+        abstract = True
+
+    @property
+    def abs_pressure(self):
+        # Public alias for _abs_pressure; Django templates cannot access underscore-prefixed attributes.
+        return self._abs_pressure
+
+    def save(self, *args, **kwargs):
+        # Store the given pressure (if any) in kilopascals for use in database ordering
+        if self.pressure is not None and self.pressure_unit:
+            self._abs_pressure = to_kilopascals(self.pressure, self.pressure_unit)
+        else:
+            self._abs_pressure = None
+
+        # Clear pressure_unit if no pressure is defined
+        if self.pressure is None:
+            self.pressure_unit = None
+
+        super().save(*args, **kwargs)
+
+    def clean(self):
+        super().clean()
+
+        # Validate pressure and pressure_unit
+        if self.pressure is not None and not self.pressure_unit:
+            raise ValidationError(_("Must specify a unit when setting a pressure"))
+
+
 class DistanceMixin(models.Model):
     distance = models.DecimalField(
         verbose_name=_('distance'),

+ 59 - 1
netbox/utilities/conversion.py

@@ -3,11 +3,19 @@ from decimal import Decimal, InvalidOperation
 from django.utils.translation import gettext as _
 
 from dcim.choices import CableLengthUnitChoices
-from netbox.choices import DiameterUnitChoices, TemperatureUnitChoices, WeightUnitChoices
+from netbox.choices import (
+    DiameterUnitChoices,
+    FlowRateUnitChoices,
+    PressureUnitChoices,
+    TemperatureUnitChoices,
+    WeightUnitChoices,
+)
 
 __all__ = (
     'to_celsius',
     'to_grams',
+    'to_kilopascals',
+    'to_liters_per_minute',
     'to_meters',
     'to_millimeters',
 )
@@ -70,6 +78,56 @@ def to_meters(length, unit) -> Decimal:
     )
 
 
+def to_liters_per_minute(flow_rate, unit) -> Decimal:
+    """
+    Convert the given flow rate to liters per minute, returning a Decimal value.
+    """
+    try:
+        flow_rate = Decimal(flow_rate)
+    except InvalidOperation:
+        raise TypeError(_("Invalid value '{flow_rate}' for flow rate (must be a number)").format(flow_rate=flow_rate))
+    if flow_rate < 0:
+        raise ValueError(_("Flow rate must be a positive number"))
+
+    if unit == FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE:
+        return round(Decimal(flow_rate), 4)
+    if unit == FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR:
+        return round(flow_rate * Decimal(1000) / Decimal(60), 4)
+    if unit == FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE:
+        return round(flow_rate * Decimal('3.785411784'), 4)
+    raise ValueError(
+        _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
+            unit=unit,
+            valid_units=', '.join(FlowRateUnitChoices.values())
+        )
+    )
+
+
+def to_kilopascals(pressure, unit) -> Decimal:
+    """
+    Convert the given pressure to kilopascals, returning a Decimal value.
+    """
+    try:
+        pressure = Decimal(pressure)
+    except InvalidOperation:
+        raise TypeError(_("Invalid value '{pressure}' for pressure (must be a number)").format(pressure=pressure))
+    if pressure < 0:
+        raise ValueError(_("Pressure must be a positive number"))
+
+    if unit == PressureUnitChoices.UNIT_KILOPASCAL:
+        return round(Decimal(pressure), 4)
+    if unit == PressureUnitChoices.UNIT_BAR:
+        return round(pressure * Decimal(100), 4)
+    if unit == PressureUnitChoices.UNIT_PSI:
+        return round(pressure * Decimal('6.894757'), 4)
+    raise ValueError(
+        _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
+            unit=unit,
+            valid_units=', '.join(PressureUnitChoices.values())
+        )
+    )
+
+
 def to_celsius(temperature, unit) -> Decimal:
     """
     Convert the given temperature to degrees Celsius, returning a Decimal value. Temperatures may be negative.