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

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
 ### 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
 ### 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
 ### 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 dcim.models import CoolingFeed, CoolingSource
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
 from netbox.api.serializers import PrimaryModelSerializer
 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 tenancy.api.serializers_.tenants import TenantSerializer
 
 
 from .base import ConnectedEndpointsSerializer
 from .base import ConnectedEndpointsSerializer
@@ -73,6 +73,18 @@ class CoolingFeedSerializer(PrimaryModelSerializer, CabledObjectSerializer, Conn
         required=False,
         required=False,
         allow_null=True
         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(
     tenant = TenantSerializer(
         nested=True,
         nested=True,
         required=False,
         required=False,
@@ -83,7 +95,7 @@ class CoolingFeedSerializer(PrimaryModelSerializer, CabledObjectSerializer, Conn
         model = CoolingFeed
         model = CoolingFeed
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'cooling_source', 'rack', 'name', 'status', 'type', 'fluid_type',
             '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',
             'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type',
             'connected_endpoints_reachable', 'description', 'tenant', 'owner', 'comments', 'tags', 'custom_fields',
             'connected_endpoints_reachable', 'description', 'tenant', 'owner', 'comments', 'tags', 'custom_fields',
             'created', 'last_updated', '_occupied',
             '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 extras.models import ConfigTemplate
 from ipam.filtersets import PrimaryIPFilterSet
 from ipam.filtersets import PrimaryIPFilterSet
 from ipam.models import ASN, VRF, IPAddress, VLANTranslationPolicy
 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 (
 from netbox.filtersets import (
     AttributeFiltersMixin,
     AttributeFiltersMixin,
     BaseFilterSet,
     BaseFilterSet,
@@ -3314,11 +3314,21 @@ class CoolingFeedFilterSet(PrimaryModelFilterSet, CabledObjectFilterSet, PathEnd
         distinct=False,
         distinct=False,
         null_value=None
         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:
     class Meta:
         model = CoolingFeed
         model = CoolingFeed
         fields = (
         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',
             '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,
         min_value=0,
         required=False
         required=False
     )
     )
+    flow_rate_unit = forms.ChoiceField(
+        label=_('Flow rate unit'),
+        choices=add_blank_choice(FlowRateUnitChoices),
+        required=False,
+        initial=''
+    )
     pressure = forms.DecimalField(
     pressure = forms.DecimalField(
         label=_('Pressure'),
         label=_('Pressure'),
         min_value=0,
         min_value=0,
         required=False
         required=False
     )
     )
+    pressure_unit = forms.ChoiceField(
+        label=_('Pressure unit'),
+        choices=add_blank_choice(PressureUnitChoices),
+        required=False,
+        initial=''
+    )
     mark_connected = forms.NullBooleanField(
     mark_connected = forms.NullBooleanField(
         label=_('Mark connected'),
         label=_('Mark connected'),
         required=False,
         required=False,
@@ -1184,12 +1196,12 @@ class CoolingFeedBulkEditForm(PrimaryModelBulkEditForm):
     fieldsets = (
     fieldsets = (
         FieldSet('cooling_source', 'rack', 'status', 'type', 'fluid_type', 'mark_connected', 'description', 'tenant'),
         FieldSet('cooling_source', 'rack', 'status', 'type', 'fluid_type', 'mark_connected', 'description', 'tenant'),
         FieldSet(
         FieldSet(
-            'cooling_capacity', 'flow_rate', 'pressure',
+            'cooling_capacity', 'flow_rate', 'flow_rate_unit', 'pressure', 'pressure_unit',
             name=_('Characteristics')
             name=_('Characteristics')
         ),
         ),
     )
     )
     nullable_fields = (
     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',
         'tenant', 'description', 'comments',
     )
     )
 
 

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

@@ -2025,12 +2025,24 @@ class CoolingFeedImportForm(PrimaryModelImportForm):
         required=False,
         required=False,
         help_text=_('Coolant fluid type')
         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:
     class Meta:
         model = CoolingFeed
         model = CoolingFeed
         fields = (
         fields = (
             'site', 'cooling_source', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'fluid_type',
             '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',
             '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('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', 'cooling_source_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'cooling_source_id', 'rack_id', name=_('Location')),
         FieldSet('status', 'type', 'fluid_type', name=_('Attributes')),
         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('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
@@ -1594,6 +1597,28 @@ class CoolingFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
         choices=add_blank_choice(FluidTypeChoices),
         choices=add_blank_choice(FluidTypeChoices),
         required=False
         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)
     tag = TagFilterField(model)
 
 
 
 

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

@@ -1020,7 +1020,9 @@ class CoolingFeedForm(TenancyForm, PrimaryModelForm):
             name=_('Cooling Feed')
             name=_('Cooling Feed')
         ),
         ),
         FieldSet(
         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')
             name=_('Characteristics')
         ),
         ),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
@@ -1030,7 +1032,8 @@ class CoolingFeedForm(TenancyForm, PrimaryModelForm):
         model = CoolingFeed
         model = CoolingFeed
         fields = [
         fields = [
             'cooling_source', 'rack', 'name', 'status', 'type', 'mark_connected', 'fluid_type', 'cooling_capacity',
             '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
 import strawberry
 
 
 from dcim.choices import *
 from dcim.choices import *
-from netbox.choices import DiameterUnitChoices, TemperatureUnitChoices
+from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices, PressureUnitChoices, TemperatureUnitChoices
 
 
 __all__ = (
 __all__ = (
     'CableEndEnum',
     'CableEndEnum',
@@ -19,6 +19,7 @@ __all__ = (
     'DeviceFaceEnum',
     'DeviceFaceEnum',
     'DeviceStatusEnum',
     'DeviceStatusEnum',
     'DiameterUnitEnum',
     'DiameterUnitEnum',
+    'FlowRateUnitEnum',
     'FluidTypeEnum',
     'FluidTypeEnum',
     'InterfaceDuplexEnum',
     'InterfaceDuplexEnum',
     'InterfaceKindEnum',
     'InterfaceKindEnum',
@@ -40,6 +41,7 @@ __all__ = (
     'PowerOutletStatusEnum',
     'PowerOutletStatusEnum',
     'PowerOutletTypeEnum',
     'PowerOutletTypeEnum',
     'PowerPortTypeEnum',
     'PowerPortTypeEnum',
+    'PressureUnitEnum',
     'RackAirflowEnum',
     'RackAirflowEnum',
     'RackDimensionUnitEnum',
     'RackDimensionUnitEnum',
     'RackFormFactorEnum',
     'RackFormFactorEnum',
@@ -67,6 +69,7 @@ DeviceAirflowEnum = strawberry.enum(DeviceAirflowChoices.as_enum(prefix='airflow
 DeviceFaceEnum = strawberry.enum(DeviceFaceChoices.as_enum(prefix='face'))
 DeviceFaceEnum = strawberry.enum(DeviceFaceChoices.as_enum(prefix='face'))
 DeviceStatusEnum = strawberry.enum(DeviceStatusChoices.as_enum(prefix='status'))
 DeviceStatusEnum = strawberry.enum(DeviceStatusChoices.as_enum(prefix='status'))
 DiameterUnitEnum = strawberry.enum(DiameterUnitChoices.as_enum(prefix='unit'))
 DiameterUnitEnum = strawberry.enum(DiameterUnitChoices.as_enum(prefix='unit'))
+FlowRateUnitEnum = strawberry.enum(FlowRateUnitChoices.as_enum(prefix='unit'))
 FluidTypeEnum = strawberry.enum(FluidTypeChoices.as_enum(prefix='fluid'))
 FluidTypeEnum = strawberry.enum(FluidTypeChoices.as_enum(prefix='fluid'))
 InterfaceDuplexEnum = strawberry.enum(InterfaceDuplexChoices.as_enum(prefix='duplex'))
 InterfaceDuplexEnum = strawberry.enum(InterfaceDuplexChoices.as_enum(prefix='duplex'))
 InterfaceKindEnum = strawberry.enum(InterfaceKindChoices.as_enum(prefix='kind'))
 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'))
 PowerOutletStatusEnum = strawberry.enum(PowerOutletStatusChoices.as_enum(prefix='status'))
 PowerOutletTypeEnum = strawberry.enum(PowerOutletTypeChoices.as_enum(prefix='type'))
 PowerOutletTypeEnum = strawberry.enum(PowerOutletTypeChoices.as_enum(prefix='type'))
 PowerPortTypeEnum = strawberry.enum(PowerPortTypeChoices.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())
 RackAirflowEnum = strawberry.enum(RackAirflowChoices.as_enum())
 RackDimensionUnitEnum = strawberry.enum(RackDimensionUnitChoices.as_enum(prefix='unit'))
 RackDimensionUnitEnum = strawberry.enum(RackDimensionUnitChoices.as_enum(prefix='unit'))
 RackFormFactorEnum = strawberry.enum(RackFormFactorChoices.as_enum(prefix='type'))
 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 = (
     flow_rate: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         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 = (
     pressure: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         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)
 @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.contrib.postgres.fields
 import django.core.validators
 import django.core.validators
@@ -774,6 +774,46 @@ class Migration(migrations.Migration):
                         encoder=utilities.json.CustomFieldJSONEncoder,
                         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)),
                 ("description", models.CharField(blank=True, max_length=200)),
                 ("comments", models.TextField(blank=True)),
                 ("comments", models.TextField(blank=True)),
                 ("cable_end", models.CharField(blank=True, max_length=1, null=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)],
                         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",
                     "_path",
                     models.ForeignKey(
                     models.ForeignKey(

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

@@ -7,6 +7,7 @@ from dcim.choices import *
 from netbox.choices import TemperatureUnitChoices
 from netbox.choices import TemperatureUnitChoices
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
+from netbox.models.mixins import FlowRateMixin, PressureMixin
 from utilities.conversion import to_celsius
 from utilities.conversion import to_celsius
 
 
 from .device_components import CabledObjectModel, PathEndpoint
 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"))
             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
     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.
     represented as separate feeds so each can be traced independently.
@@ -218,24 +219,8 @@ class CoolingFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
         validators=[MinValueValidator(0)],
         validators=[MinValueValidator(0)],
         help_text=_('Cooling capacity (kW)')
         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(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
@@ -246,7 +231,7 @@ class CoolingFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
 
 
     clone_fields = (
     clone_fields = (
         'cooling_source', 'rack', 'status', 'type', 'mark_connected', 'fluid_type', 'cooling_capacity', 'flow_rate',
         'cooling_source', 'rack', 'status', 'type', 'mark_connected', 'fluid_type', 'cooling_capacity', 'flow_rate',
-        'pressure', 'tenant',
+        'flow_rate_unit', 'pressure', 'pressure_unit', 'tenant',
     )
     )
     prerequisite_models = (
     prerequisite_models = (
         'dcim.CoolingSource',
         'dcim.CoolingSource',

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

@@ -118,10 +118,18 @@ class CoolingFeedTable(TenancyColumnsMixin, CableTerminationTable, PrimaryModelT
         verbose_name=_('Cooling Capacity (kW)')
         verbose_name=_('Cooling Capacity (kW)')
     )
     )
     flow_rate = tables.Column(
     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(
     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(
     tenant = tables.Column(
         linkify=True,
         linkify=True,
@@ -140,8 +148,8 @@ class CoolingFeedTable(TenancyColumnsMixin, CableTerminationTable, PrimaryModelT
         model = CoolingFeed
         model = CoolingFeed
         fields = (
         fields = (
             'pk', 'id', 'name', 'cooling_source', 'site', 'rack', 'status', 'type', 'fluid_type', 'cooling_capacity',
             '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 = (
         default_columns = (
             'pk', 'name', 'cooling_source', 'rack', 'status', 'type', 'fluid_type', 'cooling_capacity', 'flow_rate',
             '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 dcim.models import *
 from ipam.choices import VLANQinQRoleChoices
 from ipam.choices import VLANQinQRoleChoices
 from ipam.models import ASN, RIR, VLAN, VRF, IPAddress, VLANTranslationPolicy
 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 tenancy.models import Tenant, TenantGroup
 from users.models import User
 from users.models import User
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
@@ -8408,7 +8415,9 @@ class CoolingFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
                 fluid_type=FluidTypeChoices.FLUID_WATER,
                 fluid_type=FluidTypeChoices.FLUID_WATER,
                 cooling_capacity=100,
                 cooling_capacity=100,
                 flow_rate=10,
                 flow_rate=10,
+                flow_rate_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
                 pressure=100,
                 pressure=100,
+                pressure_unit=PressureUnitChoices.UNIT_KILOPASCAL,
                 description='foobar1'
                 description='foobar1'
             ),
             ),
             CoolingFeed(
             CoolingFeed(
@@ -8421,7 +8430,9 @@ class CoolingFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
                 fluid_type=FluidTypeChoices.FLUID_WATER,
                 fluid_type=FluidTypeChoices.FLUID_WATER,
                 cooling_capacity=200,
                 cooling_capacity=200,
                 flow_rate=20,
                 flow_rate=20,
+                flow_rate_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
                 pressure=200,
                 pressure=200,
+                pressure_unit=PressureUnitChoices.UNIT_KILOPASCAL,
                 description='foobar2'
                 description='foobar2'
             ),
             ),
             CoolingFeed(
             CoolingFeed(
@@ -8434,11 +8445,15 @@ class CoolingFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
                 fluid_type=FluidTypeChoices.FLUID_DIELECTRIC,
                 fluid_type=FluidTypeChoices.FLUID_DIELECTRIC,
                 cooling_capacity=300,
                 cooling_capacity=300,
                 flow_rate=30,
                 flow_rate=30,
+                flow_rate_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE,
                 pressure=300,
                 pressure=300,
+                pressure_unit=PressureUnitChoices.UNIT_PSI,
                 description='foobar3'
                 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')
         manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
         device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model', slug='model')
         device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model', slug='model')
@@ -8480,10 +8495,18 @@ class CoolingFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'flow_rate': [10, 20]}
         params = {'flow_rate': [10, 20]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_pressure(self):
         params = {'pressure': [100, 200]}
         params = {'pressure': [100, 200]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_description(self):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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 dcim.models import *
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
 from ipam.models import ASN, RIR, VLAN, VRF
 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 tenancy.models import Tenant
 from users.models import ObjectPermission, User
 from users.models import ObjectPermission, User
 from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
 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,
             'fluid_type': FluidTypeChoices.FLUID_WATER,
             'cooling_capacity': 100,
             'cooling_capacity': 100,
             'flow_rate': 50,
             'flow_rate': 50,
+            'flow_rate_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
             'pressure': 200,
             'pressure': 200,
+            'pressure_unit': PressureUnitChoices.UNIT_KILOPASCAL,
             'comments': 'New comments',
             'comments': 'New comments',
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
@@ -4625,7 +4634,9 @@ class CoolingFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'fluid_type': FluidTypeChoices.FLUID_WATER,
             'fluid_type': FluidTypeChoices.FLUID_WATER,
             'cooling_capacity': 100,
             'cooling_capacity': 100,
             'flow_rate': 50,
             'flow_rate': 50,
+            'flow_rate_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
             'pressure': 200,
             'pressure': 200,
+            'pressure_unit': PressureUnitChoices.UNIT_KILOPASCAL,
             'comments': 'New comments',
             'comments': 'New comments',
         }
         }
 
 

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

@@ -425,8 +425,8 @@ class CoolingFeedElectricalPanel(panels.ObjectAttributesPanel):
     title = _('Cooling Characteristics')
     title = _('Cooling Characteristics')
 
 
     cooling_capacity = attrs.TextAttr('cooling_capacity', format_string=_('{} kW'))
     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):
 class VirtualDeviceContextPanel(panels.ObjectAttributesPanel):

+ 34 - 0
netbox/netbox/choices.py

@@ -9,8 +9,10 @@ __all__ = (
     'ColorChoices',
     'ColorChoices',
     'DiameterUnitChoices',
     'DiameterUnitChoices',
     'DistanceUnitChoices',
     'DistanceUnitChoices',
+    'FlowRateUnitChoices',
     'ImportFormatChoices',
     'ImportFormatChoices',
     'ImportMethodChoices',
     'ImportMethodChoices',
+    'PressureUnitChoices',
     'TemperatureUnitChoices',
     'TemperatureUnitChoices',
     'WeightUnitChoices',
     'WeightUnitChoices',
 )
 )
@@ -229,3 +231,35 @@ class TemperatureUnitChoices(ChoiceSet):
         (UNIT_CELSIUS, _('Celsius')),
         (UNIT_CELSIUS, _('Celsius')),
         (UNIT_FAHRENHEIT, _('Fahrenheit')),
         (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.exceptions import ValidationError
+from django.core.validators import MinValueValidator
 from django.db import models
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from netbox.choices import *
 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__ = (
 __all__ = (
     'DiameterMixin',
     'DiameterMixin',
     'DistanceMixin',
     'DistanceMixin',
+    'FlowRateMixin',
     'OwnerMixin',
     'OwnerMixin',
+    'PressureMixin',
     'WeightMixin',
     'WeightMixin',
 )
 )
 
 
@@ -128,6 +131,112 @@ class DiameterMixin(models.Model):
             raise ValidationError(_("Must specify a unit when setting a diameter"))
             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):
 class DistanceMixin(models.Model):
     distance = models.DecimalField(
     distance = models.DecimalField(
         verbose_name=_('distance'),
         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 django.utils.translation import gettext as _
 
 
 from dcim.choices import CableLengthUnitChoices
 from dcim.choices import CableLengthUnitChoices
-from netbox.choices import DiameterUnitChoices, TemperatureUnitChoices, WeightUnitChoices
+from netbox.choices import (
+    DiameterUnitChoices,
+    FlowRateUnitChoices,
+    PressureUnitChoices,
+    TemperatureUnitChoices,
+    WeightUnitChoices,
+)
 
 
 __all__ = (
 __all__ = (
     'to_celsius',
     'to_celsius',
     'to_grams',
     'to_grams',
+    'to_kilopascals',
+    'to_liters_per_minute',
     'to_meters',
     'to_meters',
     'to_millimeters',
     '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:
 def to_celsius(temperature, unit) -> Decimal:
     """
     """
     Convert the given temperature to degrees Celsius, returning a Decimal value. Temperatures may be negative.
     Convert the given temperature to degrees Celsius, returning a Decimal value. Temperatures may be negative.