Răsfoiți Sursa

Closes #22447: Add Cooling infrastructure modeling (#22517)

Arthur Hanson 2 săptămâni în urmă
părinte
comite
da1db0055d
76 a modificat fișierele cu 7780 adăugiri și 307 ștergeri
  1. 41 0
      docs/features/cooling.md
  2. 37 0
      docs/models/dcim/coolingfeed.md
  3. 40 0
      docs/models/dcim/coolingintake.md
  4. 3 0
      docs/models/dcim/coolingintaketemplate.md
  5. 38 0
      docs/models/dcim/coolingoutflow.md
  6. 3 0
      docs/models/dcim/coolingoutflowtemplate.md
  7. 36 0
      docs/models/dcim/coolingsource.md
  8. 4 0
      docs/models/dcim/device.md
  9. 4 0
      docs/models/dcim/devicetype.md
  10. 4 0
      docs/models/dcim/moduletype.md
  11. 14 0
      docs/models/dcim/rack.md
  12. 8 0
      docs/models/dcim/racktype.md
  13. 7 0
      mkdocs.yml
  14. 1 0
      netbox/dcim/api/serializers.py
  15. 83 0
      netbox/dcim/api/serializers_/cooling.py
  16. 83 1
      netbox/dcim/api/serializers_/device_components.py
  17. 7 4
      netbox/dcim/api/serializers_/devices.py
  18. 87 0
      netbox/dcim/api/serializers_/devicetype_components.py
  19. 15 8
      netbox/dcim/api/serializers_/devicetypes.py
  20. 9 0
      netbox/dcim/api/serializers_/nested.py
  21. 12 4
      netbox/dcim/api/serializers_/racks.py
  22. 8 0
      netbox/dcim/api/urls.py
  23. 44 0
      netbox/dcim/api/views.py
  24. 114 0
      netbox/dcim/choices.py
  25. 4 0
      netbox/dcim/constants.py
  26. 304 8
      netbox/dcim/filtersets.py
  27. 29 0
      netbox/dcim/forms/bulk_create.py
  28. 366 8
      netbox/dcim/forms/bulk_edit.py
  29. 295 9
      netbox/dcim/forms/bulk_import.py
  30. 310 4
      netbox/dcim/forms/filtersets.py
  31. 192 9
      netbox/dcim/forms/model_forms.py
  32. 28 0
      netbox/dcim/forms/object_create.py
  33. 42 0
      netbox/dcim/forms/object_import.py
  34. 19 0
      netbox/dcim/graphql/enums.py
  35. 7 1
      netbox/dcim/graphql/filter_mixins.py
  36. 135 0
      netbox/dcim/graphql/filters.py
  37. 18 0
      netbox/dcim/graphql/schema.py
  38. 87 0
      netbox/dcim/graphql/types.py
  39. 836 0
      netbox/dcim/migrations/0250_cooling_infrastructure.py
  40. 1 0
      netbox/dcim/models/__init__.py
  41. 193 0
      netbox/dcim/models/cooling.py
  42. 131 1
      netbox/dcim/models/device_component_templates.py
  43. 98 1
      netbox/dcim/models/device_components.py
  44. 54 3
      netbox/dcim/models/devices.py
  45. 174 0
      netbox/dcim/models/mixins.py
  46. 30 1
      netbox/dcim/models/modules.py
  47. 28 5
      netbox/dcim/models/racks.py
  48. 44 0
      netbox/dcim/search.py
  49. 1 0
      netbox/dcim/tables/__init__.py
  50. 303 0
      netbox/dcim/tables/cooling.py
  51. 11 1
      netbox/dcim/tables/devices.py
  52. 11 1
      netbox/dcim/tables/devicetypes.py
  53. 5 2
      netbox/dcim/tables/modules.py
  54. 16 3
      netbox/dcim/tables/racks.py
  55. 10 0
      netbox/dcim/tables/template_code.py
  56. 10 0
      netbox/dcim/tests/query_counts.json
  57. 325 0
      netbox/dcim/tests/test_api.py
  58. 835 173
      netbox/dcim/tests/test_filtersets.py
  59. 494 2
      netbox/dcim/tests/test_models.py
  60. 20 0
      netbox/dcim/tests/test_tables.py
  61. 465 1
      netbox/dcim/tests/test_views.py
  62. 55 0
      netbox/dcim/ui/panels.py
  63. 28 0
      netbox/dcim/urls.py
  64. 531 9
      netbox/dcim/views.py
  65. 34 0
      netbox/netbox/choices.py
  66. 17 0
      netbox/netbox/navigation/menu.py
  67. 166 0
      netbox/netbox/tests/test_ui.py
  68. 103 0
      netbox/netbox/ui/attrs.py
  69. 14 0
      netbox/templates/dcim/buttons/bulk_add_components.html
  70. 6 0
      netbox/templates/dcim/device/base.html
  71. 8 1
      netbox/templates/dcim/device_edit.html
  72. 6 0
      netbox/templates/dcim/devicetype/base.html
  73. 8 0
      netbox/templates/ui/attrs/measurement.html
  74. 70 45
      netbox/utilities/conversion.py
  75. 28 0
      netbox/utilities/templatetags/helpers.py
  76. 73 2
      netbox/utilities/tests/test_conversions.py

+ 41 - 0
docs/features/cooling.md

@@ -0,0 +1,41 @@
+# Cooling
+
+As part of its DCIM feature set, NetBox supports modeling data center cooling infrastructure, from facility plant down to the coolant connections on individual devices. This is used to document liquid- and hybrid-cooled environments (chillers, cooling distribution units, manifolds, rear-door heat exchangers, and cold-plate servers) as a source of truth.
+
+## Model Overview
+
+Cooling infrastructure is modeled as a hierarchy running from facility plant down to individual devices:
+
+**cooling source → cooling feed → device cooling intake / cooling outflow**
+
+A few properties of the model are worth noting up front:
+
+- **Connections are direct references, not cables.** Coolant hoses are not modeled as structured cabling; instead, an intake references the outflow that supplies it directly. Tracing a loop is a walk along these references.
+- **A single feed represents the entire loop.** A cooling feed covers both the supply (cold) and return (warm) paths of a loop, rather than modeling each direction as a separate object.
+- **Intakes and outflows both sit on the supply path.** Both device components describe the cold, coolant-distribution side of the loop: an intake receives coolant and an outflow passes it onward to downstream equipment. The warm return path is not modeled per-component — it is captured by the feed loop.
+
+## Cooling Sources
+
+A [cooling source](../models/dcim/coolingsource.md) is the furthest upstream cooling element modeled in NetBox, representing a chiller, cooling tower, dry cooler, or CRAC/CRAH unit. Each source is associated with a site, and may optionally be associated with a particular location within that site. A cooling source is not a device; it represents external facility plant, and records the coolant (fluid type) and total rated cooling capacity for the loops it originates.
+
+## Cooling Feeds
+
+A [cooling feed](../models/dcim/coolingfeed.md) represents a coolant loop running between a cooling source and a particular rack. Each feed records an operational status, a rated cooling capacity, and a rated (design) flow rate.
+
+## Device Components
+
+Devices participate in cooling through two component types, instantiated from templates defined on the device type:
+
+- A [cooling intake](../models/dcim/coolingintake.md) is a coolant intake on a device, such as a server cold-plate inlet or a CDU facility intake. It records the connector type, diameter, and rated maximum flow, and optionally references the upstream [cooling outflow](../models/dcim/coolingoutflow.md) that supplies it.
+- A [cooling outflow](../models/dcim/coolingoutflow.md) is a coolant supply point on a device, such as a CDU or manifold outlet. It optionally references a parent cooling intake on the same device — the device takes coolant in through its intake and passes it back out through its outflow.
+
+!!! tip "In-rack cooling equipment is modeled as a device"
+    Coolant distribution units (CDUs), manifolds, and rear-door heat exchangers (RDHx) are modeled as ordinary (typically zero-U) [devices](../models/dcim/device.md) installed in the rack — exactly as a PDU is modeled as a device with power ports and outlets. The device's make and model come from its [device type](../models/dcim/devicetype.md), and its cooling connections are represented by cooling intake and outflow components. There is no dedicated CDU or RDHx model.
+
+## Racks and Devices
+
+Racks and devices carry lightweight cooling attributes independent of the feed/component topology:
+
+- A [rack](../models/dcim/rack.md) records a **cooling capability** (air-only, hybrid, or liquid-only) and a **cooling capacity** in kilowatts, typically inherited from its rack type.
+- A [device](../models/dcim/device.md) records a **cooling method** (air, liquid, hybrid, or immersion), inherited from its device type and overridable per device.
+

+ 37 - 0
docs/models/dcim/coolingfeed.md

@@ -0,0 +1,37 @@
+# Cooling Feed
+
+A cooling feed represents a coolant loop delivered from a [cooling source](./coolingsource.md) to a particular rack or coolant distribution unit (CDU). The [cooling intakes](./coolingintake.md) a feed supplies are derived from the devices installed in the rack it serves, rather than referenced explicitly.
+
+A single feed represents the entire loop, covering both the supply (cold) and return (warm) paths.
+
+!!! tip
+    In-rack cooling equipment — coolant distribution units (CDUs), manifolds, and rear-door heat exchangers (RDHx) — is modeled as an ordinary (typically zero-U) [device](./device.md) installed in the rack. The device's make and model come from its [device type](./devicetype.md), and a [cooling intake](./coolingintake.md) component connects it to cooling. The feed serving such a device is derived from its rack.
+
+## Fields
+
+### Cooling Source
+
+The [cooling source](./coolingsource.md) which supplies this feed.
+
+### Rack
+
+The [rack](./rack.md) which this feed serves (optional).
+
+### Name
+
+The feed's name or identifier. Must be unique to the assigned cooling source.
+
+### Status
+
+The feed's operational status.
+
+!!! tip
+    Additional statuses may be defined by setting `CoolingFeed.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
+
+### Cooling Capacity
+
+The heat-removal capacity of the feed, in kilowatts (kW).
+
+### Maximum Flow
+
+The maximum rate of coolant flow supported by the feed, expressed as a numeric value with a selectable unit (liters per minute, cubic meters per hour, or gallons per minute). Must be a positive, non-zero value in the selected unit, or left blank.

+ 40 - 0
docs/models/dcim/coolingintake.md

@@ -0,0 +1,40 @@
+# Cooling Intakes
+
+A cooling intake is a device component which consumes coolant, such as a server cold-plate inlet or a coolant distribution unit (CDU) intake. It **receives** coolant from the cold, supply side of a loop (see [cooling](../../features/cooling.md) for the overall flow model). A cooling intake optionally references the upstream [cooling outflow](./coolingoutflow.md) which supplies it.
+
+!!! tip
+    Like most device components, cooling intakes are instantiated automatically from [cooling intake templates](./coolingintaketemplate.md) assigned to the selected device type when a device is created.
+
+## Fields
+
+### Device
+
+The device to which this cooling intake belongs.
+
+### Module
+
+The installed module within the assigned device to which this cooling intake belongs (optional).
+
+### Name
+
+The name of the cooling intake. Must be unique to the parent device.
+
+### Label
+
+An alternative physical label identifying the cooling intake.
+
+### Connector Type
+
+The physical coolant connector type (e.g. UQD, UQDB, QDC, camlock, or threaded NPT/BSP).
+
+### Diameter
+
+The connector diameter, expressed as a numeric value with a selectable unit (millimeters, centimeters, or inches). Must be a positive, non-zero value in the selected unit, or left blank.
+
+### Maximum Flow
+
+The maximum coolant flow rate this port supports, expressed as a numeric value with a selectable unit (liters per minute, cubic meters per hour, or gallons per minute). Must be a positive, non-zero value in the selected unit, or left blank.
+
+### Cooling Outflow
+
+The upstream [cooling outflow](./coolingoutflow.md) which supplies this intake (optional).

+ 3 - 0
docs/models/dcim/coolingintaketemplate.md

@@ -0,0 +1,3 @@
+# Cooling Intake Templates
+
+A template for a cooling intake that will be created on all instantiations of the parent device type. See the [cooling intake](./coolingintake.md) documentation for more detail.

+ 38 - 0
docs/models/dcim/coolingoutflow.md

@@ -0,0 +1,38 @@
+# Cooling Outflows
+
+A cooling outflow is a device component which delivers coolant to a downstream [cooling intake](./coolingintake.md), and generally represents an outlet on a coolant distribution unit (CDU) or manifold. A cooling outflow may optionally be associated with an upstream cooling intake on the same device for path tracing.
+
+A cooling outflow is a **supply** point on the cold, coolant-distribution side of a loop: it passes coolant onward to downstream equipment. It does **not** represent the return of warmed coolant back to the cooling source. The return path is not modeled per-component; instead, a single [cooling feed](./coolingfeed.md) represents the entire loop, covering both the supply (cold) and return (warm) paths.
+
+!!! tip
+    Like most device components, cooling outflows are instantiated automatically from [cooling outflow templates](./coolingoutflowtemplate.md) assigned to the selected device type when a device is created.
+
+## Fields
+
+### Device
+
+The device to which this cooling outflow belongs.
+
+### Module
+
+The installed module within the assigned device to which this cooling outflow belongs (optional).
+
+### Name
+
+The name of the cooling outflow. Must be unique to the parent device.
+
+### Label
+
+An alternative physical label identifying the cooling outflow.
+
+### Connector Type
+
+The physical coolant connector type (e.g. UQD, UQDB, QDC, camlock, or threaded NPT/BSP).
+
+### Diameter
+
+The connector diameter, expressed as a numeric value with a selectable unit (millimeters, centimeters, or inches). Must be a positive, non-zero value in the selected unit, or left blank.
+
+### Cooling Intake
+
+The upstream [cooling intake](./coolingintake.md) on the same device which feeds this outlet (optional).

+ 3 - 0
docs/models/dcim/coolingoutflowtemplate.md

@@ -0,0 +1,3 @@
+# Cooling Outflow Templates
+
+A template for a cooling outflow that will be created on all instantiations of the parent device type. See the [cooling outflow](./coolingoutflow.md) documentation for more detail.

+ 36 - 0
docs/models/dcim/coolingsource.md

@@ -0,0 +1,36 @@
+# Cooling Source
+
+A cooling source represents a facility-level source of cooling, such as a chiller, cooling tower, or dry cooler. It serves as the upstream origin for one or more [cooling feeds](./coolingfeed.md) which distribute coolant to racks and devices. A cooling source is not modeled as a device; it represents external facility plant.
+
+## Fields
+
+### Site
+
+The [site](./site.md) at which the cooling source is located.
+
+### Location
+
+The [location](./location.md) within the site where the cooling source resides (optional).
+
+### Name
+
+The cooling source's name or identifier. Must be unique to the assigned site.
+
+### Type
+
+The type of cooling plant (e.g. chiller, cooling tower, dry cooler, CRAC, or CRAH).
+
+### Status
+
+The operational status of the cooling source.
+
+!!! tip
+    Additional statuses may be defined by setting `CoolingSource.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
+
+### Fluid Type
+
+The coolant used by the source (e.g. water, water/glycol, dielectric fluid, or refrigerant).
+
+### Cooling Capacity
+
+The total heat-removal capacity of the source, expressed in kilowatts (kW).

+ 4 - 0
docs/models/dcim/device.md

@@ -30,6 +30,10 @@ The hardware [device type](./devicetype.md) which defines the device's make & mo
 
 The direction in which air circulates through the device chassis for cooling.
 
+### Cooling Method
+
+The cooling method employed by the device (air, liquid, hybrid, or immersion). If not set, this is inherited from the assigned [device type](./devicetype.md) when the device is created.
+
 ### Serial Number
 
 The unique physical serial number assigned to this device by its manufacturer.

+ 4 - 0
docs/models/dcim/devicetype.md

@@ -57,6 +57,10 @@ Indicates whether this is a parent type (capable of housing child devices), a ch
 
 The default direction in which airflow circulates within the device chassis. This may be configured differently for instantiated devices (e.g. because of different fan modules).
 
+### Cooling Method
+
+The default cooling method employed by devices of this type (air, liquid, hybrid, or immersion). Instantiated devices inherit this value unless overridden.
+
 ### Weight
 
 The numeric weight of the device, including a unit designation (e.g. 10 kilograms or 20 pounds).

+ 4 - 0
docs/models/dcim/moduletype.md

@@ -75,6 +75,10 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms
 
 The direction in which air circulates through the device chassis for cooling.
 
+### Cooling Method
+
+The cooling method employed by modules of this type (air, liquid, hybrid, or immersion). This is useful for liquid-cooled modules such as direct-to-chip accelerator (OAM) modules or liquid-cooled line cards.
+
 ### End of Life
 
 The date after which this module type is no longer supported by its manufacturer. This can be used to identify modules approaching or past their support horizon to aid in hardware lifecycle planning.

+ 14 - 0
docs/models/dcim/rack.md

@@ -54,6 +54,20 @@ The unique physical serial number assigned to this rack.
 
 A unique, locally-administered label used to identify hardware resources.
 
+### Cooling Capability
+
+Describes how the rack is able to cool the equipment installed in it, which indicates what kind of equipment it can accommodate:
+
+- **Air-only**: The rack is cooled by airflow only; no coolant is delivered to it. Only air-cooled equipment can be installed.
+- **Hybrid**: Coolant can be delivered to the rack (e.g. via a [cooling feed](./coolingfeed.md)), but it can also house air-cooled equipment. Suitable for mixed or hybrid deployments.
+- **Liquid-only**: The rack is intended exclusively for liquid-cooled equipment (such as direct-to-chip or immersion systems) and does not provide adequate air cooling on its own.
+
+This attribute documents the rack's intended use so that incompatible equipment—such as high-density liquid-cooled hardware in an air-only rack—can be identified. When the rack is assigned a [rack type](./racktype.md), this value is inherited from the rack type.
+
+### Cooling Capacity
+
+The rack's cooling capacity, expressed in kilowatts (kW). When the rack is assigned a [rack type](./racktype.md), this value is inherited from the rack type.
+
 ## Physical Attributes
 
 Several physical attributes may be defined on each rack, including its width, height, outer dimensions, mounting depth, and weight. These should generally be defined on the [rack type](./racktype.md) assigned to the rack rather than on the rack itself.

+ 8 - 0
docs/models/dcim/racktype.md

@@ -54,6 +54,14 @@ The numeric weight of the rack, including a unit designation (e.g. 10 kilograms
 
 The maximum total weight capacity for all installed devices, inclusive of the rack itself.
 
+### Cooling Capability
+
+The rack design's coolant capability: air-only, hybrid, or liquid-only. Racks of this type inherit this value.
+
+### Cooling Capacity
+
+The rack design's cooling capacity, expressed in kilowatts (kW). Racks of this type inherit this value.
+
 ### Descending Units
 
 If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.)

+ 7 - 0
mkdocs.yml

@@ -71,6 +71,7 @@ nav:
         - Facilities: 'features/facilities.md'
         - Devices & Cabling: 'features/devices-cabling.md'
         - Power Tracking: 'features/power-tracking.md'
+        - Cooling: 'features/cooling.md'
         - IPAM: 'features/ipam.md'
         - VLAN Management: 'features/vlan-management.md'
         - L2VPN & Overlay: 'features/l2vpn-overlay.md'
@@ -202,6 +203,12 @@ nav:
             - ConsolePortTemplate: 'models/dcim/consoleporttemplate.md'
             - ConsoleServerPort: 'models/dcim/consoleserverport.md'
             - ConsoleServerPortTemplate: 'models/dcim/consoleserverporttemplate.md'
+            - CoolingFeed: 'models/dcim/coolingfeed.md'
+            - CoolingIntake: 'models/dcim/coolingintake.md'
+            - CoolingIntakeTemplate: 'models/dcim/coolingintaketemplate.md'
+            - CoolingOutflow: 'models/dcim/coolingoutflow.md'
+            - CoolingOutflowTemplate: 'models/dcim/coolingoutflowtemplate.md'
+            - CoolingSource: 'models/dcim/coolingsource.md'
             - Device: 'models/dcim/device.md'
             - DeviceBay: 'models/dcim/devicebay.md'
             - DeviceBayTemplate: 'models/dcim/devicebaytemplate.md'

+ 1 - 0
netbox/dcim/api/serializers.py

@@ -1,4 +1,5 @@
 from .serializers_.cables import *
+from .serializers_.cooling import *
 from .serializers_.device_components import *
 from .serializers_.devices import *
 from .serializers_.devicetype_components import *

+ 83 - 0
netbox/dcim/api/serializers_/cooling.py

@@ -0,0 +1,83 @@
+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 FlowRateUnitChoices
+from tenancy.api.serializers_.tenants import TenantSerializer
+
+from .racks import RackSerializer
+from .sites import LocationSerializer, SiteSerializer
+
+__all__ = (
+    'CoolingFeedSerializer',
+    'CoolingSourceSerializer',
+)
+
+
+class CoolingSourceSerializer(PrimaryModelSerializer):
+    site = SiteSerializer(nested=True)
+    location = LocationSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    type = ChoiceField(
+        choices=CoolingSourceTypeChoices
+    )
+    status = ChoiceField(
+        choices=CoolingSourceStatusChoices,
+        default=lambda: CoolingSourceStatusChoices.STATUS_ACTIVE,
+    )
+    fluid_type = ChoiceField(
+        choices=FluidTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+
+    # Related object counts
+    coolingfeed_count = RelatedObjectCountField('cooling_feeds')
+
+    class Meta:
+        model = CoolingSource
+        fields = [
+            'id', 'url', 'display_url', 'display', 'site', 'location', 'name', 'type', 'status', 'fluid_type',
+            'cooling_capacity', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'coolingfeed_count', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'coolingfeed_count')
+
+
+class CoolingFeedSerializer(PrimaryModelSerializer):
+    cooling_source = CoolingSourceSerializer(nested=True)
+    rack = RackSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    status = ChoiceField(
+        choices=CoolingFeedStatusChoices,
+        default=lambda: CoolingFeedStatusChoices.STATUS_ACTIVE,
+    )
+    max_flow_unit = ChoiceField(
+        choices=FlowRateUnitChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    tenant = TenantSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = CoolingFeed
+        fields = [
+            'id', 'url', 'display_url', 'display', 'cooling_source', 'rack', 'name', 'status',
+            'cooling_capacity', 'max_flow', 'max_flow_unit', 'description', 'tenant',
+            'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')

+ 83 - 1
netbox/dcim/api/serializers_/device_components.py

@@ -8,6 +8,8 @@ from dcim.constants import *
 from dcim.models import (
     ConsolePort,
     ConsoleServerPort,
+    CoolingIntake,
+    CoolingOutflow,
     DeviceBay,
     FrontPort,
     Interface,
@@ -25,6 +27,7 @@ from ipam.models import VLAN
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.serializers import NetBoxModelSerializer
+from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices
 from users.api.serializers_.mixins import OwnerMixin
 from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
 from wireless.api.serializers_.nested import NestedWirelessLinkSerializer
@@ -38,12 +41,14 @@ from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, V
 from .devicetypes import ModuleBayTypeSerializer
 from .manufacturers import ManufacturerSerializer
 from .mixins import _UNSET, MACAddressShortcutMixin
-from .nested import NestedInterfaceSerializer
+from .nested import NestedCoolingOutflowSerializer, NestedInterfaceSerializer
 from .roles import InventoryItemRoleSerializer
 
 __all__ = (
     'ConsolePortSerializer',
     'ConsoleServerPortSerializer',
+    'CoolingIntakeSerializer',
+    'CoolingOutflowSerializer',
     'DeviceBaySerializer',
     'FrontPortSerializer',
     'InterfaceSerializer',
@@ -199,6 +204,83 @@ class PowerOutletSerializer(
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
+class CoolingIntakeSerializer(OwnerMixin, NetBoxModelSerializer):
+    device = DeviceSerializer(nested=True)
+    module = ModuleSerializer(
+        nested=True,
+        fields=('id', 'url', 'display', 'device', 'module_bay'),
+        required=False,
+        allow_null=True
+    )
+    type = ChoiceField(
+        choices=CoolingConnectorTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    diameter_unit = ChoiceField(
+        choices=DiameterUnitChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    max_flow_unit = ChoiceField(
+        choices=FlowRateUnitChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    cooling_outflow = NestedCoolingOutflowSerializer(
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = CoolingIntake
+        fields = [
+            'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type',
+            'diameter', 'diameter_unit', 'max_flow', 'max_flow_unit', 'cooling_outflow',
+            'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
+
+
+class CoolingOutflowSerializer(OwnerMixin, NetBoxModelSerializer):
+    device = DeviceSerializer(nested=True)
+    module = ModuleSerializer(
+        nested=True,
+        fields=('id', 'url', 'display', 'device', 'module_bay'),
+        required=False,
+        allow_null=True
+    )
+    type = ChoiceField(
+        choices=CoolingConnectorTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    diameter_unit = ChoiceField(
+        choices=DiameterUnitChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    cooling_intake = CoolingIntakeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = CoolingOutflow
+        fields = [
+            'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type',
+            'diameter', 'diameter_unit', 'cooling_intake', 'description', 'owner', 'tags', 'custom_fields',
+            'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
+
+
 class InterfaceSerializer(
     MACAddressShortcutMixin,
     OwnerMixin,

+ 7 - 4
netbox/dcim/api/serializers_/devices.py

@@ -57,6 +57,7 @@ class DeviceSerializer(PrimaryModelSerializer):
     )
     status = ChoiceField(choices=DeviceStatusChoices, required=False)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
+    cooling_method = ChoiceField(choices=CoolingMethodChoices, allow_blank=True, required=False, allow_null=True)
     primary_ip = IPAddressSerializer(
         nested=True,
         read_only=True,
@@ -105,11 +106,13 @@ class DeviceSerializer(PrimaryModelSerializer):
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
             'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
-            'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
-            'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template', 'config_context',
+            'status', 'airflow', 'cooling_method', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster',
+            'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template',
+            'config_context',
             'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
-            'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
-            'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
+            'console_server_port_count', 'power_port_count', 'power_outlet_count', 'cooling_intake_count',
+            'cooling_outflow_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
+            'module_bay_count', 'inventory_item_count',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 

+ 87 - 0
netbox/dcim/api/serializers_/devicetype_components.py

@@ -6,6 +6,8 @@ from dcim.constants import *
 from dcim.models import (
     ConsolePortTemplate,
     ConsoleServerPortTemplate,
+    CoolingIntakeTemplate,
+    CoolingOutflowTemplate,
     DeviceBayTemplate,
     FrontPortTemplate,
     InterfaceTemplate,
@@ -19,6 +21,7 @@ from dcim.models import (
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
+from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices
 from wireless.choices import *
 
 from .base import PortSerializer
@@ -30,6 +33,8 @@ from .roles import InventoryItemRoleSerializer
 __all__ = (
     'ConsolePortTemplateSerializer',
     'ConsoleServerPortTemplateSerializer',
+    'CoolingIntakeTemplateSerializer',
+    'CoolingOutflowTemplateSerializer',
     'DeviceBayTemplateSerializer',
     'FrontPortTemplateSerializer',
     'InterfaceTemplateSerializer',
@@ -170,6 +175,88 @@ class PowerOutletTemplateSerializer(ComponentTemplateSerializer):
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
+class CoolingIntakeTemplateSerializer(ComponentTemplateSerializer):
+    device_type = DeviceTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    module_type = ModuleTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    type = ChoiceField(
+        choices=CoolingConnectorTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    diameter_unit = ChoiceField(
+        choices=DiameterUnitChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    max_flow_unit = ChoiceField(
+        choices=FlowRateUnitChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = CoolingIntakeTemplate
+        fields = [
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type',
+            'diameter', 'diameter_unit', 'max_flow', 'max_flow_unit', 'description',
+            'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class CoolingOutflowTemplateSerializer(ComponentTemplateSerializer):
+    device_type = DeviceTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    module_type = ModuleTypeSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    type = ChoiceField(
+        choices=CoolingConnectorTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    diameter_unit = ChoiceField(
+        choices=DiameterUnitChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    cooling_intake = CoolingIntakeTemplateSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = CoolingOutflowTemplate
+        fields = [
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type',
+            'diameter', 'diameter_unit', 'cooling_intake', 'description', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
 class InterfaceTemplateSerializer(ComponentTemplateSerializer):
     device_type = DeviceTypeSerializer(
         nested=True,

+ 15 - 8
netbox/dcim/api/serializers_/devicetypes.py

@@ -32,6 +32,7 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
     )
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
+    cooling_method = ChoiceField(choices=CoolingMethodChoices, allow_blank=True, required=False, allow_null=True)
     weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
     front_image = serializers.ImageField(required=False, allow_null=True)
     rear_image = serializers.ImageField(required=False, allow_null=True)
@@ -41,6 +42,8 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
     console_server_port_template_count = serializers.IntegerField(read_only=True)
     power_port_template_count = serializers.IntegerField(read_only=True)
     power_outlet_template_count = serializers.IntegerField(read_only=True)
+    cooling_intake_template_count = serializers.IntegerField(read_only=True)
+    cooling_outflow_template_count = serializers.IntegerField(read_only=True)
     interface_template_count = serializers.IntegerField(read_only=True)
     front_port_template_count = serializers.IntegerField(read_only=True)
     rear_port_template_count = serializers.IntegerField(read_only=True)
@@ -53,13 +56,14 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
         model = DeviceType
         fields = [
             'id', 'url', 'display_url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number',
-            'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
-            'weight_unit', 'end_of_life', 'front_image', 'rear_image', 'description', 'owner',
+            'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'cooling_method',
+            'weight', 'weight_unit', 'end_of_life', 'front_image', 'rear_image', 'description', 'owner',
             'comments', 'tags', 'custom_fields',
             'created', 'last_updated', 'device_count', 'console_port_template_count',
             'console_server_port_template_count', 'power_port_template_count', 'power_outlet_template_count',
-            'interface_template_count', 'front_port_template_count', 'rear_port_template_count',
-            'device_bay_template_count', 'module_bay_template_count', 'inventory_item_template_count',
+            'cooling_intake_template_count', 'cooling_outflow_template_count', 'interface_template_count',
+            'front_port_template_count', 'rear_port_template_count', 'device_bay_template_count',
+            'module_bay_template_count', 'inventory_item_template_count',
         ]
         brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
 
@@ -117,6 +121,7 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
         required=False,
         allow_null=True
     )
+    cooling_method = ChoiceField(choices=CoolingMethodChoices, allow_blank=True, required=False, allow_null=True)
     attributes = AttributesField(
         source='attribute_data',
         required=False,
@@ -129,6 +134,8 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
     console_server_port_template_count = serializers.IntegerField(read_only=True)
     power_port_template_count = serializers.IntegerField(read_only=True)
     power_outlet_template_count = serializers.IntegerField(read_only=True)
+    cooling_intake_template_count = serializers.IntegerField(read_only=True)
+    cooling_outflow_template_count = serializers.IntegerField(read_only=True)
     interface_template_count = serializers.IntegerField(read_only=True)
     front_port_template_count = serializers.IntegerField(read_only=True)
     rear_port_template_count = serializers.IntegerField(read_only=True)
@@ -138,11 +145,11 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
         model = ModuleType
         fields = [
             'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
-            'weight', 'weight_unit', 'end_of_life', 'description', 'attributes', 'module_bay_types', 'owner',
-            'comments', 'tags', 'custom_fields',
+            'cooling_method', 'weight', 'weight_unit', 'end_of_life', 'description', 'attributes', 'module_bay_types',
+            'owner', 'comments', 'tags', 'custom_fields',
             'created', 'last_updated', 'module_count', 'console_port_template_count',
             'console_server_port_template_count', 'power_port_template_count', 'power_outlet_template_count',
-            'interface_template_count', 'front_port_template_count', 'rear_port_template_count',
-            'module_bay_template_count',
+            'cooling_intake_template_count', 'cooling_outflow_template_count', 'interface_template_count',
+            'front_port_template_count', 'rear_port_template_count', 'module_bay_template_count',
         ]
         brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description', 'module_count')

+ 9 - 0
netbox/dcim/api/serializers_/nested.py

@@ -5,6 +5,7 @@ from dcim import models
 from netbox.api.serializers import WritableNestedSerializer
 
 __all__ = (
+    'NestedCoolingOutflowSerializer',
     'NestedDeviceBaySerializer',
     'NestedDeviceRoleSerializer',
     'NestedDeviceSerializer',
@@ -68,6 +69,14 @@ class NestedDeviceSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display_url', 'display', 'name']
 
 
+class NestedCoolingOutflowSerializer(WritableNestedSerializer):
+    device = NestedDeviceSerializer(read_only=True)
+
+    class Meta:
+        model = models.CoolingOutflow
+        fields = ['id', 'url', 'display_url', 'display', 'device', 'name']
+
+
 class NestedInterfaceSerializer(WritableNestedSerializer):
     device = NestedDeviceSerializer(read_only=True)
     _occupied = serializers.BooleanField(required=False, read_only=True)

+ 12 - 4
netbox/dcim/api/serializers_/racks.py

@@ -77,6 +77,12 @@ class RackBaseSerializer(PrimaryModelSerializer):
         required=False,
         allow_null=True
     )
+    cooling_capability = ChoiceField(
+        choices=RackCoolingCapabilityChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
 
 
 class RackTypeSerializer(RackBaseSerializer):
@@ -88,8 +94,9 @@ class RackTypeSerializer(RackBaseSerializer):
         fields = [
             'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor',
             'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
-            'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'owner', 'comments',
-            'tags', 'custom_fields', 'created', 'last_updated', 'rack_count',
+            'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'cooling_capability',
+            'cooling_capacity', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'rack_count',
         ]
         brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'rack_count')
 
@@ -152,8 +159,9 @@ class RackSerializer(RackBaseSerializer):
             'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'group', 'tenant',
             'status', 'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit',
             'weight', 'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
-            'outer_unit', 'mounting_depth', 'airflow', 'description', 'owner', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated', 'device_count', 'powerfeed_count',
+            'outer_unit', 'mounting_depth', 'airflow', 'cooling_capability', 'cooling_capacity',
+            'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
+            'powerfeed_count',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
 

+ 8 - 0
netbox/dcim/api/urls.py

@@ -30,6 +30,8 @@ router.register('console-port-templates', views.ConsolePortTemplateViewSet)
 router.register('console-server-port-templates', views.ConsoleServerPortTemplateViewSet)
 router.register('power-port-templates', views.PowerPortTemplateViewSet)
 router.register('power-outlet-templates', views.PowerOutletTemplateViewSet)
+router.register('cooling-intake-templates', views.CoolingIntakeTemplateViewSet)
+router.register('cooling-outflow-templates', views.CoolingOutflowTemplateViewSet)
 router.register('interface-templates', views.InterfaceTemplateViewSet)
 router.register('front-port-templates', views.FrontPortTemplateViewSet)
 router.register('rear-port-templates', views.RearPortTemplateViewSet)
@@ -49,6 +51,8 @@ router.register('console-ports', views.ConsolePortViewSet)
 router.register('console-server-ports', views.ConsoleServerPortViewSet)
 router.register('power-ports', views.PowerPortViewSet)
 router.register('power-outlets', views.PowerOutletViewSet)
+router.register('cooling-intakes', views.CoolingIntakeViewSet)
+router.register('cooling-outflows', views.CoolingOutflowViewSet)
 router.register('interfaces', views.InterfaceViewSet)
 router.register('front-ports', views.FrontPortViewSet)
 router.register('rear-ports', views.RearPortViewSet)
@@ -74,6 +78,10 @@ router.register('virtual-chassis', views.VirtualChassisViewSet)
 router.register('power-panels', views.PowerPanelViewSet)
 router.register('power-feeds', views.PowerFeedViewSet)
 
+# Cooling
+router.register('cooling-sources', views.CoolingSourceViewSet)
+router.register('cooling-feeds', views.CoolingFeedViewSet)
+
 # Miscellaneous
 router.register('connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
 

+ 44 - 0
netbox/dcim/api/views.py

@@ -332,6 +332,18 @@ class PowerOutletTemplateViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.PowerOutletTemplateFilterSet
 
 
+class CoolingIntakeTemplateViewSet(NetBoxModelViewSet):
+    queryset = CoolingIntakeTemplate.objects.all()
+    serializer_class = serializers.CoolingIntakeTemplateSerializer
+    filterset_class = filtersets.CoolingIntakeTemplateFilterSet
+
+
+class CoolingOutflowTemplateViewSet(NetBoxModelViewSet):
+    queryset = CoolingOutflowTemplate.objects.all()
+    serializer_class = serializers.CoolingOutflowTemplateSerializer
+    filterset_class = filtersets.CoolingOutflowTemplateFilterSet
+
+
 class InterfaceTemplateViewSet(NetBoxModelViewSet):
     queryset = InterfaceTemplate.objects.all()
     serializer_class = serializers.InterfaceTemplateSerializer
@@ -477,6 +489,18 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
     filterset_class = filtersets.PowerOutletFilterSet
 
 
+class CoolingIntakeViewSet(NetBoxModelViewSet):
+    queryset = CoolingIntake.objects.all()
+    serializer_class = serializers.CoolingIntakeSerializer
+    filterset_class = filtersets.CoolingIntakeFilterSet
+
+
+class CoolingOutflowViewSet(NetBoxModelViewSet):
+    queryset = CoolingOutflow.objects.all()
+    serializer_class = serializers.CoolingOutflowSerializer
+    filterset_class = filtersets.CoolingOutflowFilterSet
+
+
 class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = Interface.objects.prefetch_related(
         GenericPrefetch(
@@ -621,6 +645,26 @@ class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
     filterset_class = filtersets.PowerFeedFilterSet
 
 
+#
+# Cooling sources
+#
+
+class CoolingSourceViewSet(NetBoxModelViewSet):
+    queryset = CoolingSource.objects.all()
+    serializer_class = serializers.CoolingSourceSerializer
+    filterset_class = filtersets.CoolingSourceFilterSet
+
+
+#
+# Cooling feeds
+#
+
+class CoolingFeedViewSet(NetBoxModelViewSet):
+    queryset = CoolingFeed.objects.all()
+    serializer_class = serializers.CoolingFeedSerializer
+    filterset_class = filtersets.CoolingFeedFilterSet
+
+
 #
 # Miscellaneous
 #

+ 114 - 0
netbox/dcim/choices.py

@@ -2116,6 +2116,120 @@ class PowerOutletStatusChoices(ChoiceSet):
     ]
 
 
+#
+# Cooling
+#
+
+class CoolingMethodChoices(ChoiceSet):
+    METHOD_AIR = 'air'
+    METHOD_LIQUID = 'liquid'
+    METHOD_HYBRID = 'hybrid'
+    METHOD_IMMERSION = 'immersion'
+
+    CHOICES = [
+        (METHOD_AIR, _('Air'), 'cyan'),
+        (METHOD_LIQUID, _('Liquid'), 'blue'),
+        (METHOD_HYBRID, _('Hybrid'), 'purple'),
+        (METHOD_IMMERSION, _('Immersion'), 'indigo'),
+    ]
+
+
+class CoolingSourceTypeChoices(ChoiceSet):
+    key = 'CoolingSource.type'
+
+    TYPE_CHILLER = 'chiller'
+    TYPE_COOLING_TOWER = 'cooling-tower'
+    TYPE_DRY_COOLER = 'dry-cooler'
+    TYPE_CRAC = 'crac'
+    TYPE_CRAH = 'crah'
+
+    CHOICES = [
+        (TYPE_CHILLER, _('Chiller')),
+        (TYPE_COOLING_TOWER, _('Cooling tower')),
+        (TYPE_DRY_COOLER, _('Dry cooler')),
+        (TYPE_CRAC, _('CRAC')),
+        (TYPE_CRAH, _('CRAH')),
+    ]
+
+
+class CoolingSourceStatusChoices(ChoiceSet):
+    key = 'CoolingSource.status'
+
+    STATUS_OFFLINE = 'offline'
+    STATUS_ACTIVE = 'active'
+    STATUS_PLANNED = 'planned'
+    STATUS_FAILED = 'failed'
+
+    CHOICES = [
+        (STATUS_OFFLINE, _('Offline'), 'gray'),
+        (STATUS_ACTIVE, _('Active'), 'green'),
+        (STATUS_PLANNED, _('Planned'), 'blue'),
+        (STATUS_FAILED, _('Failed'), 'red'),
+    ]
+
+
+class CoolingFeedStatusChoices(ChoiceSet):
+    key = 'CoolingFeed.status'
+
+    STATUS_OFFLINE = 'offline'
+    STATUS_ACTIVE = 'active'
+    STATUS_PLANNED = 'planned'
+    STATUS_FAILED = 'failed'
+
+    CHOICES = [
+        (STATUS_OFFLINE, _('Offline'), 'gray'),
+        (STATUS_ACTIVE, _('Active'), 'green'),
+        (STATUS_PLANNED, _('Planned'), 'blue'),
+        (STATUS_FAILED, _('Failed'), 'red'),
+    ]
+
+
+class FluidTypeChoices(ChoiceSet):
+    FLUID_WATER = 'water'
+    FLUID_WATER_GLYCOL = 'water-glycol'
+    FLUID_DIELECTRIC = 'dielectric'
+    FLUID_REFRIGERANT = 'refrigerant'
+
+    CHOICES = [
+        (FLUID_WATER, _('Water')),
+        (FLUID_WATER_GLYCOL, _('Water/glycol')),
+        (FLUID_DIELECTRIC, _('Dielectric')),
+        (FLUID_REFRIGERANT, _('Refrigerant')),
+    ]
+
+
+class RackCoolingCapabilityChoices(ChoiceSet):
+    CAPABILITY_AIR_ONLY = 'air-only'
+    CAPABILITY_HYBRID = 'hybrid'
+    CAPABILITY_LIQUID_ONLY = 'liquid-only'
+
+    CHOICES = [
+        (CAPABILITY_AIR_ONLY, _('Air only'), 'cyan'),
+        (CAPABILITY_HYBRID, _('Hybrid'), 'blue'),
+        (CAPABILITY_LIQUID_ONLY, _('Liquid only'), 'purple'),
+    ]
+
+
+class CoolingConnectorTypeChoices(ChoiceSet):
+    TYPE_UQD = 'uqd'
+    TYPE_UQDB = 'uqdb'
+    TYPE_QDC = 'qdc'
+    TYPE_CAMLOCK = 'camlock'
+    TYPE_NPT = 'npt'
+    TYPE_BSP = 'bsp'
+    TYPE_PROPRIETARY = 'proprietary'
+
+    CHOICES = [
+        (TYPE_UQD, _('UQD (Universal Quick Disconnect)')),
+        (TYPE_UQDB, _('UQDB (Universal Quick Disconnect, Blind-mate)')),
+        (TYPE_QDC, _('QDC (Quick Disconnect Coupling)')),
+        (TYPE_CAMLOCK, _('Camlock (cam-and-groove)')),
+        (TYPE_NPT, _('NPT (threaded)')),
+        (TYPE_BSP, _('BSP (threaded)')),
+        (TYPE_PROPRIETARY, _('Proprietary')),
+    ]
+
+
 #
 # VDC
 #

+ 4 - 0
netbox/dcim/constants.py

@@ -97,6 +97,8 @@ MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
     model__in=(
         'consoleporttemplate',
         'consoleserverporttemplate',
+        'coolingintaketemplate',
+        'coolingoutflowtemplate',
         'frontporttemplate',
         'interfacetemplate',
         'poweroutlettemplate',
@@ -109,6 +111,8 @@ MODULAR_COMPONENT_MODELS = Q(
     model__in=(
         'consoleport',
         'consoleserverport',
+        'coolingintake',
+        'coolingoutflow',
         'frontport',
         'interface',
         'poweroutlet',

+ 304 - 8
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
+from netbox.choices import ColorChoices, DiameterUnitChoices, FlowRateUnitChoices
 from netbox.filtersets import (
     AttributeFiltersMixin,
     BaseFilterSet,
@@ -57,6 +57,12 @@ __all__ = (
     'ConsolePortTemplateFilterSet',
     'ConsoleServerPortFilterSet',
     'ConsoleServerPortTemplateFilterSet',
+    'CoolingFeedFilterSet',
+    'CoolingIntakeFilterSet',
+    'CoolingIntakeTemplateFilterSet',
+    'CoolingOutflowFilterSet',
+    'CoolingOutflowTemplateFilterSet',
+    'CoolingSourceFilterSet',
     'DeviceBayFilterSet',
     'DeviceBayTemplateFilterSet',
     'DeviceFilterSet',
@@ -355,12 +361,17 @@ class RackTypeFilterSet(PrimaryModelFilterSet):
         choices=RackWidthChoices,
         distinct=False,
     )
+    cooling_capability = django_filters.MultipleChoiceFilter(
+        choices=RackCoolingCapabilityChoices,
+        distinct=False,
+    )
 
     class Meta:
         model = RackType
         fields = (
             'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height',
-            'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
+            'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'cooling_capacity',
+            'description',
 
             # Counters
             'rack_count',
@@ -495,12 +506,17 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS
         lookup_expr='iexact'
     )
 
+    cooling_capability = django_filters.MultipleChoiceFilter(
+        choices=RackCoolingCapabilityChoices,
+        distinct=False,
+    )
+
     class Meta:
         model = Rack
         fields = (
             'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
-            'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight',
-            'weight_unit', 'description',
+            'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'cooling_capacity',
+            'weight', 'max_weight', 'weight_unit', 'description',
         )
 
     def search(self, queryset, name, value):
@@ -728,13 +744,15 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
         model = DeviceType
         fields = (
             'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
-            'subdevice_role', 'airflow', 'weight', 'weight_unit', 'end_of_life', 'description',
+            'subdevice_role', 'airflow', 'cooling_method', 'weight', 'weight_unit', 'end_of_life', 'description',
 
             # Counters
             'console_port_template_count',
             'console_server_port_template_count',
             'power_port_template_count',
             'power_outlet_template_count',
+            'cooling_intake_template_count',
+            'cooling_outflow_template_count',
             'interface_template_count',
             'front_port_template_count',
             'rear_port_template_count',
@@ -929,13 +947,16 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
     class Meta:
         model = ModuleType
         fields = (
-            'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'end_of_life', 'description',
+            'id', 'model', 'part_number', 'airflow', 'cooling_method', 'weight', 'weight_unit', 'end_of_life',
+            'description',
 
             # Counters
             'console_port_template_count',
             'console_server_port_template_count',
             'power_port_template_count',
             'power_outlet_template_count',
+            'cooling_intake_template_count',
+            'cooling_outflow_template_count',
             'interface_template_count',
             'front_port_template_count',
             'rear_port_template_count',
@@ -1051,6 +1072,55 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
         fields = ('id', 'name', 'label', 'type', 'color', 'feed_leg', 'description')
 
 
+@register_filterset
+class CoolingIntakeTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=CoolingConnectorTypeChoices,
+        distinct=False,
+        null_value=None
+    )
+    diameter_unit = django_filters.MultipleChoiceFilter(
+        choices=DiameterUnitChoices,
+        distinct=False,
+        null_value=None
+    )
+    max_flow_unit = django_filters.MultipleChoiceFilter(
+        choices=FlowRateUnitChoices,
+        distinct=False,
+        null_value=None
+    )
+
+    class Meta:
+        model = CoolingIntakeTemplate
+        fields = (
+            'id', 'name', 'label', 'diameter', 'max_flow', 'description',
+        )
+
+
+@register_filterset
+class CoolingOutflowTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=CoolingConnectorTypeChoices,
+        distinct=False,
+        null_value=None
+    )
+    diameter_unit = django_filters.MultipleChoiceFilter(
+        choices=DiameterUnitChoices,
+        distinct=False,
+        null_value=None
+    )
+    cooling_intake_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='cooling_intake',
+        queryset=CoolingIntakeTemplate.objects.all(),
+        distinct=False,
+        label=_('Cooling intake (ID)'),
+    )
+
+    class Meta:
+        model = CoolingOutflowTemplate
+        fields = ('id', 'name', 'label', 'diameter', 'description')
+
+
 @register_filterset
 class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
@@ -1542,14 +1612,16 @@ class DeviceFilterSet(
     class Meta:
         model = Device
         fields = (
-            'id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority',
-            'description',
+            'id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'cooling_method', 'vc_position',
+            'vc_priority', 'description',
 
             # Counters
             'console_port_count',
             'console_server_port_count',
             'power_port_count',
             'power_outlet_count',
+            'cooling_intake_count',
+            'cooling_outflow_count',
             'interface_count',
             'front_port_count',
             'rear_port_count',
@@ -2102,6 +2174,63 @@ class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
         )
 
 
+@register_filterset
+class CoolingIntakeFilterSet(ModularDeviceComponentFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=CoolingConnectorTypeChoices,
+        distinct=False,
+        null_value=None
+    )
+    diameter_unit = django_filters.MultipleChoiceFilter(
+        choices=DiameterUnitChoices,
+        distinct=False,
+        null_value=None
+    )
+    max_flow_unit = django_filters.MultipleChoiceFilter(
+        choices=FlowRateUnitChoices,
+        distinct=False,
+        null_value=None
+    )
+    cooling_outflow_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=CoolingOutflow.objects.all(),
+        field_name='cooling_outflow',
+        distinct=False,
+        label=_('Cooling outflow (ID)'),
+    )
+
+    class Meta:
+        model = CoolingIntake
+        fields = (
+            'id', 'name', 'label', 'diameter', 'max_flow', 'description',
+        )
+
+
+@register_filterset
+class CoolingOutflowFilterSet(ModularDeviceComponentFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=CoolingConnectorTypeChoices,
+        distinct=False,
+        null_value=None
+    )
+    diameter_unit = django_filters.MultipleChoiceFilter(
+        choices=DiameterUnitChoices,
+        distinct=False,
+        null_value=None
+    )
+    cooling_intake_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='cooling_intake',
+        queryset=CoolingIntake.objects.all(),
+        distinct=False,
+        label=_('Cooling intake (ID)'),
+    )
+
+    class Meta:
+        model = CoolingOutflow
+        fields = (
+            'id', 'name', 'label', 'diameter', 'description',
+        )
+
+
 @register_filterset
 class MACAddressFilterSet(PrimaryModelFilterSet):
     mac_address = MultiValueMACAddressFilter()
@@ -3121,6 +3250,173 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CabledObjectFilterSet, PathEndpo
         return queryset.filter(qs_filter)
 
 
+@register_filterset
+class CoolingSourceFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region',
+        lookup_expr='in',
+        label=_('Region (ID)'),
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Region (slug)'),
+    )
+    site_group_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='site__group',
+        lookup_expr='in',
+        label=_('Site group (ID)'),
+    )
+    site_group = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='site__group',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Site group (slug)'),
+    )
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Site.objects.all(),
+        distinct=False,
+        label=_('Site (ID)'),
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        field_name='site__slug',
+        queryset=Site.objects.all(),
+        distinct=False,
+        to_field_name='slug',
+        label=_('Site name (slug)'),
+    )
+    location_id = TreeNodeMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        field_name='location',
+        lookup_expr='in',
+        label=_('Location (ID)'),
+    )
+    location = TreeNodeMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        field_name='location',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Location (slug)'),
+    )
+    type = django_filters.MultipleChoiceFilter(
+        choices=CoolingSourceTypeChoices,
+        distinct=False,
+        null_value=None
+    )
+    status = django_filters.MultipleChoiceFilter(
+        choices=CoolingSourceStatusChoices,
+        distinct=False,
+        null_value=None
+    )
+    fluid_type = django_filters.MultipleChoiceFilter(
+        choices=FluidTypeChoices,
+        distinct=False,
+        null_value=None
+    )
+
+    class Meta:
+        model = CoolingSource
+        fields = (
+            'id', 'name', 'cooling_capacity', 'description',
+        )
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(name__icontains=value) |
+            Q(description__icontains=value) |
+            Q(comments__icontains=value)
+        )
+        return queryset.filter(qs_filter)
+
+
+@register_filterset
+class CoolingFeedFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='cooling_source__site__region',
+        lookup_expr='in',
+        label=_('Region (ID)'),
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='cooling_source__site__region',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Region (slug)'),
+    )
+    site_group_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='cooling_source__site__group',
+        lookup_expr='in',
+        label=_('Site group (ID)'),
+    )
+    site_group = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='cooling_source__site__group',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Site group (slug)'),
+    )
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='cooling_source__site',
+        queryset=Site.objects.all(),
+        distinct=False,
+        label=_('Site (ID)'),
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        field_name='cooling_source__site__slug',
+        queryset=Site.objects.all(),
+        distinct=False,
+        to_field_name='slug',
+        label=_('Site name (slug)'),
+    )
+    cooling_source_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=CoolingSource.objects.all(),
+        distinct=False,
+        label=_('Cooling source (ID)'),
+    )
+    rack_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='rack',
+        queryset=Rack.objects.all(),
+        distinct=False,
+        label=_('Rack (ID)'),
+    )
+    status = django_filters.MultipleChoiceFilter(
+        choices=CoolingFeedStatusChoices,
+        distinct=False,
+        null_value=None
+    )
+    max_flow_unit = django_filters.MultipleChoiceFilter(
+        choices=FlowRateUnitChoices,
+        distinct=False,
+        null_value=None
+    )
+
+    class Meta:
+        model = CoolingFeed
+        fields = (
+            'id', 'name', 'cooling_capacity', 'max_flow', 'description',
+        )
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(name__icontains=value) |
+            Q(description__icontains=value) |
+            Q(cooling_source__name__icontains=value) |
+            Q(comments__icontains=value)
+        )
+        return queryset.filter(qs_filter)
+
+
 #
 # Connection filter sets
 #

+ 29 - 0
netbox/dcim/forms/bulk_create.py

@@ -13,6 +13,8 @@ from .object_create import ComponentCreateForm
 __all__ = (
     'ConsolePortBulkCreateForm',
     'ConsoleServerPortBulkCreateForm',
+    'CoolingIntakeBulkCreateForm',
+    'CoolingOutflowBulkCreateForm',
     'DeviceBayBulkCreateForm',
     # 'FrontPortBulkCreateForm',
     'InterfaceBulkCreateForm',
@@ -81,6 +83,33 @@ class PowerOutletBulkCreateForm(
     )
 
 
+class CoolingIntakeBulkCreateForm(
+    form_from_model(
+        CoolingIntake,
+        [
+            'type', 'diameter', 'diameter_unit', 'max_flow', 'max_flow_unit'
+        ]
+    ),
+    DeviceBulkAddComponentForm
+):
+    model = CoolingIntake
+    field_order = (
+        'name', 'label', 'type', 'diameter', 'diameter_unit', 'max_flow', 'max_flow_unit',
+        'description', 'tags',
+    )
+
+
+class CoolingOutflowBulkCreateForm(
+    form_from_model(CoolingOutflow, ['type', 'diameter', 'diameter_unit']),
+    DeviceBulkAddComponentForm
+):
+    model = CoolingOutflow
+    field_order = (
+        'name', 'label', 'type', 'diameter', 'diameter_unit',
+        'description', 'tags',
+    )
+
+
 class InterfaceBulkCreateForm(
     form_from_model(Interface, [
         'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type', 'rf_role'

+ 366 - 8
netbox/dcim/forms/bulk_edit.py

@@ -1,3 +1,5 @@
+from decimal import Decimal
+
 from django import forms
 from django.conf import settings
 from django.utils.translation import gettext_lazy as _
@@ -41,6 +43,12 @@ __all__ = (
     'ConsolePortTemplateBulkEditForm',
     'ConsoleServerPortBulkEditForm',
     'ConsoleServerPortTemplateBulkEditForm',
+    'CoolingFeedBulkEditForm',
+    'CoolingIntakeBulkEditForm',
+    'CoolingIntakeTemplateBulkEditForm',
+    'CoolingOutflowBulkEditForm',
+    'CoolingOutflowTemplateBulkEditForm',
+    'CoolingSourceBulkEditForm',
     'DeviceBayBulkEditForm',
     'DeviceBayTemplateBulkEditForm',
     'DeviceBulkEditForm',
@@ -302,6 +310,16 @@ class RackTypeBulkEditForm(PrimaryModelBulkEditForm):
         required=False,
         initial=''
     )
+    cooling_capability = forms.ChoiceField(
+        label=_('Cooling capability'),
+        choices=add_blank_choice(RackCoolingCapabilityChoices),
+        required=False
+    )
+    cooling_capacity = forms.DecimalField(
+        label=_('Cooling capacity'),
+        min_value=0,
+        required=False
+    )
 
     model = RackType
     fieldsets = (
@@ -313,10 +331,11 @@ class RackTypeBulkEditForm(PrimaryModelBulkEditForm):
             name=_('Dimensions')
         ),
         FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
+        FieldSet('cooling_capability', 'cooling_capacity', name=_('Cooling')),
     )
     nullable_fields = (
         'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'weight',
-        'max_weight', 'weight_unit', 'description', 'comments',
+        'max_weight', 'weight_unit', 'cooling_capability', 'cooling_capacity', 'description', 'comments',
     )
 
 
@@ -439,6 +458,16 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         choices=add_blank_choice(RackAirflowChoices),
         required=False
     )
+    cooling_capability = forms.ChoiceField(
+        label=_('Cooling capability'),
+        choices=add_blank_choice(RackCoolingCapabilityChoices),
+        required=False
+    )
+    cooling_capacity = forms.DecimalField(
+        label=_('Cooling capacity'),
+        min_value=0,
+        required=False
+    )
     weight = forms.DecimalField(
         label=_('Weight'),
         min_value=0,
@@ -464,11 +493,13 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
         FieldSet('outer_width', 'outer_height', 'outer_depth', 'outer_unit', name=_('Outer Dimensions')),
         FieldSet('form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'mounting_depth', name=_('Hardware')),
+        FieldSet('cooling_capability', 'cooling_capacity', name=_('Cooling')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
     )
     nullable_fields = (
         'location', 'group', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_height', 'outer_depth',
-        'outer_unit', 'weight', 'max_weight', 'weight_unit', 'description', 'comments',
+        'outer_unit', 'cooling_capability', 'cooling_capacity', 'weight', 'max_weight', 'weight_unit', 'description',
+        'comments',
     )
 
 
@@ -540,6 +571,11 @@ class DeviceTypeBulkEditForm(PrimaryModelBulkEditForm):
         choices=add_blank_choice(DeviceAirflowChoices),
         required=False
     )
+    cooling_method = forms.ChoiceField(
+        label=_('Cooling method'),
+        choices=add_blank_choice(CoolingMethodChoices),
+        required=False
+    )
     weight = forms.DecimalField(
         label=_('Weight'),
         min_value=0,
@@ -561,12 +597,14 @@ class DeviceTypeBulkEditForm(PrimaryModelBulkEditForm):
     fieldsets = (
         FieldSet(
             'manufacturer', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
-            'airflow', 'description', name=_('Device Type')
+            'airflow', 'cooling_method', 'description', name=_('Device Type')
         ),
         FieldSet('weight', 'weight_unit', name=_('Weight')),
         FieldSet('end_of_life', name=_('Lifecycle')),
     )
-    nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'end_of_life', 'description', 'comments')
+    nullable_fields = (
+        'part_number', 'airflow', 'cooling_method', 'weight', 'weight_unit', 'end_of_life', 'description', 'comments',
+    )
 
 
 class ModuleBayTypeBulkEditForm(PrimaryModelBulkEditForm):
@@ -620,6 +658,11 @@ class ModuleTypeBulkEditForm(PrimaryModelBulkEditForm):
         choices=add_blank_choice(ModuleAirflowChoices),
         required=False
     )
+    cooling_method = forms.ChoiceField(
+        label=_('Cooling method'),
+        choices=add_blank_choice(CoolingMethodChoices),
+        required=False
+    )
     weight = forms.DecimalField(
         label=_('Weight'),
         min_value=0,
@@ -652,14 +695,16 @@ class ModuleTypeBulkEditForm(PrimaryModelBulkEditForm):
     fieldsets = (
         FieldSet('profile', 'manufacturer', 'part_number', 'description', name=_('Module Type')),
         FieldSet(
-            'airflow',
             InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
             name=_('Chassis')
         ),
+        FieldSet('cooling_method', 'airflow', name=_('Cooling')),
         FieldSet('add_module_bay_types', 'remove_module_bay_types', name=_('Bay Types')),
         FieldSet('end_of_life', name=_('Lifecycle')),
     )
-    nullable_fields = ('part_number', 'weight', 'weight_unit', 'profile', 'end_of_life', 'description', 'comments')
+    nullable_fields = (
+        'part_number', 'weight', 'weight_unit', 'profile', 'end_of_life', 'description', 'comments', 'cooling_method',
+    )
 
 
 class DeviceRoleBulkEditForm(NestedGroupModelBulkEditForm):
@@ -769,6 +814,11 @@ class DeviceBulkEditForm(PrimaryModelBulkEditForm):
         choices=add_blank_choice(DeviceAirflowChoices),
         required=False
     )
+    cooling_method = forms.ChoiceField(
+        label=_('Cooling method'),
+        choices=add_blank_choice(CoolingMethodChoices),
+        required=False
+    )
     serial = forms.CharField(
         max_length=50,
         required=False,
@@ -792,12 +842,12 @@ class DeviceBulkEditForm(PrimaryModelBulkEditForm):
     fieldsets = (
         FieldSet('role', 'status', 'tenant', 'platform', 'description', name=_('Device')),
         FieldSet('site', 'location', name=_('Location')),
-        FieldSet('manufacturer', 'device_type', 'airflow', 'serial', name=_('Hardware')),
+        FieldSet('manufacturer', 'device_type', 'airflow', 'cooling_method', 'serial', name=_('Hardware')),
         FieldSet('config_template', name=_('Configuration')),
         FieldSet('cluster', name=_('Virtualization')),
     )
     nullable_fields = (
-        'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'cluster', 'comments',
+        'location', 'tenant', 'platform', 'serial', 'airflow', 'cooling_method', 'description', 'cluster', 'comments',
     )
 
 
@@ -1036,6 +1086,130 @@ class PowerFeedBulkEditForm(PrimaryModelBulkEditForm):
     nullable_fields = ('location', 'tenant', 'description', 'comments')
 
 
+#
+# Cooling
+#
+
+class CoolingSourceBulkEditForm(PrimaryModelBulkEditForm):
+    region = DynamicModelChoiceField(
+        label=_('Region'),
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
+    site_group = DynamicModelChoiceField(
+        label=_('Site group'),
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
+    site = DynamicModelChoiceField(
+        label=_('Site'),
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region',
+            'group_id': '$site_group',
+        }
+    )
+    location = DynamicModelChoiceField(
+        label=_('Location'),
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        }
+    )
+    type = forms.ChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(CoolingSourceTypeChoices),
+        required=False,
+        initial=''
+    )
+    status = forms.ChoiceField(
+        label=_('Status'),
+        choices=add_blank_choice(CoolingSourceStatusChoices),
+        required=False,
+        initial=''
+    )
+    fluid_type = forms.ChoiceField(
+        label=_('Fluid type'),
+        choices=add_blank_choice(FluidTypeChoices),
+        required=False,
+        initial=''
+    )
+    cooling_capacity = forms.DecimalField(
+        label=_('Cooling capacity'),
+        min_value=0,
+        required=False
+    )
+
+    model = CoolingSource
+    fieldsets = (
+        FieldSet('region', 'site_group', 'site', 'location', 'type', 'status', 'description'),
+        FieldSet('fluid_type', 'cooling_capacity', name=_('Characteristics')),
+    )
+    nullable_fields = (
+        'location', 'fluid_type', 'cooling_capacity', 'description', 'comments',
+    )
+
+
+class CoolingFeedBulkEditForm(PrimaryModelBulkEditForm):
+    cooling_source = DynamicModelChoiceField(
+        label=_('Cooling source'),
+        queryset=CoolingSource.objects.all(),
+        required=False
+    )
+    rack = DynamicModelChoiceField(
+        label=_('Rack'),
+        queryset=Rack.objects.all(),
+        required=False,
+    )
+    status = forms.ChoiceField(
+        label=_('Status'),
+        choices=add_blank_choice(CoolingFeedStatusChoices),
+        required=False,
+        initial=''
+    )
+    cooling_capacity = forms.DecimalField(
+        label=_('Cooling capacity'),
+        min_value=0,
+        required=False
+    )
+    max_flow = forms.DecimalField(
+        label=_('Max flow'),
+        min_value=Decimal('0.01'),
+        required=False
+    )
+    max_flow_unit = forms.ChoiceField(
+        label=_('Max flow unit'),
+        choices=add_blank_choice(FlowRateUnitChoices),
+        required=False,
+        initial=''
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+
+    model = CoolingFeed
+    fieldsets = (
+        FieldSet('cooling_source', 'rack', 'status', 'description', 'tenant'),
+        FieldSet(
+            'cooling_capacity', 'max_flow', 'max_flow_unit',
+            name=_('Characteristics')
+        ),
+    )
+    nullable_fields = (
+        'rack', 'cooling_capacity', 'max_flow', 'max_flow_unit',
+        'tenant', 'description', 'comments',
+    )
+
+
 #
 # Device component templates
 #
@@ -1176,6 +1350,125 @@ class PowerOutletTemplateBulkEditForm(ComponentTemplateBulkEditForm):
             self.fields['power_port'].widget.attrs['disabled'] = True
 
 
+class CoolingIntakeTemplateBulkEditForm(ComponentTemplateBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=CoolingIntakeTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    label = forms.CharField(
+        label=_('Label'),
+        max_length=64,
+        required=False
+    )
+    type = forms.ChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(CoolingConnectorTypeChoices),
+        required=False
+    )
+    diameter = forms.DecimalField(
+        label=_('Diameter'),
+        min_value=Decimal('0.01'),
+        required=False
+    )
+    diameter_unit = forms.ChoiceField(
+        label=_('Diameter unit'),
+        choices=add_blank_choice(DiameterUnitChoices),
+        required=False
+    )
+    max_flow = forms.DecimalField(
+        label=_('Max flow'),
+        min_value=Decimal('0.01'),
+        required=False
+    )
+    max_flow_unit = forms.ChoiceField(
+        label=_('Max flow unit'),
+        choices=add_blank_choice(FlowRateUnitChoices),
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        required=False
+    )
+
+    fieldsets = (
+        FieldSet(
+            'label', 'type',
+            InlineFields('diameter', 'diameter_unit', label=_('Diameter')),
+            InlineFields('max_flow', 'max_flow_unit', label=_('Max flow')),
+            'description',
+        ),
+    )
+    nullable_fields = (
+        'label', 'type', 'diameter', 'diameter_unit', 'max_flow', 'max_flow_unit',
+        'description',
+    )
+
+
+class CoolingOutflowTemplateBulkEditForm(ComponentTemplateBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=CoolingOutflowTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    device_type = forms.ModelChoiceField(
+        label=_('Device type'),
+        queryset=DeviceType.objects.all(),
+        required=False,
+        disabled=True,
+        widget=forms.HiddenInput()
+    )
+    label = forms.CharField(
+        label=_('Label'),
+        max_length=64,
+        required=False
+    )
+    type = forms.ChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(CoolingConnectorTypeChoices),
+        required=False
+    )
+    diameter = forms.DecimalField(
+        label=_('Diameter'),
+        min_value=Decimal('0.01'),
+        required=False
+    )
+    diameter_unit = forms.ChoiceField(
+        label=_('Diameter unit'),
+        choices=add_blank_choice(DiameterUnitChoices),
+        required=False
+    )
+    cooling_intake = forms.ModelChoiceField(
+        label=_('Cooling intake'),
+        queryset=CoolingIntakeTemplate.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        required=False
+    )
+
+    fieldsets = (
+        FieldSet(
+            'label', 'type',
+            InlineFields('diameter', 'diameter_unit', label=_('Diameter')),
+            'cooling_intake', 'description',
+        ),
+    )
+    nullable_fields = (
+        'label', 'type', 'diameter', 'diameter_unit', 'cooling_intake', 'description',
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit cooling_intake queryset to CoolingIntakeTemplates which belong to the parent DeviceType
+        if 'device_type' in self.initial:
+            device_type = DeviceType.objects.filter(pk=self.initial['device_type']).first()
+            self.fields['cooling_intake'].queryset = CoolingIntakeTemplate.objects.filter(device_type=device_type)
+        else:
+            self.fields['cooling_intake'].choices = ()
+            self.fields['cooling_intake'].widget.attrs['disabled'] = True
+
+
 class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=InterfaceTemplate.objects.all(),
@@ -1482,6 +1775,71 @@ class PowerOutletBulkEditForm(
             self.fields['power_port'].widget.attrs['disabled'] = True
 
 
+class CoolingIntakeBulkEditForm(
+    ComponentBulkEditForm,
+    form_from_model(CoolingIntake, [
+        'label', 'type', 'diameter', 'diameter_unit', 'max_flow', 'max_flow_unit',
+        'description'
+    ])
+):
+    cooling_outflow = DynamicModelChoiceField(
+        label=_('Cooling outflow'),
+        queryset=CoolingOutflow.objects.all(),
+        required=False
+    )
+
+    model = CoolingIntake
+    fieldsets = (
+        FieldSet(
+            'module', 'type',
+            InlineFields('diameter', 'diameter_unit', label=_('Diameter')),
+            'label', 'cooling_outflow', 'description',
+        ),
+        FieldSet(
+            InlineFields('max_flow', 'max_flow_unit', label=_('Max flow')),
+            name=_('Characteristics')
+        ),
+    )
+    nullable_fields = (
+        'module', 'label', 'type', 'diameter', 'diameter_unit', 'max_flow', 'max_flow_unit',
+        'cooling_outflow', 'description',
+    )
+
+
+class CoolingOutflowBulkEditForm(
+    ComponentBulkEditForm,
+    form_from_model(
+        CoolingOutflow,
+        [
+            'label', 'type', 'diameter', 'diameter_unit', 'cooling_intake',
+            'description'
+        ]
+    )
+):
+    model = CoolingOutflow
+    fieldsets = (
+        FieldSet(
+            'module', 'type',
+            InlineFields('diameter', 'diameter_unit', label=_('Diameter')),
+            'label', 'description', 'cooling_intake',
+        ),
+    )
+    nullable_fields = (
+        'module', 'label', 'type', 'diameter', 'diameter_unit', 'cooling_intake', 'description',
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit cooling_intake queryset to CoolingIntakes which belong to the parent Device
+        if self.device_id:
+            device = Device.objects.filter(pk=self.device_id).first()
+            self.fields['cooling_intake'].queryset = CoolingIntake.objects.filter(device=device)
+        else:
+            self.fields['cooling_intake'].choices = ()
+            self.fields['cooling_intake'].widget.attrs['disabled'] = True
+
+
 class InterfaceBulkEditForm(
     ComponentBulkEditForm,
     form_from_model(Interface, [

+ 295 - 9
netbox/dcim/forms/bulk_import.py

@@ -40,6 +40,10 @@ __all__ = (
     'CableImportForm',
     'ConsolePortImportForm',
     'ConsoleServerPortImportForm',
+    'CoolingFeedImportForm',
+    'CoolingIntakeImportForm',
+    'CoolingOutflowImportForm',
+    'CoolingSourceImportForm',
     'DeviceBayImportForm',
     'DeviceImportForm',
     'DeviceRoleImportForm',
@@ -241,13 +245,19 @@ class RackTypeImportForm(PrimaryModelImportForm):
         required=False,
         help_text=_('Unit for rack weights')
     )
+    cooling_capability = CSVChoiceField(
+        label=_('Cooling capability'),
+        choices=RackCoolingCapabilityChoices,
+        required=False,
+        help_text=_('Cooling capability')
+    )
 
     class Meta:
         model = RackType
         fields = (
             'manufacturer', 'model', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
             'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight',
-            'weight_unit', 'description', 'owner', 'comments', 'tags',
+            'weight_unit', 'cooling_capability', 'cooling_capacity', 'description', 'owner', 'comments', 'tags',
         )
 
     def __init__(self, data=None, *args, **kwargs):
@@ -327,6 +337,12 @@ class RackImportForm(PrimaryModelImportForm):
         required=False,
         help_text=_('Airflow direction')
     )
+    cooling_capability = CSVChoiceField(
+        label=_('Cooling capability'),
+        choices=RackCoolingCapabilityChoices,
+        required=False,
+        help_text=_('Cooling capability')
+    )
     weight_unit = CSVChoiceField(
         label=_('Weight unit'),
         choices=WeightUnitChoices,
@@ -339,8 +355,8 @@ class RackImportForm(PrimaryModelImportForm):
         fields = (
             'site', 'location', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor',
             'serial', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
-            'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'owner',
-            'comments', 'tags',
+            'outer_unit', 'mounting_depth', 'airflow', 'cooling_capability', 'cooling_capacity', 'weight',
+            'max_weight', 'weight_unit', 'description', 'owner', 'comments', 'tags',
         )
 
     def __init__(self, data=None, *args, **kwargs):
@@ -460,8 +476,8 @@ class DeviceTypeImportForm(PrimaryModelImportForm):
         model = DeviceType
         fields = [
             'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization',
-            'is_full_depth', 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'end_of_life',
-            'owner', 'comments', 'tags',
+            'is_full_depth', 'subdevice_role', 'airflow', 'cooling_method', 'description', 'weight', 'weight_unit',
+            'end_of_life', 'owner', 'comments', 'tags',
         ]
 
 
@@ -507,6 +523,12 @@ class ModuleTypeImportForm(PrimaryModelImportForm):
         required=False,
         help_text=_('Airflow direction')
     )
+    cooling_method = CSVChoiceField(
+        label=_('Cooling method'),
+        choices=CoolingMethodChoices,
+        required=False,
+        help_text=_('Cooling method')
+    )
     weight = forms.DecimalField(
         label=_('Weight'),
         required=False,
@@ -527,8 +549,8 @@ class ModuleTypeImportForm(PrimaryModelImportForm):
     class Meta:
         model = ModuleType
         fields = [
-            'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'end_of_life',
-            'profile', 'attribute_data', 'owner', 'comments', 'tags',
+            'manufacturer', 'model', 'part_number', 'description', 'cooling_method', 'airflow', 'weight', 'weight_unit',
+            'end_of_life', 'profile', 'attribute_data', 'owner', 'comments', 'tags',
         ]
 
     def clean(self):
@@ -716,6 +738,12 @@ class DeviceImportForm(BaseDeviceImportForm):
         required=False,
         help_text=_('Airflow direction')
     )
+    cooling_method = CSVChoiceField(
+        label=_('Cooling method'),
+        choices=CoolingMethodChoices,
+        required=False,
+        help_text=_('Cooling method')
+    )
     config_template = CSVModelChoiceField(
         label=_('Config template'),
         queryset=ConfigTemplate.objects.all(),
@@ -728,8 +756,8 @@ class DeviceImportForm(BaseDeviceImportForm):
         fields = [
             'name', 'role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
             'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent', 'device_bay', 'airflow',
-            'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'owner',
-            'comments', 'tags',
+            'cooling_method', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'description',
+            'config_template', 'owner', 'comments', 'tags',
         ]
 
     def __init__(self, data=None, *args, **kwargs):
@@ -966,6 +994,143 @@ class PowerOutletImportForm(OwnerCSVMixin, NetBoxModelImportForm):
             self.fields['power_port'].queryset = PowerPort.objects.none()
 
 
+class CoolingIntakeImportForm(OwnerCSVMixin, NetBoxModelImportForm):
+    device = CSVModelChoiceField(
+        label=_('Device'),
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+    type = CSVChoiceField(
+        label=_('Type'),
+        choices=CoolingConnectorTypeChoices,
+        required=False,
+        help_text=_('Physical connector type')
+    )
+    diameter_unit = CSVChoiceField(
+        label=_('Diameter unit'),
+        choices=DiameterUnitChoices,
+        required=False,
+        help_text=_('Diameter unit')
+    )
+    max_flow_unit = CSVChoiceField(
+        label=_('Max flow unit'),
+        choices=FlowRateUnitChoices,
+        required=False,
+        help_text=_('Unit for maximum flow')
+    )
+    cooling_outflow_device = CSVModelChoiceField(
+        label=_('Cooling outflow device'),
+        queryset=Device.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Device bearing the upstream cooling outflow (defaults to this intake\'s device)')
+    )
+    cooling_outflow = CSVModelChoiceField(
+        label=_('Cooling outflow'),
+        queryset=CoolingOutflow.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Upstream cooling outflow which feeds this intake')
+    )
+
+    class Meta:
+        model = CoolingIntake
+        fields = (
+            'device', 'name', 'label', 'type', 'diameter', 'diameter_unit', 'max_flow',
+            'max_flow_unit', 'cooling_outflow_device', 'cooling_outflow', 'description', 'owner', 'tags',
+        )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # The supplying outflow typically belongs to an upstream device (e.g. a CDU or manifold), so scope the
+        # CoolingOutflow choices to cooling_outflow_device where given, falling back to this intake's own device.
+        if self.is_bound and 'device' in self.data:
+            try:
+                device = self.fields['device'].to_python(self.data['device'])
+            except forms.ValidationError:
+                device = None
+        else:
+            try:
+                device = self.instance.device
+            except Device.DoesNotExist:
+                device = None
+
+        outflow_device = None
+        if self.is_bound and self.data.get('cooling_outflow_device'):
+            try:
+                outflow_device = self.fields['cooling_outflow_device'].to_python(
+                    self.data['cooling_outflow_device']
+                )
+            except forms.ValidationError:
+                outflow_device = None
+
+        if outflow_device:
+            self.fields['cooling_outflow'].queryset = CoolingOutflow.objects.filter(device=outflow_device)
+        elif device:
+            self.fields['cooling_outflow'].queryset = CoolingOutflow.objects.filter(
+                device__in=[device, device.get_vc_master()]
+            )
+        else:
+            self.fields['cooling_outflow'].queryset = CoolingOutflow.objects.none()
+
+
+class CoolingOutflowImportForm(OwnerCSVMixin, NetBoxModelImportForm):
+    device = CSVModelChoiceField(
+        label=_('Device'),
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+    type = CSVChoiceField(
+        label=_('Type'),
+        choices=CoolingConnectorTypeChoices,
+        required=False,
+        help_text=_('Physical connector type')
+    )
+    diameter_unit = CSVChoiceField(
+        label=_('Diameter unit'),
+        choices=DiameterUnitChoices,
+        required=False,
+        help_text=_('Diameter unit')
+    )
+    cooling_intake = CSVModelChoiceField(
+        label=_('Cooling intake'),
+        queryset=CoolingIntake.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Local cooling intake which feeds this outflow')
+    )
+
+    class Meta:
+        model = CoolingOutflow
+        fields = (
+            'device', 'name', 'label', 'type', 'diameter', 'diameter_unit',
+            'cooling_intake', 'description', 'owner', 'tags',
+        )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit CoolingIntake choices to those belonging to this device (or VC master)
+        if self.is_bound and 'device' in self.data:
+            try:
+                device = self.fields['device'].to_python(self.data['device'])
+            except forms.ValidationError:
+                device = None
+        else:
+            try:
+                device = self.instance.device
+            except Device.DoesNotExist:
+                device = None
+
+        if device:
+            self.fields['cooling_intake'].queryset = CoolingIntake.objects.filter(
+                device__in=[device, device.get_vc_master()]
+            )
+        else:
+            self.fields['cooling_intake'].queryset = CoolingIntake.objects.none()
+
+
 class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
     device = CSVModelChoiceField(
         label=_('Device'),
@@ -2027,6 +2192,127 @@ class PowerFeedImportForm(PrimaryModelImportForm):
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
+class CoolingSourceImportForm(PrimaryModelImportForm):
+    site = CSVModelChoiceField(
+        label=_('Site'),
+        queryset=Site.objects.all(),
+        to_field_name='name',
+        help_text=_('Name of parent site')
+    )
+    location = CSVModelChoiceField(
+        label=_('Location'),
+        queryset=Location.objects.all(),
+        required=False,
+        to_field_name='name'
+    )
+    type = CSVChoiceField(
+        label=_('Type'),
+        choices=CoolingSourceTypeChoices,
+        help_text=_('Cooling source type')
+    )
+    status = CSVChoiceField(
+        label=_('Status'),
+        choices=CoolingSourceStatusChoices,
+        help_text=_('Operational status')
+    )
+    fluid_type = CSVChoiceField(
+        label=_('Fluid type'),
+        choices=FluidTypeChoices,
+        required=False,
+        help_text=_('Coolant fluid type')
+    )
+
+    class Meta:
+        model = CoolingSource
+        fields = (
+            'site', 'location', 'name', 'type', 'status', 'fluid_type', 'cooling_capacity', 'description', 'owner',
+            'comments', 'tags',
+        )
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit location queryset by assigned site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
+
+
+class CoolingFeedImportForm(PrimaryModelImportForm):
+    site = CSVModelChoiceField(
+        label=_('Site'),
+        queryset=Site.objects.all(),
+        to_field_name='name',
+        help_text=_('Assigned site')
+    )
+    cooling_source = CSVModelChoiceField(
+        label=_('Cooling source'),
+        queryset=CoolingSource.objects.all(),
+        to_field_name='name',
+        help_text=_('Upstream cooling source')
+    )
+    location = CSVModelChoiceField(
+        label=_('Location'),
+        queryset=Location.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text=_("Rack's location (if any)")
+    )
+    rack = CSVModelChoiceField(
+        label=_('Rack'),
+        queryset=Rack.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text=_('Rack')
+    )
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text=_('Assigned tenant')
+    )
+    status = CSVChoiceField(
+        label=_('Status'),
+        choices=CoolingFeedStatusChoices,
+        help_text=_('Operational status')
+    )
+    max_flow_unit = CSVChoiceField(
+        label=_('Max flow unit'),
+        choices=FlowRateUnitChoices,
+        required=False,
+        help_text=_('Unit for maximum flow')
+    )
+
+    class Meta:
+        model = CoolingFeed
+        fields = (
+            'site', 'cooling_source', 'location', 'rack', 'name', 'status',
+            'cooling_capacity', 'max_flow', 'max_flow_unit', 'tenant', 'description', 'owner',
+            'comments', 'tags',
+        )
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit cooling_source queryset by site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['cooling_source'].queryset = self.fields['cooling_source'].queryset.filter(**params)
+
+            # Limit location queryset by site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
+
+            # Limit rack queryset by site and group
+            params = {
+                f"site__{self.fields['site'].to_field_name}": data.get('site'),
+                f"location__{self.fields['location'].to_field_name}": data.get('location'),
+            }
+            self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
+
+
 class VirtualDeviceContextImportForm(PrimaryModelImportForm):
     device = CSVModelChoiceField(
         label=_('Device'),

+ 310 - 4
netbox/dcim/forms/filtersets.py

@@ -19,7 +19,12 @@ from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from tenancy.models import Tenant
 from users.models import User
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
-from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, PositiveBigIntegerField, TagFilterField
+from utilities.forms.fields import (
+    ColorField,
+    DynamicModelMultipleChoiceField,
+    PositiveBigIntegerField,
+    TagFilterField,
+)
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import DatePicker, NumberWithOptions
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -34,6 +39,12 @@ __all__ = (
     'ConsolePortTemplateFilterForm',
     'ConsoleServerPortFilterForm',
     'ConsoleServerPortTemplateFilterForm',
+    'CoolingFeedFilterForm',
+    'CoolingIntakeFilterForm',
+    'CoolingIntakeTemplateFilterForm',
+    'CoolingOutflowFilterForm',
+    'CoolingOutflowTemplateFilterForm',
+    'CoolingSourceFilterForm',
     'DeviceBayFilterForm',
     'DeviceBayTemplateFilterForm',
     'DeviceFilterForm',
@@ -338,6 +349,16 @@ class RackBaseFilterForm(PrimaryModelFilterSetForm):
         choices=add_blank_choice(WeightUnitChoices),
         required=False
     )
+    cooling_capability = forms.MultipleChoiceField(
+        label=_('Cooling capability'),
+        choices=RackCoolingCapabilityChoices,
+        required=False
+    )
+    cooling_capacity = forms.DecimalField(
+        label=_('Cooling capacity'),
+        required=False,
+        min_value=0
+    )
 
 
 class RackTypeFilterForm(RackBaseFilterForm):
@@ -347,6 +368,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
         FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')),
         FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
+        FieldSet('cooling_capability', 'cooling_capacity', name=_('Cooling')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
@@ -370,6 +392,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', name=_('Location')),
         FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
         FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
+        FieldSet('cooling_capability', 'cooling_capacity', name=_('Cooling')),
         FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
@@ -562,8 +585,9 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet(
             'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
-            'subdevice_role', 'airflow', name=_('Hardware')
+            'subdevice_role', name=_('Hardware')
         ),
+        FieldSet('cooling_method', 'airflow', name=_('Cooling')),
         FieldSet('has_front_image', 'has_rear_image', name=_('Images')),
         FieldSet(
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
@@ -603,6 +627,11 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
         choices=add_blank_choice(DeviceAirflowChoices),
         required=False
     )
+    cooling_method = forms.MultipleChoiceField(
+        label=_('Cooling method'),
+        choices=add_blank_choice(CoolingMethodChoices),
+        required=False
+    )
     has_front_image = forms.NullBooleanField(
         required=False,
         label=_('Has a front image'),
@@ -738,8 +767,9 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet(
             'profile_id', 'manufacturer_id', 'part_number', 'module_count',
-            'airflow', 'module_bay_type_id', name=_('Hardware')
+            'module_bay_type_id', name=_('Hardware')
         ),
+        FieldSet('cooling_method', 'airflow', name=_('Cooling')),
         FieldSet(
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
             'pass_through_ports', 'module_bays', name=_('Components')
@@ -830,6 +860,11 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
         choices=add_blank_choice(ModuleAirflowChoices),
         required=False
     )
+    cooling_method = forms.MultipleChoiceField(
+        label=_('Cooling method'),
+        choices=add_blank_choice(CoolingMethodChoices),
+        required=False
+    )
     weight = forms.DecimalField(
         label=_('Weight'),
         required=False
@@ -907,8 +942,9 @@ class DeviceFilterForm(
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
-        FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_('Operation')),
+        FieldSet('status', 'role_id', 'serial', 'asset_tag', 'mac_address', name=_('Operation')),
         FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')),
+        FieldSet('cooling_method', 'airflow', name=_('Cooling')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
@@ -996,6 +1032,11 @@ class DeviceFilterForm(
         choices=add_blank_choice(DeviceAirflowChoices),
         required=False
     )
+    cooling_method = forms.MultipleChoiceField(
+        label=_('Cooling method'),
+        choices=add_blank_choice(CoolingMethodChoices),
+        required=False
+    )
     serial = forms.CharField(
         label=_('Serial'),
         required=False
@@ -1476,6 +1517,136 @@ class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     tag = TagFilterField(model)
 
 
+class CoolingSourceFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
+    model = CoolingSource
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
+        FieldSet('type', 'status', 'fluid_type', name=_('Attributes')),
+        FieldSet('cooling_capacity', name=_('Characteristics')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
+        FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
+    )
+    selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
+    region_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Region')
+    )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group')
+    )
+    site_id = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
+        },
+        label=_('Site')
+    )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site_id'
+        },
+        label=_('Location')
+    )
+    type = forms.MultipleChoiceField(
+        label=_('Type'),
+        choices=CoolingSourceTypeChoices,
+        required=False
+    )
+    status = forms.MultipleChoiceField(
+        label=_('Status'),
+        choices=CoolingSourceStatusChoices,
+        required=False
+    )
+    fluid_type = forms.MultipleChoiceField(
+        label=_('Fluid type'),
+        choices=FluidTypeChoices,
+        required=False
+    )
+    cooling_capacity = forms.DecimalField(
+        label=_('Cooling capacity'),
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+class CoolingFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
+    model = CoolingFeed
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('region_id', 'site_group_id', 'site_id', 'cooling_source_id', 'rack_id', name=_('Location')),
+        FieldSet('status', name=_('Attributes')),
+        FieldSet(
+            'cooling_capacity', 'max_flow', 'max_flow_unit', name=_('Characteristics')
+        ),
+        FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
+    )
+    region_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Region')
+    )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group')
+    )
+    site_id = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region_id'
+        },
+        label=_('Site')
+    )
+    cooling_source_id = DynamicModelMultipleChoiceField(
+        queryset=CoolingSource.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site_id'
+        },
+        label=_('Cooling source')
+    )
+    rack_id = DynamicModelMultipleChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site_id'
+        },
+        label=_('Rack')
+    )
+    status = forms.MultipleChoiceField(
+        label=_('Status'),
+        choices=CoolingFeedStatusChoices,
+        required=False
+    )
+    cooling_capacity = forms.DecimalField(
+        label=_('Cooling capacity'),
+        required=False
+    )
+    max_flow = forms.DecimalField(
+        label=_('Max flow'),
+        required=False
+    )
+    max_flow_unit = forms.MultipleChoiceField(
+        label=_('Max flow unit'),
+        choices=FlowRateUnitChoices,
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
 #
 # Device components
 #
@@ -1683,6 +1854,141 @@ class PowerOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
     )
 
 
+class CoolingIntakeFilterForm(DeviceComponentFilterForm):
+    model = CoolingIntake
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet(
+            'name', 'label', 'type', 'diameter', 'diameter_unit', 'max_flow', 'max_flow_unit',
+            'cooling_outflow_id', name=_('Attributes')
+        ),
+        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
+        FieldSet(
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            name=_('Device')
+        ),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
+    )
+    type = forms.MultipleChoiceField(
+        label=_('Type'),
+        choices=CoolingConnectorTypeChoices,
+        required=False
+    )
+    diameter = forms.DecimalField(
+        label=_('Diameter'),
+        required=False
+    )
+    diameter_unit = forms.MultipleChoiceField(
+        label=_('Diameter unit'),
+        choices=DiameterUnitChoices,
+        required=False
+    )
+    max_flow = forms.DecimalField(
+        label=_('Max flow'),
+        required=False
+    )
+    max_flow_unit = forms.MultipleChoiceField(
+        label=_('Max flow unit'),
+        choices=FlowRateUnitChoices,
+        required=False
+    )
+    cooling_outflow_id = DynamicModelMultipleChoiceField(
+        label=_('Cooling outflow'),
+        queryset=CoolingOutflow.objects.all(),
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+class CoolingIntakeTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
+    model = CoolingIntakeTemplate
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet(
+            'name', 'label', 'type', 'diameter', 'diameter_unit', 'max_flow', 'max_flow_unit',
+            name=_('Attributes')
+        ),
+        FieldSet('device_type_id', 'module_type_id', name=_('Device')),
+    )
+    type = forms.MultipleChoiceField(
+        label=_('Type'),
+        choices=CoolingConnectorTypeChoices,
+        required=False
+    )
+    diameter = forms.DecimalField(
+        label=_('Diameter'),
+        required=False
+    )
+    diameter_unit = forms.MultipleChoiceField(
+        label=_('Diameter unit'),
+        choices=DiameterUnitChoices,
+        required=False
+    )
+    max_flow = forms.DecimalField(
+        label=_('Max flow'),
+        required=False
+    )
+    max_flow_unit = forms.MultipleChoiceField(
+        label=_('Max flow unit'),
+        choices=FlowRateUnitChoices,
+        required=False
+    )
+
+
+class CoolingOutflowFilterForm(DeviceComponentFilterForm):
+    model = CoolingOutflow
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet(
+            'name', 'label', 'type', 'diameter', 'diameter_unit', name=_('Attributes')
+        ),
+        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
+        FieldSet(
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            name=_('Device')
+        ),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
+    )
+    type = forms.MultipleChoiceField(
+        label=_('Type'),
+        choices=CoolingConnectorTypeChoices,
+        required=False
+    )
+    diameter = forms.DecimalField(
+        label=_('Diameter'),
+        required=False
+    )
+    diameter_unit = forms.MultipleChoiceField(
+        label=_('Diameter unit'),
+        choices=DiameterUnitChoices,
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+class CoolingOutflowTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
+    model = CoolingOutflowTemplate
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('name', 'label', 'type', 'diameter', 'diameter_unit', name=_('Attributes')),
+        FieldSet('device_type_id', 'module_type_id', name=_('Device')),
+    )
+    type = forms.MultipleChoiceField(
+        label=_('Type'),
+        choices=CoolingConnectorTypeChoices,
+        required=False
+    )
+    diameter = forms.DecimalField(
+        label=_('Diameter'),
+        required=False
+    )
+    diameter_unit = forms.MultipleChoiceField(
+        label=_('Diameter unit'),
+        choices=DiameterUnitChoices,
+        required=False
+    )
+
+
 class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = Interface
     fieldsets = (

+ 192 - 9
netbox/dcim/forms/model_forms.py

@@ -52,6 +52,12 @@ __all__ = (
     'ConsolePortTemplateForm',
     'ConsoleServerPortForm',
     'ConsoleServerPortTemplateForm',
+    'CoolingFeedForm',
+    'CoolingIntakeForm',
+    'CoolingIntakeTemplateForm',
+    'CoolingOutflowForm',
+    'CoolingOutflowTemplateForm',
+    'CoolingSourceForm',
     'DeviceBayForm',
     'DeviceBayTemplateForm',
     'DeviceForm',
@@ -310,6 +316,7 @@ class RackTypeForm(PrimaryModelForm):
             'mounting_depth', name=_('Dimensions')
         ),
         FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
+        FieldSet('cooling_capability', 'cooling_capacity', name=_('Cooling')),
     )
 
     class Meta:
@@ -317,7 +324,7 @@ class RackTypeForm(PrimaryModelForm):
         fields = [
             'manufacturer', 'model', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
             'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight',
-            'weight_unit', 'description', 'owner', 'comments', 'tags',
+            'weight_unit', 'cooling_capability', 'cooling_capacity', 'description', 'owner', 'comments', 'tags',
         ]
 
 
@@ -383,6 +390,7 @@ class RackForm(TenancyForm, PrimaryModelForm):
             'site', 'location', 'group', 'name', 'status', 'role', 'rack_type', 'description', 'airflow', 'tags',
             name=_('Rack')
         ),
+        FieldSet('cooling_capability', 'cooling_capacity', name=_('Cooling')),
         FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
@@ -392,8 +400,8 @@ class RackForm(TenancyForm, PrimaryModelForm):
         fields = [
             'site', 'location', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
             'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
-            'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight',
-            'weight_unit', 'description', 'owner', 'comments', 'tags',
+            'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'cooling_capability',
+            'cooling_capacity', 'weight', 'max_weight', 'weight_unit', 'description', 'owner', 'comments', 'tags',
         ]
 
     def __init__(self, *args, **kwargs):
@@ -532,9 +540,10 @@ class DeviceTypeForm(PrimaryModelForm):
     fieldsets = (
         FieldSet('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags', name=_('Device Type')),
         FieldSet(
-            'u_height', 'exclude_from_utilization', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow',
+            'u_height', 'exclude_from_utilization', 'is_full_depth', 'part_number', 'subdevice_role',
             'weight', 'weight_unit', name=_('Chassis')
         ),
+        FieldSet('cooling_method', 'airflow', name=_('Cooling')),
         FieldSet('end_of_life', name=_('Lifecycle')),
         FieldSet('front_image', 'rear_image', name=_('Images')),
     )
@@ -543,7 +552,8 @@ class DeviceTypeForm(PrimaryModelForm):
         model = DeviceType
         fields = [
             'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization',
-            'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'end_of_life', 'front_image',
+            'is_full_depth', 'subdevice_role', 'airflow', 'cooling_method', 'weight', 'weight_unit', 'end_of_life',
+            'front_image',
             'rear_image', 'description', 'owner', 'comments', 'tags',
         ]
         widgets = {
@@ -627,7 +637,8 @@ class ModuleTypeForm(PrimaryModelForm):
     def fieldsets(self):
         return [
             FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')),
-            FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')),
+            FieldSet('weight', 'weight_unit', name=_('Hardware')),
+            FieldSet('cooling_method', 'airflow', name=_('Cooling')),
             FieldSet('module_bay_types', name=_('Bay Type Compatibility')),
             FieldSet('end_of_life', name=_('Lifecycle')),
             FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes'), html_id='profile-attributes')
@@ -636,8 +647,8 @@ class ModuleTypeForm(PrimaryModelForm):
     class Meta:
         model = ModuleType
         fields = [
-            'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit',
-            'module_bay_types', 'end_of_life', 'owner', 'comments', 'tags',
+            'profile', 'manufacturer', 'model', 'part_number', 'description', 'cooling_method', 'airflow', 'weight',
+            'weight_unit', 'module_bay_types', 'end_of_life', 'owner', 'comments', 'tags',
         ]
         widgets = {
             'end_of_life': DatePicker(),
@@ -878,7 +889,8 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
         model = Device
         fields = [
             'name', 'role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
-            'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster',
+            'latitude', 'longitude', 'status', 'airflow', 'cooling_method', 'platform', 'primary_ip4', 'primary_ip6',
+            'oob_ip', 'cluster',
             'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
             'owner', 'comments', 'tags', 'local_context_data',
         ]
@@ -1153,6 +1165,74 @@ class PowerFeedForm(TenancyForm, PrimaryModelForm):
         ]
 
 
+#
+# Cooling
+#
+
+class CoolingSourceForm(PrimaryModelForm):
+    site = DynamicModelChoiceField(
+        label=_('Site'),
+        queryset=Site.objects.all(),
+        selector=True
+    )
+    location = DynamicModelChoiceField(
+        label=_('Location'),
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        }
+    )
+
+    fieldsets = (
+        FieldSet('site', 'location', 'name', 'type', 'status', 'description', 'tags', name=_('Cooling Source')),
+        FieldSet('fluid_type', 'cooling_capacity', name=_('Characteristics')),
+    )
+
+    class Meta:
+        model = CoolingSource
+        fields = [
+            'site', 'location', 'name', 'type', 'status', 'fluid_type', 'cooling_capacity', 'description', 'owner',
+            'comments', 'tags',
+        ]
+
+
+class CoolingFeedForm(TenancyForm, PrimaryModelForm):
+    cooling_source = DynamicModelChoiceField(
+        label=_('Cooling source'),
+        queryset=CoolingSource.objects.all(),
+        selector=True,
+        quick_add=True
+    )
+    rack = DynamicModelChoiceField(
+        label=_('Rack'),
+        queryset=Rack.objects.all(),
+        required=False,
+        selector=True
+    )
+
+    fieldsets = (
+        FieldSet(
+            'cooling_source', 'rack', 'name', 'status', 'description', 'tags',
+            name=_('Cooling Feed')
+        ),
+        FieldSet(
+            'cooling_capacity',
+            InlineFields('max_flow', 'max_flow_unit', label=_('Max flow')),
+            name=_('Characteristics')
+        ),
+        FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
+    )
+
+    class Meta:
+        model = CoolingFeed
+        fields = [
+            'cooling_source', 'rack', 'name', 'status', 'cooling_capacity',
+            'max_flow', 'max_flow_unit', 'tenant_group', 'tenant', 'description', 'owner', 'comments',
+            'tags',
+        ]
+
+
 #
 # Virtual chassis
 #
@@ -1394,6 +1474,58 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
         ]
 
 
+class CoolingIntakeTemplateForm(ModularComponentTemplateForm):
+    fieldsets = (
+        FieldSet(
+            TabbedGroups(
+                FieldSet('device_type', name=_('Device Type')),
+                FieldSet('module_type', name=_('Module Type')),
+            ),
+            'name', 'label', 'type',
+            InlineFields('diameter', 'diameter_unit', label=_('Diameter')),
+            InlineFields('max_flow', 'max_flow_unit', label=_('Max flow')),
+            'description',
+        ),
+    )
+
+    class Meta:
+        model = CoolingIntakeTemplate
+        fields = [
+            'device_type', 'module_type', 'name', 'label', 'type', 'diameter', 'diameter_unit',
+            'max_flow', 'max_flow_unit', 'description',
+        ]
+
+
+class CoolingOutflowTemplateForm(ModularComponentTemplateForm):
+    cooling_intake = DynamicModelChoiceField(
+        label=_('Cooling intake'),
+        queryset=CoolingIntakeTemplate.objects.all(),
+        required=False,
+        query_params={
+            'device_type_id': '$device_type',
+        }
+    )
+
+    fieldsets = (
+        FieldSet(
+            TabbedGroups(
+                FieldSet('device_type', name=_('Device Type')),
+                FieldSet('module_type', name=_('Module Type')),
+            ),
+            'name', 'label', 'type',
+            InlineFields('diameter', 'diameter_unit', label=_('Diameter')),
+            'cooling_intake', 'description',
+        ),
+    )
+
+    class Meta:
+        model = CoolingOutflowTemplate
+        fields = [
+            'device_type', 'module_type', 'name', 'label', 'type', 'diameter', 'diameter_unit',
+            'cooling_intake', 'description',
+        ]
+
+
 class InterfaceTemplateForm(ModularComponentTemplateForm):
     type = ChoiceField(
         label=_('Type'),
@@ -1835,6 +1967,57 @@ class PowerOutletForm(ModularDeviceComponentForm):
         ]
 
 
+class CoolingIntakeForm(ModularDeviceComponentForm):
+    cooling_outflow = DynamicModelChoiceField(
+        label=_('Cooling outflow'),
+        queryset=CoolingOutflow.objects.all(),
+        required=False,
+        selector=True
+    )
+
+    fieldsets = (
+        FieldSet(
+            'device', 'module', 'name', 'label', 'type',
+            InlineFields('diameter', 'diameter_unit', label=_('Diameter')),
+            InlineFields('max_flow', 'max_flow_unit', label=_('Max flow')),
+            'cooling_outflow', 'description', 'tags',
+        ),
+    )
+
+    class Meta:
+        model = CoolingIntake
+        fields = [
+            'device', 'module', 'name', 'label', 'type', 'diameter', 'diameter_unit', 'max_flow',
+            'max_flow_unit', 'cooling_outflow', 'description', 'owner', 'tags',
+        ]
+
+
+class CoolingOutflowForm(ModularDeviceComponentForm):
+    cooling_intake = DynamicModelChoiceField(
+        label=_('Cooling intake'),
+        queryset=CoolingIntake.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device',
+        }
+    )
+
+    fieldsets = (
+        FieldSet(
+            'device', 'module', 'name', 'label', 'type',
+            InlineFields('diameter', 'diameter_unit', label=_('Diameter')),
+            'cooling_intake', 'description', 'tags',
+        ),
+    )
+
+    class Meta:
+        model = CoolingOutflow
+        fields = [
+            'device', 'module', 'name', 'label', 'type', 'diameter', 'diameter_unit', 'cooling_intake', 'description',
+            'owner', 'tags',
+        ]
+
+
 class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
     type = ChoiceField(
         label=_('Type'),

+ 28 - 0
netbox/dcim/forms/object_create.py

@@ -21,6 +21,10 @@ __all__ = (
     'ConsolePortTemplateCreateForm',
     'ConsoleServerPortCreateForm',
     'ConsoleServerPortTemplateCreateForm',
+    'CoolingIntakeCreateForm',
+    'CoolingIntakeTemplateCreateForm',
+    'CoolingOutflowCreateForm',
+    'CoolingOutflowTemplateCreateForm',
     'DeviceBayCreateForm',
     'DeviceBayTemplateCreateForm',
     'FrontPortCreateForm',
@@ -109,6 +113,18 @@ class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutlet
         exclude = ('name', 'label')
 
 
+class CoolingIntakeTemplateCreateForm(ComponentCreateForm, model_forms.CoolingIntakeTemplateForm):
+
+    class Meta(model_forms.CoolingIntakeTemplateForm.Meta):
+        exclude = ('name', 'label')
+
+
+class CoolingOutflowTemplateCreateForm(ComponentCreateForm, model_forms.CoolingOutflowTemplateForm):
+
+    class Meta(model_forms.CoolingOutflowTemplateForm.Meta):
+        exclude = ('name', 'label')
+
+
 class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm):
     channel_id = ExpandableNumericField(
         label=_('Channel ID'),
@@ -206,6 +222,18 @@ class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm):
         exclude = ('name', 'label')
 
 
+class CoolingIntakeCreateForm(ComponentCreateForm, model_forms.CoolingIntakeForm):
+
+    class Meta(model_forms.CoolingIntakeForm.Meta):
+        exclude = ('name', 'label')
+
+
+class CoolingOutflowCreateForm(ComponentCreateForm, model_forms.CoolingOutflowForm):
+
+    class Meta(model_forms.CoolingOutflowForm.Meta):
+        exclude = ('name', 'label')
+
+
 class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
     channel_id = ExpandableNumericField(
         label=_('Channel ID'),

+ 42 - 0
netbox/dcim/forms/object_import.py

@@ -8,6 +8,8 @@ from wireless.choices import WirelessRoleChoices
 __all__ = (
     'ConsolePortTemplateImportForm',
     'ConsoleServerPortTemplateImportForm',
+    'CoolingIntakeTemplateImportForm',
+    'CoolingOutflowTemplateImportForm',
     'DeviceBayTemplateImportForm',
     'FrontPortTemplateImportForm',
     'InterfaceTemplateImportForm',
@@ -80,6 +82,46 @@ class PowerOutletTemplateImportForm(forms.ModelForm):
         return module_type
 
 
+class CoolingIntakeTemplateImportForm(forms.ModelForm):
+
+    class Meta:
+        model = CoolingIntakeTemplate
+        fields = [
+            'device_type', 'module_type', 'name', 'label', 'type', 'diameter', 'diameter_unit',
+            'max_flow', 'max_flow_unit', 'description',
+        ]
+
+
+class CoolingOutflowTemplateImportForm(forms.ModelForm):
+    cooling_intake = forms.ModelChoiceField(
+        label=_('Cooling intake'),
+        queryset=CoolingIntakeTemplate.objects.all(),
+        to_field_name='name',
+        required=False
+    )
+
+    class Meta:
+        model = CoolingOutflowTemplate
+        fields = [
+            'device_type', 'module_type', 'name', 'label', 'type', 'diameter', 'diameter_unit',
+            'cooling_intake', 'description',
+        ]
+
+    def clean_device_type(self):
+        if device_type := self.cleaned_data['device_type']:
+            cooling_intake = self.fields['cooling_intake']
+            cooling_intake.queryset = cooling_intake.queryset.filter(device_type=device_type)
+
+        return device_type
+
+    def clean_module_type(self):
+        if module_type := self.cleaned_data['module_type']:
+            cooling_intake = self.fields['cooling_intake']
+            cooling_intake.queryset = cooling_intake.queryset.filter(module_type=module_type)
+
+        return module_type
+
+
 class InterfaceTemplateImportForm(forms.ModelForm):
     type = forms.ChoiceField(
         label=_('Type'),

+ 19 - 0
netbox/dcim/graphql/enums.py

@@ -1,6 +1,7 @@
 import strawberry
 
 from dcim.choices import *
+from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices
 
 __all__ = (
     'CableEndEnum',
@@ -8,9 +9,17 @@ __all__ = (
     'CableTypeEnum',
     'ConsolePortSpeedEnum',
     'ConsolePortTypeEnum',
+    'CoolingConnectorTypeEnum',
+    'CoolingFeedStatusEnum',
+    'CoolingMethodEnum',
+    'CoolingSourceStatusEnum',
+    'CoolingSourceTypeEnum',
     'DeviceAirflowEnum',
     'DeviceFaceEnum',
     'DeviceStatusEnum',
+    'DiameterUnitEnum',
+    'FlowRateUnitEnum',
+    'FluidTypeEnum',
     'InterfaceDuplexEnum',
     'InterfaceKindEnum',
     'InterfaceModeEnum',
@@ -32,6 +41,7 @@ __all__ = (
     'PowerOutletTypeEnum',
     'PowerPortTypeEnum',
     'RackAirflowEnum',
+    'RackCoolingCapabilityEnum',
     'RackDimensionUnitEnum',
     'RackFormFactorEnum',
     'RackReservationStatusEnum',
@@ -47,9 +57,17 @@ CableLengthUnitEnum = strawberry.enum(CableLengthUnitChoices.as_enum(prefix='uni
 CableTypeEnum = strawberry.enum(CableTypeChoices.as_enum(prefix='type'))
 ConsolePortSpeedEnum = strawberry.enum(ConsolePortSpeedChoices.as_enum(prefix='speed'))
 ConsolePortTypeEnum = strawberry.enum(ConsolePortTypeChoices.as_enum(prefix='type'))
+CoolingConnectorTypeEnum = strawberry.enum(CoolingConnectorTypeChoices.as_enum(prefix='type'))
+CoolingFeedStatusEnum = strawberry.enum(CoolingFeedStatusChoices.as_enum(prefix='status'))
+CoolingMethodEnum = strawberry.enum(CoolingMethodChoices.as_enum(prefix='method'))
+CoolingSourceStatusEnum = strawberry.enum(CoolingSourceStatusChoices.as_enum(prefix='status'))
+CoolingSourceTypeEnum = strawberry.enum(CoolingSourceTypeChoices.as_enum(prefix='type'))
 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'))
 InterfaceModeEnum = strawberry.enum(InterfaceModeChoices.as_enum(prefix='mode'))
@@ -71,6 +89,7 @@ PowerOutletStatusEnum = strawberry.enum(PowerOutletStatusChoices.as_enum(prefix=
 PowerOutletTypeEnum = strawberry.enum(PowerOutletTypeChoices.as_enum(prefix='type'))
 PowerPortTypeEnum = strawberry.enum(PowerPortTypeChoices.as_enum(prefix='type'))
 RackAirflowEnum = strawberry.enum(RackAirflowChoices.as_enum())
+RackCoolingCapabilityEnum = strawberry.enum(RackCoolingCapabilityChoices.as_enum(prefix='capability'))
 RackDimensionUnitEnum = strawberry.enum(RackDimensionUnitChoices.as_enum(prefix='unit'))
 RackFormFactorEnum = strawberry.enum(RackFormFactorChoices.as_enum(prefix='type'))
 RackReservationStatusEnum = strawberry.enum(RackReservationStatusChoices.as_enum(prefix='status'))

+ 7 - 1
netbox/dcim/graphql/filter_mixins.py

@@ -14,7 +14,7 @@ if TYPE_CHECKING:
     from dcim.graphql.filters import LocationFilter, RegionFilter, SiteFilter, SiteGroupFilter
     from extras.graphql.filters import ConfigTemplateFilter
     from ipam.graphql.filters import VLANFilter, VLANTranslationPolicyFilter
-    from netbox.graphql.filter_lookups import IntegerLookup
+    from netbox.graphql.filter_lookups import FloatLookup, IntegerLookup
 
     from .filters import *
 
@@ -177,3 +177,9 @@ class RackFilterMixin:
     max_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
+    cooling_capability: (
+        BaseFilterLookup[Annotated['RackCoolingCapabilityEnum', strawberry.lazy('dcim.graphql.enums')]] | None
+    ) = strawberry_django.filter_field()
+    cooling_capacity: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )

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

@@ -77,6 +77,12 @@ __all__ = (
     'ConsolePortTemplateFilter',
     'ConsoleServerPortFilter',
     'ConsoleServerPortTemplateFilter',
+    'CoolingFeedFilter',
+    'CoolingIntakeFilter',
+    'CoolingIntakeTemplateFilter',
+    'CoolingOutflowFilter',
+    'CoolingOutflowTemplateFilter',
+    'CoolingSourceFilter',
     'DeviceBayFilter',
     'DeviceBayTemplateFilter',
     'DeviceFilter',
@@ -256,6 +262,9 @@ class DeviceFilter(
     airflow: BaseFilterLookup[Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
+    cooling_method: BaseFilterLookup[Annotated['CoolingMethodEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
     primary_ip4: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -399,6 +408,9 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
     airflow: BaseFilterLookup[Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
+    cooling_method: BaseFilterLookup[Annotated['CoolingMethodEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
     end_of_life: DateFilterLookup | None = strawberry_django.filter_field()
     front_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -828,6 +840,9 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
     airflow: BaseFilterLookup[Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
+    cooling_method: BaseFilterLookup[Annotated['CoolingMethodEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
     end_of_life: DateFilterLookup | None = strawberry_django.filter_field()
     consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field(name='console_port_templates')
@@ -979,6 +994,126 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
     )
 
 
+@register_filter(models.CoolingFeed, lookups=True)
+class CoolingFeedFilter(TenancyFilterMixin, PrimaryModelFilter):
+    cooling_source: Annotated['CoolingSourceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    cooling_source_id: ID | None = strawberry_django.filter_field()
+    rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+    rack_id: ID | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    status: BaseFilterLookup[Annotated['CoolingFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    cooling_capacity: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    max_flow: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    max_flow_unit: (
+        BaseFilterLookup[Annotated['FlowRateUnitEnum', strawberry.lazy('dcim.graphql.enums')]] | None
+    ) = strawberry_django.filter_field()
+
+
+@register_filter(models.CoolingOutflow, lookups=True)
+class CoolingOutflowFilter(ModularComponentFilterMixin, NetBoxModelFilter):
+    type: BaseFilterLookup[
+        Annotated['CoolingConnectorTypeEnum', strawberry.lazy('dcim.graphql.enums')]
+    ] | None = strawberry_django.filter_field()
+    diameter: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    diameter_unit: BaseFilterLookup[Annotated['DiameterUnitEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    cooling_intake: Annotated['CoolingIntakeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    cooling_intake_id: ID | None = strawberry_django.filter_field()
+
+
+@register_filter(models.CoolingOutflowTemplate, lookups=True)
+class CoolingOutflowTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
+    type: BaseFilterLookup[
+        Annotated['CoolingConnectorTypeEnum', strawberry.lazy('dcim.graphql.enums')]
+    ] | None = strawberry_django.filter_field()
+    diameter: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    diameter_unit: BaseFilterLookup[Annotated['DiameterUnitEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    cooling_intake: Annotated['CoolingIntakeTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    cooling_intake_id: ID | None = strawberry_django.filter_field()
+
+
+@register_filter(models.CoolingIntake, lookups=True)
+class CoolingIntakeFilter(ModularComponentFilterMixin, NetBoxModelFilter):
+    type: BaseFilterLookup[
+        Annotated['CoolingConnectorTypeEnum', strawberry.lazy('dcim.graphql.enums')]
+    ] | None = strawberry_django.filter_field()
+    diameter: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    diameter_unit: BaseFilterLookup[Annotated['DiameterUnitEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    max_flow: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    max_flow_unit: BaseFilterLookup[
+        Annotated['FlowRateUnitEnum', strawberry.lazy('dcim.graphql.enums')]
+    ] | None = strawberry_django.filter_field()
+
+
+@register_filter(models.CoolingIntakeTemplate, lookups=True)
+class CoolingIntakeTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
+    type: BaseFilterLookup[
+        Annotated['CoolingConnectorTypeEnum', strawberry.lazy('dcim.graphql.enums')]
+    ] | None = strawberry_django.filter_field()
+    diameter: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    diameter_unit: BaseFilterLookup[Annotated['DiameterUnitEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    max_flow: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    max_flow_unit: BaseFilterLookup[
+        Annotated['FlowRateUnitEnum', strawberry.lazy('dcim.graphql.enums')]
+    ] | None = strawberry_django.filter_field()
+
+
+@register_filter(models.CoolingSource, lookups=True)
+class CoolingSourceFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilter):
+    site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+    site_id: ID | None = strawberry_django.filter_field()
+    location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    type: BaseFilterLookup[Annotated['CoolingSourceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    status: BaseFilterLookup[Annotated['CoolingSourceStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    fluid_type: BaseFilterLookup[Annotated['FluidTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    cooling_capacity: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+
+
 @register_filter(models.RackType, lookups=True)
 class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
     form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (

+ 18 - 0
netbox/dcim/graphql/schema.py

@@ -24,6 +24,24 @@ class DCIMQuery:
     console_server_port_template: ConsoleServerPortTemplateType = strawberry_django.field()
     console_server_port_template_list: list[ConsoleServerPortTemplateType] = strawberry_django.field()
 
+    cooling_feed: CoolingFeedType = strawberry_django.field()
+    cooling_feed_list: list[CoolingFeedType] = strawberry_django.field()
+
+    cooling_intake: CoolingIntakeType = strawberry_django.field()
+    cooling_intake_list: list[CoolingIntakeType] = strawberry_django.field()
+
+    cooling_intake_template: CoolingIntakeTemplateType = strawberry_django.field()
+    cooling_intake_template_list: list[CoolingIntakeTemplateType] = strawberry_django.field()
+
+    cooling_outflow: CoolingOutflowType = strawberry_django.field()
+    cooling_outflow_list: list[CoolingOutflowType] = strawberry_django.field()
+
+    cooling_outflow_template: CoolingOutflowTemplateType = strawberry_django.field()
+    cooling_outflow_template_list: list[CoolingOutflowTemplateType] = strawberry_django.field()
+
+    cooling_source: CoolingSourceType = strawberry_django.field()
+    cooling_source_list: list[CoolingSourceType] = strawberry_django.field()
+
     device: DeviceType = strawberry_django.field()
     device_list: list[DeviceType] = strawberry_django.field()
 

+ 87 - 0
netbox/dcim/graphql/types.py

@@ -52,6 +52,12 @@ __all__ = (
     'ConsolePortType',
     'ConsoleServerPortTemplateType',
     'ConsoleServerPortType',
+    'CoolingFeedType',
+    'CoolingIntakeTemplateType',
+    'CoolingIntakeType',
+    'CoolingOutflowTemplateType',
+    'CoolingOutflowType',
+    'CoolingSourceType',
     'DeviceBayTemplateType',
     'DeviceBayType',
     'DeviceRoleType',
@@ -258,6 +264,8 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, Prima
     console_server_port_count: BigInt
     power_port_count: BigInt
     power_outlet_count: BigInt
+    cooling_intake_count: BigInt
+    cooling_outflow_count: BigInt
     interface_count: BigInt
     front_port_count: BigInt
     rear_port_count: BigInt
@@ -284,9 +292,11 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, Prima
     rearports: list[Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]]
     consoleports: list[Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')]]
     powerports: list[Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')]]
+    coolingintakes: list[Annotated["CoolingIntakeType", strawberry.lazy('dcim.graphql.types')]]
     cabletermination_set: list[Annotated["CableTerminationType", strawberry.lazy('dcim.graphql.types')]]
     consoleserverports: list[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]]
     poweroutlets: list[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]]
+    coolingoutflows: list[Annotated["CoolingOutflowType", strawberry.lazy('dcim.graphql.types')]]
     frontports: list[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]]
     devicebays: list[Annotated["DeviceBayType", strawberry.lazy('dcim.graphql.types')]]
     modulebays: list[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]]
@@ -378,6 +388,8 @@ class DeviceTypeType(PrimaryObjectType):
     console_server_port_template_count: BigInt
     power_port_template_count: BigInt
     power_outlet_template_count: BigInt
+    cooling_intake_template_count: BigInt
+    cooling_outflow_template_count: BigInt
     interface_template_count: BigInt
     front_port_template_count: BigInt
     rear_port_template_count: BigInt
@@ -395,6 +407,8 @@ class DeviceTypeType(PrimaryObjectType):
     instances: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     poweroutlettemplates: list[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
     powerporttemplates: list[Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
+    coolingoutflowtemplates: list[Annotated["CoolingOutflowTemplateType", strawberry.lazy('dcim.graphql.types')]]
+    coolingintaketemplates: list[Annotated["CoolingIntakeTemplateType", strawberry.lazy('dcim.graphql.types')]]
     inventoryitemtemplates: list[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]]
     rearporttemplates: list[Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
     consoleserverporttemplates: list[Annotated["ConsoleServerPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
@@ -597,9 +611,11 @@ class ModuleType(PrimaryObjectType):
 
     interfaces: list[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
     powerports: list[Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')]]
+    coolingintakes: list[Annotated["CoolingIntakeType", strawberry.lazy('dcim.graphql.types')]]
     consoleserverports: list[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]]
     consoleports: list[Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')]]
     poweroutlets: list[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]]
+    coolingoutflows: list[Annotated["CoolingOutflowType", strawberry.lazy('dcim.graphql.types')]]
     rearports: list[Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]]
     frontports: list[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]]
 
@@ -668,6 +684,8 @@ class ModuleTypeType(PrimaryObjectType):
     console_server_port_template_count: BigInt
     power_port_template_count: BigInt
     power_outlet_template_count: BigInt
+    cooling_intake_template_count: BigInt
+    cooling_outflow_template_count: BigInt
     interface_template_count: BigInt
     front_port_template_count: BigInt
     rear_port_template_count: BigInt
@@ -681,6 +699,8 @@ class ModuleTypeType(PrimaryObjectType):
     interfacetemplates: list[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]]
     powerporttemplates: list[Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
     poweroutlettemplates: list[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
+    coolingintaketemplates: list[Annotated["CoolingIntakeTemplateType", strawberry.lazy('dcim.graphql.types')]]
+    coolingoutflowtemplates: list[Annotated["CoolingOutflowTemplateType", strawberry.lazy('dcim.graphql.types')]]
     rearporttemplates: list[Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
     instances: list[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]]
     consoleporttemplates: list[Annotated["ConsolePortTemplateType", strawberry.lazy('dcim.graphql.types')]]
@@ -792,6 +812,73 @@ class PowerPortTemplateType(ModularComponentTemplateType):
     poweroutlet_templates: list[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
+@register_type(
+    models.CoolingFeed,
+    exclude=['_abs_max_flow'],
+    filters=CoolingFeedFilter,
+    pagination=True
+)
+class CoolingFeedType(PrimaryObjectType):
+    cooling_source: Annotated["CoolingSourceType", strawberry.lazy('dcim.graphql.types')]
+    rack: Annotated["RackType", strawberry.lazy('dcim.graphql.types')] | None
+    tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
+
+
+@register_type(
+    models.CoolingOutflow,
+    exclude=['_abs_diameter'],
+    filters=CoolingOutflowFilter,
+    pagination=True
+)
+class CoolingOutflowType(ModularComponentType):
+    cooling_intake: Annotated["CoolingIntakeType", strawberry.lazy('dcim.graphql.types')] | None
+
+
+@register_type(
+    models.CoolingOutflowTemplate,
+    exclude=['_abs_diameter'],
+    filters=CoolingOutflowTemplateFilter,
+    pagination=True
+)
+class CoolingOutflowTemplateType(ModularComponentTemplateType):
+    cooling_intake: Annotated["CoolingIntakeTemplateType", strawberry.lazy('dcim.graphql.types')] | None
+
+
+@register_type(
+    models.CoolingIntake,
+    exclude=['_abs_diameter', '_abs_max_flow'],
+    filters=CoolingIntakeFilter,
+    pagination=True
+)
+class CoolingIntakeType(ModularComponentType):
+    cooling_outflow: Annotated["CoolingOutflowType", strawberry.lazy('dcim.graphql.types')] | None
+
+    coolingoutflows: list[Annotated["CoolingOutflowType", strawberry.lazy('dcim.graphql.types')]]
+
+
+@register_type(
+    models.CoolingIntakeTemplate,
+    exclude=['_abs_diameter', '_abs_max_flow'],
+    filters=CoolingIntakeTemplateFilter,
+    pagination=True
+)
+class CoolingIntakeTemplateType(ModularComponentTemplateType):
+    coolingoutflow_templates: list[Annotated["CoolingOutflowTemplateType", strawberry.lazy('dcim.graphql.types')]]
+
+
+@register_type(
+    models.CoolingSource,
+    fields='__all__',
+    filters=CoolingSourceFilter,
+    pagination=True
+)
+class CoolingSourceType(ContactsMixin, PrimaryObjectType):
+    site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]
+    location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None
+
+    cooling_feeds: list[Annotated["CoolingFeedType", strawberry.lazy('dcim.graphql.types')]]
+
+
 @register_type(
     models.RackGroup,
     fields='__all__',

+ 836 - 0
netbox/dcim/migrations/0250_cooling_infrastructure.py

@@ -0,0 +1,836 @@
+from decimal import Decimal
+
+import django.core.validators
+import django.db.models.deletion
+import taggit.managers
+from django.db import migrations, models
+
+import netbox.models.deletion
+import utilities.fields
+import utilities.json
+import utilities.tracking
+from utilities.migration import InstallDenormalizationTrigger
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("dcim", "0249_interface_channels"),
+        ("extras", "0139_alter_customfieldchoiceset_extra_choices"),
+        ("tenancy", "0025_ltree_paths"),
+        ("users", "0016_default_ordering_indexes"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="device",
+            name="cooling_method",
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AddField(
+            model_name="device",
+            name="cooling_outflow_count",
+            field=utilities.fields.CounterCacheField(
+                default=0,
+                editable=False,
+                to_field="device",
+                to_model="dcim.CoolingOutflow",
+            ),
+        ),
+        migrations.AddField(
+            model_name="device",
+            name="cooling_intake_count",
+            field=utilities.fields.CounterCacheField(
+                default=0,
+                editable=False,
+                to_field="device",
+                to_model="dcim.CoolingIntake",
+            ),
+        ),
+        migrations.AddField(
+            model_name="devicetype",
+            name="cooling_method",
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AddField(
+            model_name="devicetype",
+            name="cooling_outflow_template_count",
+            field=utilities.fields.CounterCacheField(
+                default=0,
+                editable=False,
+                to_field="device_type",
+                to_model="dcim.CoolingOutflowTemplate",
+            ),
+        ),
+        migrations.AddField(
+            model_name="devicetype",
+            name="cooling_intake_template_count",
+            field=utilities.fields.CounterCacheField(
+                default=0,
+                editable=False,
+                to_field="device_type",
+                to_model="dcim.CoolingIntakeTemplate",
+            ),
+        ),
+        migrations.AddField(
+            model_name="moduletype",
+            name="cooling_method",
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AddField(
+            model_name="moduletype",
+            name="cooling_outflow_template_count",
+            field=utilities.fields.CounterCacheField(
+                default=0,
+                editable=False,
+                to_field="module_type",
+                to_model="dcim.CoolingOutflowTemplate",
+            ),
+        ),
+        migrations.AddField(
+            model_name="moduletype",
+            name="cooling_intake_template_count",
+            field=utilities.fields.CounterCacheField(
+                default=0,
+                editable=False,
+                to_field="module_type",
+                to_model="dcim.CoolingIntakeTemplate",
+            ),
+        ),
+        migrations.AddField(
+            model_name="rack",
+            name="cooling_capability",
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AddField(
+            model_name="rack",
+            name="cooling_capacity",
+            field=models.DecimalField(
+                blank=True,
+                decimal_places=2,
+                max_digits=10,
+                null=True,
+                validators=[django.core.validators.MinValueValidator(0)],
+            ),
+        ),
+        migrations.AddField(
+            model_name="racktype",
+            name="cooling_capability",
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AddField(
+            model_name="racktype",
+            name="cooling_capacity",
+            field=models.DecimalField(
+                blank=True,
+                decimal_places=2,
+                max_digits=10,
+                null=True,
+                validators=[django.core.validators.MinValueValidator(0)],
+            ),
+        ),
+        migrations.CreateModel(
+            name="CoolingIntake",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True, primary_key=True, serialize=False
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True, null=True)),
+                ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+                (
+                    "custom_field_data",
+                    models.JSONField(
+                        blank=True,
+                        default=dict,
+                        encoder=utilities.json.CustomFieldJSONEncoder,
+                    ),
+                ),
+                (
+                    "diameter",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(Decimal('0.01'))],
+                    ),
+                ),
+                (
+                    "diameter_unit",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                (
+                    "_abs_diameter",
+                    models.DecimalField(
+                        blank=True, decimal_places=4, max_digits=13, null=True
+                    ),
+                ),
+                (
+                    "max_flow",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(Decimal('0.01'))],
+                    ),
+                ),
+                (
+                    "max_flow_unit",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                (
+                    "_abs_max_flow",
+                    models.DecimalField(
+                        blank=True, decimal_places=4, max_digits=13, null=True
+                    ),
+                ),
+                ("name", models.CharField(db_collation="natural_sort", max_length=64)),
+                ("label", models.CharField(blank=True, max_length=64)),
+                ("description", models.CharField(blank=True, max_length=200)),
+                ("type", models.CharField(blank=True, max_length=50, null=True)),
+                (
+                    "_location",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to="dcim.location",
+                    ),
+                ),
+                (
+                    "_rack",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to="dcim.rack",
+                    ),
+                ),
+                (
+                    "_site",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to="dcim.site",
+                    ),
+                ),
+                (
+                    "device",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="%(class)ss",
+                        to="dcim.device",
+                    ),
+                ),
+                (
+                    "module",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="%(class)ss",
+                        to="dcim.module",
+                    ),
+                ),
+                (
+                    "owner",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name="+",
+                        to="users.owner",
+                    ),
+                ),
+                (
+                    "tags",
+                    taggit.managers.TaggableManager(
+                        through="extras.TaggedItem", to="extras.Tag"
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "cooling intake",
+                "verbose_name_plural": "cooling intakes",
+                "ordering": ("device", "name"),
+                "abstract": False,
+            },
+            bases=(
+                netbox.models.deletion.DeleteMixin,
+                models.Model,
+                utilities.tracking.TrackingModelMixin,
+            ),
+        ),
+        migrations.CreateModel(
+            name="CoolingOutflow",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True, primary_key=True, serialize=False
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True, null=True)),
+                ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+                (
+                    "custom_field_data",
+                    models.JSONField(
+                        blank=True,
+                        default=dict,
+                        encoder=utilities.json.CustomFieldJSONEncoder,
+                    ),
+                ),
+                (
+                    "diameter",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(Decimal('0.01'))],
+                    ),
+                ),
+                (
+                    "diameter_unit",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                (
+                    "_abs_diameter",
+                    models.DecimalField(
+                        blank=True, decimal_places=4, max_digits=13, null=True
+                    ),
+                ),
+                ("name", models.CharField(db_collation="natural_sort", max_length=64)),
+                ("label", models.CharField(blank=True, max_length=64)),
+                ("description", models.CharField(blank=True, max_length=200)),
+                ("type", models.CharField(blank=True, max_length=50, null=True)),
+                (
+                    "_location",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to="dcim.location",
+                    ),
+                ),
+                (
+                    "_rack",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to="dcim.rack",
+                    ),
+                ),
+                (
+                    "_site",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to="dcim.site",
+                    ),
+                ),
+                (
+                    "device",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="%(class)ss",
+                        to="dcim.device",
+                    ),
+                ),
+                (
+                    "module",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="%(class)ss",
+                        to="dcim.module",
+                    ),
+                ),
+                (
+                    "owner",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name="+",
+                        to="users.owner",
+                    ),
+                ),
+                (
+                    "tags",
+                    taggit.managers.TaggableManager(
+                        through="extras.TaggedItem", to="extras.Tag"
+                    ),
+                ),
+                (
+                    "cooling_intake",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="coolingoutflows",
+                        to="dcim.coolingintake",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "cooling outflow",
+                "verbose_name_plural": "cooling outflows",
+                "ordering": ("device", "name"),
+                "abstract": False,
+            },
+            bases=(
+                netbox.models.deletion.DeleteMixin,
+                models.Model,
+                utilities.tracking.TrackingModelMixin,
+            ),
+        ),
+        migrations.CreateModel(
+            name="CoolingIntakeTemplate",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True, primary_key=True, serialize=False
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True, null=True)),
+                ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+                (
+                    "diameter",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(Decimal('0.01'))],
+                    ),
+                ),
+                (
+                    "diameter_unit",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                (
+                    "_abs_diameter",
+                    models.DecimalField(
+                        blank=True, decimal_places=4, max_digits=13, null=True
+                    ),
+                ),
+                (
+                    "max_flow",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(Decimal('0.01'))],
+                    ),
+                ),
+                (
+                    "max_flow_unit",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                (
+                    "_abs_max_flow",
+                    models.DecimalField(
+                        blank=True, decimal_places=4, max_digits=13, null=True
+                    ),
+                ),
+                ("name", models.CharField(db_collation="natural_sort", max_length=64)),
+                ("label", models.CharField(blank=True, max_length=64)),
+                ("description", models.CharField(blank=True, max_length=200)),
+                ("type", models.CharField(blank=True, max_length=50, null=True)),
+                (
+                    "device_type",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="%(class)ss",
+                        to="dcim.devicetype",
+                    ),
+                ),
+                (
+                    "module_type",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="%(class)ss",
+                        to="dcim.moduletype",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "cooling intake template",
+                "verbose_name_plural": "cooling intake templates",
+                "ordering": ("device_type", "module_type", "name"),
+                "abstract": False,
+            },
+            bases=(
+                netbox.models.deletion.DeleteMixin,
+                models.Model,
+                utilities.tracking.TrackingModelMixin,
+            ),
+        ),
+        migrations.CreateModel(
+            name="CoolingOutflowTemplate",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True, primary_key=True, serialize=False
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True, null=True)),
+                ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+                (
+                    "diameter",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(Decimal('0.01'))],
+                    ),
+                ),
+                (
+                    "diameter_unit",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                (
+                    "_abs_diameter",
+                    models.DecimalField(
+                        blank=True, decimal_places=4, max_digits=13, null=True
+                    ),
+                ),
+                ("name", models.CharField(db_collation="natural_sort", max_length=64)),
+                ("label", models.CharField(blank=True, max_length=64)),
+                ("description", models.CharField(blank=True, max_length=200)),
+                ("type", models.CharField(blank=True, max_length=50, null=True)),
+                (
+                    "device_type",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="%(class)ss",
+                        to="dcim.devicetype",
+                    ),
+                ),
+                (
+                    "module_type",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="%(class)ss",
+                        to="dcim.moduletype",
+                    ),
+                ),
+                (
+                    "cooling_intake",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="coolingoutflow_templates",
+                        to="dcim.coolingintaketemplate",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "cooling outflow template",
+                "verbose_name_plural": "cooling outflow templates",
+                "ordering": ("device_type", "module_type", "name"),
+                "abstract": False,
+            },
+            bases=(
+                netbox.models.deletion.DeleteMixin,
+                models.Model,
+                utilities.tracking.TrackingModelMixin,
+            ),
+        ),
+        migrations.CreateModel(
+            name="CoolingSource",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True, primary_key=True, serialize=False
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True, null=True)),
+                ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+                (
+                    "custom_field_data",
+                    models.JSONField(
+                        blank=True,
+                        default=dict,
+                        encoder=utilities.json.CustomFieldJSONEncoder,
+                    ),
+                ),
+                ("description", models.CharField(blank=True, max_length=200)),
+                ("comments", models.TextField(blank=True)),
+                ("name", models.CharField(db_collation="natural_sort", max_length=100)),
+                ("type", models.CharField(max_length=50)),
+                ("status", models.CharField(default="active", max_length=50)),
+                ("fluid_type", models.CharField(blank=True, max_length=50, null=True)),
+                (
+                    "cooling_capacity",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=10,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(0)],
+                    ),
+                ),
+                (
+                    "location",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.PROTECT,
+                        to="dcim.location",
+                    ),
+                ),
+                (
+                    "owner",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name="+",
+                        to="users.owner",
+                    ),
+                ),
+                (
+                    "site",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.PROTECT, to="dcim.site"
+                    ),
+                ),
+                (
+                    "tags",
+                    taggit.managers.TaggableManager(
+                        through="extras.TaggedItem", to="extras.Tag"
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "cooling source",
+                "verbose_name_plural": "cooling sources",
+                "ordering": ["site", "name"],
+            },
+            bases=(netbox.models.deletion.DeleteMixin, models.Model),
+        ),
+        migrations.CreateModel(
+            name="CoolingFeed",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True, primary_key=True, serialize=False
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True, null=True)),
+                ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+                (
+                    "custom_field_data",
+                    models.JSONField(
+                        blank=True,
+                        default=dict,
+                        encoder=utilities.json.CustomFieldJSONEncoder,
+                    ),
+                ),
+                (
+                    "max_flow",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(Decimal('0.01'))],
+                    ),
+                ),
+                (
+                    "max_flow_unit",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                (
+                    "_abs_max_flow",
+                    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)),
+                ("name", models.CharField(db_collation="natural_sort", max_length=100)),
+                ("status", models.CharField(default="active", max_length=50)),
+                (
+                    "cooling_capacity",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=10,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(0)],
+                    ),
+                ),
+                (
+                    "owner",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name="+",
+                        to="users.owner",
+                    ),
+                ),
+                (
+                    "rack",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name="cooling_feeds",
+                        to="dcim.rack",
+                    ),
+                ),
+                (
+                    "tags",
+                    taggit.managers.TaggableManager(
+                        through="extras.TaggedItem", to="extras.Tag"
+                    ),
+                ),
+                (
+                    "tenant",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name="cooling_feeds",
+                        to="tenancy.tenant",
+                    ),
+                ),
+                (
+                    "cooling_source",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name="cooling_feeds",
+                        to="dcim.coolingsource",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "cooling feed",
+                "verbose_name_plural": "cooling feeds",
+                "ordering": ["cooling_source", "name"],
+            },
+            bases=(netbox.models.deletion.DeleteMixin, models.Model),
+        ),
+        migrations.AddField(
+            model_name="coolingintake",
+            name="cooling_outflow",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="coolingintakes",
+                to="dcim.coolingoutflow",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="coolingintake",
+            constraint=models.UniqueConstraint(
+                fields=("device", "name"), name="dcim_coolingintake_unique_device_name"
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="coolingoutflow",
+            constraint=models.UniqueConstraint(
+                fields=("device", "name"), name="dcim_coolingoutflow_unique_device_name"
+            ),
+        ),
+        migrations.AddIndex(
+            model_name="coolingintaketemplate",
+            index=models.Index(
+                fields=["device_type", "module_type", "name"],
+                name="dcim_coolin_device__ee88f9_idx",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="coolingintaketemplate",
+            constraint=models.UniqueConstraint(
+                fields=("device_type", "name"),
+                name="dcim_coolingintaketemplate_unique_device_type_name",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="coolingintaketemplate",
+            constraint=models.UniqueConstraint(
+                fields=("module_type", "name"),
+                name="dcim_coolingintaketemplate_unique_module_type_name",
+            ),
+        ),
+        migrations.AddIndex(
+            model_name="coolingoutflowtemplate",
+            index=models.Index(
+                fields=["device_type", "module_type", "name"],
+                name="dcim_coolin_device__4d0859_idx",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="coolingoutflowtemplate",
+            constraint=models.UniqueConstraint(
+                fields=("device_type", "name"),
+                name="dcim_coolingoutflowtemplate_unique_device_type_name",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="coolingoutflowtemplate",
+            constraint=models.UniqueConstraint(
+                fields=("module_type", "name"),
+                name="dcim_coolingoutflowtemplate_unique_module_type_name",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="coolingsource",
+            constraint=models.UniqueConstraint(
+                fields=("site", "name"), name="dcim_coolingsource_unique_site_name"
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="coolingfeed",
+            constraint=models.UniqueConstraint(
+                fields=("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 0243.
+        *[
+            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_coolingintake", "dcim_coolingoutflow")
+        ],
+    ]

+ 1 - 0
netbox/dcim/models/__init__.py

@@ -1,4 +1,5 @@
 from .cables import *
+from .cooling import *
 from .device_component_templates import *
 from .device_components import *
 from .devices import *

+ 193 - 0
netbox/dcim/models/cooling.py

@@ -0,0 +1,193 @@
+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 dcim.choices import *
+from dcim.models.mixins import MaxFlowMixin
+from netbox.models import PrimaryModel
+from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
+
+__all__ = (
+    'CoolingFeed',
+    'CoolingSource',
+)
+
+
+#
+# Cooling
+#
+
+class CoolingSource(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
+    """
+    A facility-level source of cooling; e.g. a chiller, cooling tower, or dry cooler.
+    """
+    site = models.ForeignKey(
+        to='Site',
+        on_delete=models.PROTECT
+    )
+    location = models.ForeignKey(
+        to='dcim.Location',
+        on_delete=models.PROTECT,
+        blank=True,
+        null=True
+    )
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        db_collation="natural_sort"
+    )
+    type = models.CharField(
+        verbose_name=_('type'),
+        max_length=50,
+        choices=CoolingSourceTypeChoices
+    )
+    status = models.CharField(
+        verbose_name=_('status'),
+        max_length=50,
+        choices=CoolingSourceStatusChoices,
+        default=CoolingSourceStatusChoices.STATUS_ACTIVE
+    )
+    fluid_type = models.CharField(
+        verbose_name=_('fluid type'),
+        max_length=50,
+        choices=FluidTypeChoices,
+        blank=True,
+        null=True
+    )
+    cooling_capacity = models.DecimalField(
+        verbose_name=_('cooling capacity'),
+        max_digits=10,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(0)],
+        help_text=_('Total rated cooling capacity (kW)')
+    )
+
+    clone_fields = (
+        'site', 'location', 'type', 'status', 'fluid_type', 'cooling_capacity',
+    )
+    prerequisite_models = (
+        'dcim.Site',
+    )
+
+    class Meta:
+        ordering = ['site', 'name']
+        constraints = (
+            models.UniqueConstraint(
+                fields=('site', 'name'),
+                name='%(app_label)s_%(class)s_unique_site_name'
+            ),
+        )
+        verbose_name = _('cooling source')
+        verbose_name_plural = _('cooling sources')
+
+    def __str__(self):
+        return self.name
+
+    def get_status_color(self):
+        return CoolingSourceStatusChoices.colors.get(self.status)
+
+    def clean(self):
+        super().clean()
+
+        # Location must belong to assigned Site
+        if self.location and self.location.site != self.site:
+            raise ValidationError(
+                _("Location {location} ({location_site}) is in a different site than {site}").format(
+                    location=self.location, location_site=self.location.site, site=self.site)
+            )
+
+
+class CoolingFeed(MaxFlowMixin, PrimaryModel):
+    """
+    A coolant loop delivered from a CoolingSource to a rack or CDU. A single feed represents the entire
+    loop (both the supply and return paths). A CoolingFeed serves a rack; the cooling intakes it supplies
+    are derived from the devices installed in that rack rather than referenced explicitly.
+
+    Maximum flow is a rated specification (the flow the loop supports), not live telemetry; runtime
+    readings belong in an external monitoring system.
+    """
+    cooling_source = models.ForeignKey(
+        to='CoolingSource',
+        on_delete=models.PROTECT,
+        related_name='cooling_feeds'
+    )
+    rack = models.ForeignKey(
+        to='Rack',
+        on_delete=models.PROTECT,
+        related_name='cooling_feeds',
+        blank=True,
+        null=True
+    )
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        db_collation="natural_sort"
+    )
+    status = models.CharField(
+        verbose_name=_('status'),
+        max_length=50,
+        choices=CoolingFeedStatusChoices,
+        default=CoolingFeedStatusChoices.STATUS_ACTIVE
+    )
+    cooling_capacity = models.DecimalField(
+        verbose_name=_('cooling capacity'),
+        max_digits=10,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(0)],
+        help_text=_('Rated cooling capacity (kW)')
+    )
+    # max_flow, max_flow_unit, _abs_max_flow provided by MaxFlowMixin
+    tenant = models.ForeignKey(
+        to='tenancy.Tenant',
+        on_delete=models.PROTECT,
+        related_name='cooling_feeds',
+        blank=True,
+        null=True
+    )
+
+    clone_fields = (
+        'cooling_source', 'rack', 'status', 'cooling_capacity', 'max_flow', 'max_flow_unit', 'tenant',
+    )
+    prerequisite_models = (
+        'dcim.CoolingSource',
+    )
+
+    class Meta:
+        ordering = ['cooling_source', 'name']
+        constraints = (
+            models.UniqueConstraint(
+                fields=('cooling_source', 'name'),
+                name='%(app_label)s_%(class)s_unique_cooling_source_name'
+            ),
+        )
+        verbose_name = _('cooling feed')
+        verbose_name_plural = _('cooling feeds')
+
+    def __str__(self):
+        return self.name
+
+    def clean(self):
+        super().clean()
+
+        # Rack must belong to same Site as CoolingSource
+        if self.rack and self.rack.site != self.cooling_source.site:
+            raise ValidationError(_(
+                "Rack {rack} ({rack_site}) and cooling source {source} ({source_site}) are in different sites."
+            ).format(
+                rack=self.rack,
+                rack_site=self.rack.site,
+                source=self.cooling_source,
+                source_site=self.cooling_source.site
+            ))
+
+    @property
+    def parent_object(self):
+        return self.cooling_source
+
+    def get_status_color(self):
+        return CoolingFeedStatusChoices.colors.get(self.status)

+ 131 - 1
netbox/dcim/models/device_component_templates.py

@@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
 from dcim.choices import *
 from dcim.constants import *
 from dcim.models.base import PortMappingBase
-from dcim.models.mixins import InterfaceValidationMixin
+from dcim.models.mixins import DiameterMixin, InterfaceValidationMixin, MaxFlowMixin
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import ChangeLoggingMixin
@@ -22,6 +22,8 @@ from wireless.choices import WirelessRoleChoices
 from .device_components import (
     ConsolePort,
     ConsoleServerPort,
+    CoolingIntake,
+    CoolingOutflow,
     DeviceBay,
     FrontPort,
     Interface,
@@ -35,6 +37,8 @@ from .device_components import (
 __all__ = (
     'ConsolePortTemplate',
     'ConsoleServerPortTemplate',
+    'CoolingIntakeTemplate',
+    'CoolingOutflowTemplate',
     'DeviceBayTemplate',
     'FrontPortTemplate',
     'InterfaceTemplate',
@@ -438,6 +442,132 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
         }
 
 
+class CoolingIntakeTemplate(DiameterMixin, MaxFlowMixin, ModularComponentTemplateModel):
+    """
+    A template for a CoolingIntake to be created for a new Device.
+    """
+    type = models.CharField(
+        verbose_name=_('type'),
+        max_length=50,
+        choices=CoolingConnectorTypeChoices,
+        blank=True,
+        null=True
+    )
+    # diameter, diameter_unit, _abs_diameter provided by DiameterMixin
+    # max_flow, max_flow_unit, _abs_max_flow provided by MaxFlowMixin
+
+    component_model = CoolingIntake
+
+    class Meta(ModularComponentTemplateModel.Meta):
+        verbose_name = _('cooling intake template')
+        verbose_name_plural = _('cooling intake templates')
+
+    def instantiate(self, **kwargs):
+        component = self.component_model(
+            name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+            label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
+            type=self.type,
+            diameter=self.diameter,
+            diameter_unit=self.diameter_unit,
+            max_flow=self.max_flow,
+            max_flow_unit=self.max_flow_unit,
+            **kwargs
+        )
+        # bulk_create bypasses save(), so populate the normalized _abs_* fields here
+        component.normalize_diameter()
+        component.normalize_max_flow()
+        return component
+    instantiate.do_not_call_in_templates = True
+
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'diameter': float(self.diameter) if self.diameter is not None else None,
+            'diameter_unit': self.diameter_unit,
+            'max_flow': float(self.max_flow) if self.max_flow is not None else None,
+            'max_flow_unit': self.max_flow_unit,
+            'label': self.label,
+            'description': self.description,
+        }
+
+
+class CoolingOutflowTemplate(DiameterMixin, ModularComponentTemplateModel):
+    """
+    A template for a CoolingOutflow to be created for a new Device.
+    """
+    type = models.CharField(
+        verbose_name=_('type'),
+        max_length=50,
+        choices=CoolingConnectorTypeChoices,
+        blank=True,
+        null=True
+    )
+    # diameter, diameter_unit, _abs_diameter provided by DiameterMixin
+    cooling_intake = models.ForeignKey(
+        to='dcim.CoolingIntakeTemplate',
+        on_delete=models.SET_NULL,
+        blank=True,
+        null=True,
+        related_name='coolingoutflow_templates'
+    )
+
+    component_model = CoolingOutflow
+
+    class Meta(ModularComponentTemplateModel.Meta):
+        verbose_name = _('cooling outflow template')
+        verbose_name_plural = _('cooling outflow templates')
+
+    def clean(self):
+        super().clean()
+
+        # Validate cooling intake assignment
+        if self.cooling_intake:
+            if self.device_type and self.cooling_intake.device_type != self.device_type:
+                raise ValidationError(
+                    _("Parent cooling intake ({cooling_intake}) must belong to the same device type").format(
+                        cooling_intake=self.cooling_intake
+                    )
+                )
+            if self.module_type and self.cooling_intake.module_type != self.module_type:
+                raise ValidationError(
+                    _("Parent cooling intake ({cooling_intake}) must belong to the same module type").format(
+                        cooling_intake=self.cooling_intake
+                    )
+                )
+
+    def instantiate(self, **kwargs):
+        if self.cooling_intake:
+            cooling_intake_name = self.cooling_intake.resolve_name(kwargs.get('module'), kwargs.get('device'))
+            cooling_intake = CoolingIntake.objects.get(name=cooling_intake_name, **kwargs)
+        else:
+            cooling_intake = None
+        component = self.component_model(
+            name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+            label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
+            type=self.type,
+            diameter=self.diameter,
+            diameter_unit=self.diameter_unit,
+            cooling_intake=cooling_intake,
+            **kwargs
+        )
+        # bulk_create bypasses save(), so populate the normalized _abs_diameter here
+        component.normalize_diameter()
+        return component
+    instantiate.do_not_call_in_templates = True
+
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'diameter': float(self.diameter) if self.diameter is not None else None,
+            'diameter_unit': self.diameter_unit,
+            'cooling_intake': self.cooling_intake.name if self.cooling_intake else None,
+            'label': self.label,
+            'description': self.description,
+        }
+
+
 class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel):
     """
     A template for a physical data interface on a new Device.

+ 98 - 1
netbox/dcim/models/device_components.py

@@ -12,7 +12,12 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.fields import WWNField
 from dcim.models.base import PortMappingBase
-from dcim.models.mixins import InterfaceValidationMixin
+from dcim.models.mixins import (
+    CoolingLoopValidationMixin,
+    DiameterMixin,
+    InterfaceValidationMixin,
+    MaxFlowMixin,
+)
 from netbox.choices import ColorChoices
 from netbox.models import NetBoxModel, OrganizationalModel
 from netbox.models.features import ChangeLoggingMixin
@@ -30,6 +35,8 @@ __all__ = (
     'CabledObjectModel',
     'ConsolePort',
     'ConsoleServerPort',
+    'CoolingIntake',
+    'CoolingOutflow',
     'DeviceBay',
     'FrontPort',
     'Interface',
@@ -684,6 +691,96 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
         return PowerOutletStatusChoices.colors.get(self.status)
 
 
+#
+# Cooling components
+#
+
+class CoolingIntake(
+    CoolingLoopValidationMixin, DiameterMixin, MaxFlowMixin, ModularComponentModel, TrackingModelMixin
+):
+    """
+    A coolant intake port within a Device (e.g. a server cold-plate inlet or CDU intake). A
+    CoolingIntake is supplied by an upstream CoolingOutflow or CoolingFeed.
+    """
+    type = models.CharField(
+        verbose_name=_('type'),
+        max_length=50,
+        choices=CoolingConnectorTypeChoices,
+        blank=True,
+        null=True,
+        help_text=_('Physical connector type')
+    )
+    # diameter, diameter_unit, _abs_diameter provided by DiameterMixin
+    # max_flow, max_flow_unit, _abs_max_flow provided by MaxFlowMixin
+    cooling_outflow = models.ForeignKey(
+        to='dcim.CoolingOutflow',
+        on_delete=models.SET_NULL,
+        blank=True,
+        null=True,
+        related_name='coolingintakes',
+        help_text=_('The upstream cooling outflow supplying this intake')
+    )
+
+    clone_fields = (
+        'device', 'module', 'type', 'diameter', 'diameter_unit', 'max_flow',
+        'max_flow_unit',
+    )
+    upstream_field = 'cooling_outflow'
+
+    class Meta(ModularComponentModel.Meta):
+        verbose_name = _('cooling intake')
+        verbose_name_plural = _('cooling intakes')
+
+    def clean(self):
+        super().clean()
+
+        # Prevent the intake/outflow chain from forming a loop
+        self.validate_cooling_loop()
+
+
+class CoolingOutflow(CoolingLoopValidationMixin, DiameterMixin, ModularComponentModel, TrackingModelMixin):
+    """
+    A coolant outlet within a Device (e.g. a CDU or manifold outlet) which supplies one or more
+    CoolingIntakes (referenced via CoolingIntake.cooling_outflow).
+    """
+    type = models.CharField(
+        verbose_name=_('type'),
+        max_length=50,
+        choices=CoolingConnectorTypeChoices,
+        blank=True,
+        null=True,
+        help_text=_('Physical connector type')
+    )
+    # diameter, diameter_unit, _abs_diameter provided by DiameterMixin
+    cooling_intake = models.ForeignKey(
+        to='dcim.CoolingIntake',
+        on_delete=models.SET_NULL,
+        blank=True,
+        null=True,
+        related_name='coolingoutflows'
+    )
+
+    clone_fields = ('device', 'module', 'type', 'diameter', 'diameter_unit', 'cooling_intake')
+    upstream_field = 'cooling_intake'
+
+    class Meta(ModularComponentModel.Meta):
+        verbose_name = _('cooling outflow')
+        verbose_name_plural = _('cooling outflows')
+
+    def clean(self):
+        super().clean()
+
+        # Validate cooling intake assignment
+        if self.cooling_intake and self.cooling_intake.device != self.device:
+            raise ValidationError(
+                _("Parent cooling intake ({cooling_intake}) must belong to the same device").format(
+                    cooling_intake=self.cooling_intake)
+            )
+
+        # Prevent the intake/outflow chain from forming a loop
+        self.validate_cooling_loop()
+
+
 #
 # Interfaces
 #

+ 54 - 3
netbox/dcim/models/devices.py

@@ -136,6 +136,13 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         blank=True,
         null=True
     )
+    cooling_method = models.CharField(
+        verbose_name=_('cooling method'),
+        max_length=50,
+        choices=CoolingMethodChoices,
+        blank=True,
+        null=True
+    )
     end_of_life = models.DateField(
         verbose_name=_('end of life'),
         blank=True,
@@ -168,6 +175,14 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         to_model='dcim.PowerOutletTemplate',
         to_field='device_type'
     )
+    cooling_intake_template_count = CounterCacheField(
+        to_model='dcim.CoolingIntakeTemplate',
+        to_field='device_type'
+    )
+    cooling_outflow_template_count = CounterCacheField(
+        to_model='dcim.CoolingOutflowTemplate',
+        to_field='device_type'
+    )
     interface_template_count = CounterCacheField(
         to_model='dcim.InterfaceTemplate',
         to_field='device_type'
@@ -198,8 +213,8 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
     )
 
     clone_fields = (
-        'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
-        'weight_unit',
+        'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
+        'cooling_method', 'weight', 'weight_unit',
     )
     prerequisite_models = (
         'dcim.Manufacturer',
@@ -237,6 +252,9 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
     def full_name(self):
         return f"{self.manufacturer} {self.model}"
 
+    def get_cooling_method_color(self):
+        return CoolingMethodChoices.colors.get(self.cooling_method)
+
     def to_yaml(self):
         data = {
             'manufacturer': self.manufacturer.name,
@@ -249,6 +267,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             'is_full_depth': self.is_full_depth,
             'subdevice_role': self.subdevice_role,
             'airflow': self.airflow,
+            'cooling_method': self.cooling_method,
             'weight': float(self.weight) if self.weight is not None else None,
             'weight_unit': self.weight_unit,
             'end_of_life': self.end_of_life.isoformat() if self.end_of_life else None,
@@ -272,6 +291,14 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             data['power-outlets'] = [
                 c.to_yaml() for c in self.poweroutlettemplates.all()
             ]
+        if self.coolingintaketemplates.exists():
+            data['cooling-intakes'] = [
+                c.to_yaml() for c in self.coolingintaketemplates.all()
+            ]
+        if self.coolingoutflowtemplates.exists():
+            data['cooling-outflows'] = [
+                c.to_yaml() for c in self.coolingoutflowtemplates.all()
+            ]
         if self.interfacetemplates.exists():
             data['interfaces'] = [
                 c.to_yaml() for c in self.interfacetemplates.all()
@@ -602,6 +629,13 @@ class Device(
         blank=True,
         null=True
     )
+    cooling_method = models.CharField(
+        verbose_name=_('cooling method'),
+        max_length=50,
+        choices=CoolingMethodChoices,
+        blank=True,
+        null=True
+    )
     primary_ip4 = models.OneToOneField(
         to='ipam.IPAddress',
         on_delete=models.SET_NULL,
@@ -701,6 +735,14 @@ class Device(
         to_model='dcim.PowerOutlet',
         to_field='device'
     )
+    cooling_intake_count = CounterCacheField(
+        to_model='dcim.CoolingIntake',
+        to_field='device'
+    )
+    cooling_outflow_count = CounterCacheField(
+        to_model='dcim.CoolingOutflow',
+        to_field='device'
+    )
     interface_count = CounterCacheField(
         to_model='dcim.Interface',
         to_field='device'
@@ -730,7 +772,7 @@ class Device(
 
     clone_fields = (
         'device_type', 'role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow',
-        'cluster', 'virtual_chassis',
+        'cooling_method', 'cluster', 'virtual_chassis',
     )
     prerequisite_models = (
         'dcim.Site',
@@ -1044,6 +1086,10 @@ class Device(
         if is_new and not self.airflow:
             self.airflow = self.device_type.airflow
 
+        # Inherit cooling_method attribute from DeviceType if not set
+        if is_new and not self.cooling_method:
+            self.cooling_method = self.device_type.cooling_method
+
         # Inherit default_platform from DeviceType if not set
         if is_new and not self.platform:
             self.platform = self.device_type.default_platform
@@ -1060,6 +1106,8 @@ class Device(
             self._instantiate_components(self.device_type.consoleserverporttemplates.all())
             self._instantiate_components(self.device_type.powerporttemplates.all())
             self._instantiate_components(self.device_type.poweroutlettemplates.all())
+            self._instantiate_components(self.device_type.coolingintaketemplates.all())
+            self._instantiate_components(self.device_type.coolingoutflowtemplates.all())
             self._instantiate_components(self.device_type.interfacetemplates.all())
             self._instantiate_components(self.device_type.rearporttemplates.all())
             self._instantiate_components(self.device_type.frontporttemplates.all())
@@ -1158,6 +1206,9 @@ class Device(
     def get_status_color(self):
         return DeviceStatusChoices.colors.get(self.status)
 
+    def get_cooling_method_color(self):
+        return CoolingMethodChoices.colors.get(self.cooling_method)
+
     @cached_property
     def total_weight(self):
         total_weight = sum(

+ 174 - 0
netbox/dcim/models/mixins.py

@@ -1,15 +1,26 @@
+from decimal import Decimal
+
 from django.apps import apps
 from django.contrib.contenttypes.fields import GenericForeignKey
 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 dcim.choices import InterfaceTypeChoices
 from dcim.constants import NONCONNECTABLE_IFACE_TYPES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
+from netbox.choices import *
+from utilities.conversion import (
+    to_liters_per_minute,
+    to_millimeters,
+)
 
 __all__ = (
     'CachedScopeMixin',
+    'CoolingLoopValidationMixin',
+    'DiameterMixin',
     'InterfaceValidationMixin',
+    'MaxFlowMixin',
     'RenderConfigMixin',
 )
 
@@ -229,3 +240,166 @@ class InterfaceValidationMixin:
         # RF role may be set only for wireless interfaces
         if self.rf_role and self.type not in WIRELESS_IFACE_TYPES:
             raise ValidationError({'rf_role': _("Wireless role may be set only on wireless interfaces.")})
+
+
+class CoolingLoopValidationMixin:
+    """
+    Adds loop detection to the coolant chain formed by cooling intakes and outflows. A CoolingIntake is supplied
+    by an upstream CoolingOutflow (via `cooling_outflow`), which may in turn be supplied by an upstream
+    CoolingIntake on the same device (via `cooling_intake`), and so on; this chain must remain acyclic.
+
+    Each concrete model declares `upstream_field`, the name of its foreign key to the next component upstream.
+    The chain alternates between the two models, so the walk simply follows each visited component's own
+    upstream field in turn. (The field is resolved by name rather than referencing the models directly, as this
+    module is imported by the ones defining them.)
+    """
+    upstream_field = None
+
+    @classmethod
+    def _get_upstream_field(cls):
+        return cls._meta.get_field(cls.upstream_field)
+
+    def validate_cooling_loop(self):
+        """
+        Raise a ValidationError if this component's upstream assignment forms a loop.
+
+        Each hop resolves only the next foreign key ID (a single indexed column lookup) rather than loading
+        full related objects, and the `seen` set of (model, pk) pairs guarantees termination.
+        """
+        seen = set()
+        if self.pk:
+            seen.add((type(self), self.pk))
+
+        # Seed the walk from this (possibly unsaved) component's in-memory foreign key
+        field = self._get_upstream_field()
+        model, pk = field.related_model, getattr(self, field.attname)
+
+        while pk is not None:
+            if (model, pk) in seen:
+                raise ValidationError(_("Cooling intake and outflow assignments cannot form a loop."))
+            seen.add((model, pk))
+
+            # Advance to the component upstream of the one just visited
+            field = model._get_upstream_field()
+            pk = model.objects.filter(pk=pk).values_list(field.attname, flat=True).first()
+            model = field.related_model
+
+
+class DiameterMixin(models.Model):
+    diameter = models.DecimalField(
+        verbose_name=_('diameter'),
+        max_digits=8,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(Decimal('0.01'))],
+    )
+    diameter_unit = models.CharField(
+        verbose_name=_('diameter unit'),
+        max_length=50,
+        choices=DiameterUnitChoices,
+        blank=True,
+        null=True,
+    )
+    # Stores the normalized diameter (in millimeters) for database ordering
+    _abs_diameter = models.DecimalField(
+        max_digits=13,
+        decimal_places=4,
+        blank=True,
+        null=True
+    )
+
+    class Meta:
+        abstract = True
+
+    @property
+    def abs_diameter(self):
+        # Public alias for _abs_diameter; Django templates cannot access underscore-prefixed attributes.
+        return self._abs_diameter
+
+    def normalize_diameter(self):
+        """
+        Store the given diameter (if any) in millimeters for use in database ordering. Called by save(), and
+        directly by component instantiation, which bypasses save() via bulk_create().
+        """
+        if self.diameter is not None and self.diameter_unit:
+            self._abs_diameter = to_millimeters(self.diameter, self.diameter_unit)
+        else:
+            self._abs_diameter = None
+        if self.diameter is None:
+            self.diameter_unit = None
+    normalize_diameter.alters_data = True
+
+    def save(self, *args, **kwargs):
+        self.normalize_diameter()
+
+        super().save(*args, **kwargs)
+
+    def clean(self):
+        super().clean()
+
+        # Validate diameter and diameter_unit
+        if self.diameter is not None and not self.diameter_unit:
+            raise ValidationError(_("Must specify a unit when setting a diameter"))
+
+
+class MaxFlowMixin(models.Model):
+    """
+    Adds the maximum rate of coolant flow supported by an object, held as a value plus its unit alongside a
+    normalized column (in liters per minute) so that ordering and filtering work across mixed units.
+    """
+    max_flow = models.DecimalField(
+        verbose_name=_('max flow'),
+        max_digits=8,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(Decimal('0.01'))],
+    )
+    max_flow_unit = models.CharField(
+        verbose_name=_('max flow unit'),
+        max_length=50,
+        choices=FlowRateUnitChoices,
+        blank=True,
+        null=True,
+    )
+    # Stores the normalized max flow (in liters per minute) for database ordering
+    _abs_max_flow = models.DecimalField(
+        max_digits=13,
+        decimal_places=4,
+        blank=True,
+        null=True
+    )
+
+    class Meta:
+        abstract = True
+
+    @property
+    def abs_max_flow(self):
+        # Public alias for _abs_max_flow; Django templates cannot access underscore-prefixed attributes.
+        return self._abs_max_flow
+
+    def normalize_max_flow(self):
+        """
+        Store the given max flow (if any) in liters per minute for use in database ordering. Called by save(),
+        and directly by component instantiation, which bypasses save() via bulk_create().
+        """
+        if self.max_flow is not None and self.max_flow_unit:
+            self._abs_max_flow = to_liters_per_minute(self.max_flow, self.max_flow_unit)
+        else:
+            self._abs_max_flow = None
+        if self.max_flow is None:
+            self.max_flow_unit = None
+    normalize_max_flow.alters_data = True
+
+    def save(self, *args, **kwargs):
+        self.normalize_max_flow()
+
+        super().save(*args, **kwargs)
+
+    def clean(self):
+        super().clean()
+
+        # Validate max_flow and max_flow_unit
+        if self.max_flow is not None and not self.max_flow_unit:
+            raise ValidationError(_("Must specify a unit when setting a maximum flow"))

+ 30 - 1
netbox/dcim/models/modules.py

@@ -146,6 +146,13 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         blank=True,
         null=True
     )
+    cooling_method = models.CharField(
+        verbose_name=_('cooling method'),
+        max_length=50,
+        choices=CoolingMethodChoices,
+        blank=True,
+        null=True
+    )
     end_of_life = models.DateField(
         verbose_name=_('end of life'),
         blank=True,
@@ -186,6 +193,14 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         to_model='dcim.PowerOutletTemplate',
         to_field='module_type'
     )
+    cooling_intake_template_count = CounterCacheField(
+        to_model='dcim.CoolingIntakeTemplate',
+        to_field='module_type'
+    )
+    cooling_outflow_template_count = CounterCacheField(
+        to_model='dcim.CoolingOutflowTemplate',
+        to_field='module_type'
+    )
     interface_template_count = CounterCacheField(
         to_model='dcim.InterfaceTemplate',
         to_field='module_type'
@@ -203,7 +218,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         to_field='module_type'
     )
 
-    clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow')
+    clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow', 'cooling_method')
     prerequisite_models = (
         'dcim.Manufacturer',
     )
@@ -281,6 +296,9 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         else:
             self.attribute_data = None
 
+    def get_cooling_method_color(self):
+        return CoolingMethodChoices.colors.get(self.cooling_method)
+
     def to_yaml(self):
         data = {
             'profile': self.profile.name if self.profile else None,
@@ -291,6 +309,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             'weight': float(self.weight) if self.weight is not None else None,
             'weight_unit': self.weight_unit,
             'airflow': self.airflow,
+            'cooling_method': self.cooling_method,
             'end_of_life': self.end_of_life.isoformat() if self.end_of_life else None,
             'attribute_data': self.attribute_data,
             'comments': self.comments,
@@ -313,6 +332,14 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             data['power-outlets'] = [
                 c.to_yaml() for c in self.poweroutlettemplates.all()
             ]
+        if self.coolingintaketemplates.exists():
+            data['cooling-intakes'] = [
+                c.to_yaml() for c in self.coolingintaketemplates.all()
+            ]
+        if self.coolingoutflowtemplates.exists():
+            data['cooling-outflows'] = [
+                c.to_yaml() for c in self.coolingoutflowtemplates.all()
+            ]
         if self.interfacetemplates.exists():
             data['interfaces'] = [
                 c.to_yaml() for c in self.interfacetemplates.all()
@@ -503,6 +530,8 @@ class Module(TrackingModelMixin, PrimaryModel):
             ("interfacetemplates", "interfaces", Interface),
             ("powerporttemplates", "powerports", PowerPort),
             ("poweroutlettemplates", "poweroutlets", PowerOutlet),
+            ("coolingintaketemplates", "coolingintakes", CoolingIntake),
+            ("coolingoutflowtemplates", "coolingoutflows", CoolingOutflow),
             ("rearporttemplates", "rearports", RearPort),
             ("frontporttemplates", "frontports", FrontPort),
             ("modulebaytemplates", "modulebays", ModuleBay),

+ 28 - 5
netbox/dcim/models/racks.py

@@ -135,9 +135,30 @@ class RackBase(WeightMixin, PrimaryModel):
         null=True
     )
 
+    # Cooling
+    cooling_capability = models.CharField(
+        verbose_name=_('cooling capability'),
+        max_length=50,
+        choices=RackCoolingCapabilityChoices,
+        blank=True,
+        null=True
+    )
+    cooling_capacity = models.DecimalField(
+        verbose_name=_('cooling capacity'),
+        max_digits=10,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(0)],
+        help_text=_('Cooling capacity (kW)')
+    )
+
     class Meta:
         abstract = True
 
+    def get_cooling_capability_color(self):
+        return RackCoolingCapabilityChoices.colors.get(self.cooling_capability)
+
 
 #
 # Rack Types
@@ -174,7 +195,8 @@ class RackType(ImageAttachmentsMixin, RackBase):
 
     clone_fields = (
         'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
-        'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
+        'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'cooling_capability',
+        'cooling_capacity',
     )
     prerequisite_models = (
         'dcim.Manufacturer',
@@ -269,7 +291,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
     # Fields which cannot be set locally if a RackType is assigned
     RACKTYPE_FIELDS = (
         'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height',
-        'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'max_weight',
+        'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'max_weight', 'cooling_capability',
+        'cooling_capacity',
     )
 
     form_factor = models.CharField(
@@ -369,9 +392,9 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
     )
 
     clone_fields = (
-        'site', 'location', 'tenant', 'status', 'role', 'form_factor', 'width', 'airflow', 'u_height', 'desc_units',
-        'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight',
-        'weight_unit',
+        'site', 'location', 'tenant', 'status', 'role', 'form_factor', 'width', 'airflow', 'cooling_capability',
+        'cooling_capacity', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
+        'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
     )
     prerequisite_models = (
         'dcim.Site',

+ 44 - 0
netbox/dcim/search.py

@@ -49,6 +49,50 @@ class ConsoleServerPortIndex(SearchIndex):
     display_attrs = ('device', 'label', 'type', 'description')
 
 
+@register_search
+class CoolingFeedIndex(SearchIndex):
+    model = models.CoolingFeed
+    fields = (
+        ('name', 100),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('cooling_source', 'rack', 'status', 'description')
+
+
+@register_search
+class CoolingOutflowIndex(SearchIndex):
+    model = models.CoolingOutflow
+    fields = (
+        ('name', 100),
+        ('label', 200),
+        ('description', 500),
+    )
+    display_attrs = ('device', 'label', 'type', 'description')
+
+
+@register_search
+class CoolingIntakeIndex(SearchIndex):
+    model = models.CoolingIntake
+    fields = (
+        ('name', 100),
+        ('label', 200),
+        ('description', 500),
+    )
+    display_attrs = ('device', 'label', 'type', 'description')
+
+
+@register_search
+class CoolingSourceIndex(SearchIndex):
+    model = models.CoolingSource
+    fields = (
+        ('name', 100),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('site', 'location', 'type', 'status', 'description')
+
+
 @register_search
 class DeviceIndex(SearchIndex):
     model = models.Device

+ 1 - 0
netbox/dcim/tables/__init__.py

@@ -1,5 +1,6 @@
 from .cables import *
 from .connections import *
+from .cooling import *
 from .devices import *
 from .devicetypes import *
 from .modules import *

+ 303 - 0
netbox/dcim/tables/cooling.py

@@ -0,0 +1,303 @@
+import django_tables2 as tables
+from django.utils.translation import gettext_lazy as _
+from django_tables2.utils import Accessor
+
+from dcim import models
+from dcim.models import CoolingFeed, CoolingSource
+from netbox.tables import PrimaryModelTable, columns
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+
+from .devices import DeviceComponentTable, ModularDeviceComponentTable
+from .devicetypes import ComponentTemplateTable
+from .template_code import DIAMETER, MAX_FLOW, MODULAR_COMPONENT_TEMPLATE_BUTTONS
+
+__all__ = (
+    'CoolingFeedTable',
+    'CoolingIntakeTable',
+    'CoolingIntakeTemplateTable',
+    'CoolingOutflowTable',
+    'CoolingOutflowTemplateTable',
+    'CoolingSourceTable',
+    'DeviceCoolingIntakeTable',
+    'DeviceCoolingOutflowTable',
+)
+
+
+#
+# Cooling sources
+#
+
+class CoolingSourceTable(ContactsColumnMixin, PrimaryModelTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True
+    )
+    site = tables.Column(
+        verbose_name=_('Site'),
+        linkify=True
+    )
+    location = tables.Column(
+        verbose_name=_('Location'),
+        linkify=True
+    )
+    status = columns.ChoiceFieldColumn(
+        verbose_name=_('Status'),
+    )
+    type = columns.ChoiceFieldColumn(
+        verbose_name=_('Type'),
+    )
+    fluid_type = columns.ChoiceFieldColumn(
+        verbose_name=_('Fluid Type'),
+    )
+    cooling_capacity = tables.Column(
+        verbose_name=_('Cooling Capacity (kW)')
+    )
+    coolingfeed_count = columns.LinkedCountColumn(
+        viewname='dcim:coolingfeed_list',
+        url_params={'cooling_source_id': 'pk'},
+        verbose_name=_('Cooling Feeds')
+    )
+    tags = columns.TagColumn(
+        url_name='dcim:coolingsource_list'
+    )
+
+    class Meta(PrimaryModelTable.Meta):
+        model = CoolingSource
+        fields = (
+            'pk', 'id', 'name', 'site', 'location', 'type', 'status', 'fluid_type', 'cooling_capacity',
+            'coolingfeed_count', 'contacts', 'description', 'comments', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'site', 'location', 'type', 'status', 'cooling_capacity', 'coolingfeed_count',
+        )
+
+
+#
+# Cooling feeds
+#
+
+class CoolingFeedTable(TenancyColumnsMixin, PrimaryModelTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True
+    )
+    cooling_source = tables.Column(
+        verbose_name=_('Cooling Source'),
+        linkify=True
+    )
+    rack = tables.Column(
+        verbose_name=_('Rack'),
+        linkify=True
+    )
+    status = columns.ChoiceFieldColumn(
+        verbose_name=_('Status'),
+    )
+    cooling_capacity = tables.Column(
+        verbose_name=_('Cooling Capacity (kW)')
+    )
+    max_flow = columns.TemplateColumn(
+        verbose_name=_('Max Flow'),
+        template_code=MAX_FLOW,
+        order_by=('_abs_max_flow',),
+    )
+    tenant = tables.Column(
+        linkify=True,
+        verbose_name=_('Tenant')
+    )
+    site = tables.Column(
+        accessor='rack__site',
+        linkify=True,
+        verbose_name=_('Site'),
+    )
+    tags = columns.TagColumn(
+        url_name='dcim:coolingfeed_list'
+    )
+
+    class Meta(PrimaryModelTable.Meta):
+        model = CoolingFeed
+        fields = (
+            'pk', 'id', 'name', 'cooling_source', 'site', 'rack', 'status', 'cooling_capacity', 'max_flow', 'tenant',
+            'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'cooling_source', 'rack', 'status', 'cooling_capacity',
+            'max_flow',
+        )
+
+
+#
+# Cooling ports
+#
+
+class CoolingIntakeTable(ModularDeviceComponentTable):
+    device = tables.Column(
+        verbose_name=_('Device'),
+        linkify={
+            'viewname': 'dcim:device_coolingintakes',
+            'args': [Accessor('device_id')],
+        }
+    )
+    type = columns.ChoiceFieldColumn(
+        verbose_name=_('Type'),
+    )
+    diameter = columns.TemplateColumn(
+        verbose_name=_('Diameter'),
+        template_code=DIAMETER,
+        order_by=('_abs_diameter', 'diameter_unit')
+    )
+    max_flow = columns.TemplateColumn(
+        verbose_name=_('Max Flow'),
+        template_code=MAX_FLOW,
+        order_by=('_abs_max_flow',),
+    )
+    cooling_outflow = tables.Column(
+        verbose_name=_('Cooling Outflow'),
+        linkify=True
+    )
+    tags = columns.TagColumn(
+        url_name='dcim:coolingintake_list'
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = models.CoolingIntake
+        fields = (
+            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'diameter', 'description',
+            'max_flow', 'cooling_outflow', 'inventory_items', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'device', 'label', 'type', 'diameter', 'max_flow',
+            'description',
+        )
+
+
+#
+# Cooling outlets
+#
+
+class CoolingOutflowTable(ModularDeviceComponentTable):
+    device = tables.Column(
+        verbose_name=_('Device'),
+        linkify={
+            'viewname': 'dcim:device_coolingoutflows',
+            'args': [Accessor('device_id')],
+        }
+    )
+    type = columns.ChoiceFieldColumn(
+        verbose_name=_('Type'),
+    )
+    diameter = columns.TemplateColumn(
+        verbose_name=_('Diameter'),
+        template_code=DIAMETER,
+        order_by=('_abs_diameter', 'diameter_unit')
+    )
+    cooling_intake = tables.Column(
+        verbose_name=_('Cooling Intake'),
+        linkify=True
+    )
+    tags = columns.TagColumn(
+        url_name='dcim:coolingoutflow_list'
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = models.CoolingOutflow
+        fields = (
+            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'diameter',
+            'description', 'cooling_intake', 'inventory_items', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'device', 'label', 'type', 'diameter', 'cooling_intake',
+            'description',
+        )
+
+
+#
+# Cooling port templates
+#
+
+class CoolingIntakeTemplateTable(ComponentTemplateTable):
+    diameter = columns.TemplateColumn(
+        verbose_name=_('Diameter'),
+        template_code=DIAMETER,
+        order_by=('_abs_diameter', 'diameter_unit')
+    )
+    max_flow = columns.TemplateColumn(
+        verbose_name=_('Max Flow'),
+        template_code=MAX_FLOW,
+        order_by=('_abs_max_flow',),
+    )
+    actions = columns.ActionsColumn(
+        actions=('edit', 'delete'),
+        extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
+    )
+
+    class Meta(ComponentTemplateTable.Meta):
+        model = models.CoolingIntakeTemplate
+        fields = (
+            'pk', 'name', 'label', 'type', 'diameter', 'max_flow', 'description', 'actions',
+        )
+        empty_text = "None"
+
+
+#
+# Cooling outlet templates
+#
+
+class CoolingOutflowTemplateTable(ComponentTemplateTable):
+    diameter = columns.TemplateColumn(
+        verbose_name=_('Diameter'),
+        template_code=DIAMETER,
+        order_by=('_abs_diameter', 'diameter_unit')
+    )
+    actions = columns.ActionsColumn(
+        actions=('edit', 'delete'),
+        extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
+    )
+
+    class Meta(ComponentTemplateTable.Meta):
+        model = models.CoolingOutflowTemplate
+        fields = (
+            'pk', 'name', 'label', 'type', 'diameter', 'cooling_intake', 'description',
+            'actions',
+        )
+        empty_text = "None"
+
+
+#
+# Device cooling components
+#
+
+class DeviceCoolingIntakeTable(CoolingIntakeTable):
+    name = tables.TemplateColumn(
+        verbose_name=_('Name'),
+        template_code='<i class="mdi mdi-snowflake"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
+        attrs={'td': {'class': 'text-nowrap'}}
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = models.CoolingIntake
+        fields = (
+            'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'diameter', 'max_flow',
+            'description', 'cooling_outflow', 'tags', 'actions',
+        )
+        default_columns = (
+            'pk', 'name', 'label', 'type', 'diameter', 'max_flow',
+            'description',
+        )
+
+
+class DeviceCoolingOutflowTable(CoolingOutflowTable):
+    name = tables.TemplateColumn(
+        verbose_name=_('Name'),
+        template_code='<i class="mdi mdi-snowflake"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
+        attrs={'td': {'class': 'text-nowrap'}}
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = models.CoolingOutflow
+        fields = (
+            'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'diameter',
+            'cooling_intake', 'description', 'tags', 'actions',
+        )
+        default_columns = (
+            'pk', 'name', 'label', 'type', 'diameter', 'cooling_intake', 'description',
+        )

+ 11 - 1
netbox/dcim/tables/devices.py

@@ -254,6 +254,12 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
     power_outlet_count = tables.Column(
         verbose_name=_('Power outlets')
     )
+    cooling_intake_count = tables.Column(
+        verbose_name=_('Cooling intakes')
+    )
+    cooling_outflow_count = tables.Column(
+        verbose_name=_('Cooling outflows')
+    )
     interface_count = tables.Column(
         verbose_name=_('Interfaces')
     )
@@ -272,13 +278,17 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
     inventory_item_count = tables.Column(
         verbose_name=_('Inventory items')
     )
+    cooling_method = columns.ChoiceFieldColumn(
+        verbose_name=_('Cooling Method'),
+    )
 
     class Meta(PrimaryModelTable.Meta):
         model = models.Device
         fields = (
             'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'role', 'manufacturer', 'device_type',
             'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
-            'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
+            'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'cooling_method',
+            'primary_ip', 'primary_ip4',
             'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
             'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
         )

+ 11 - 1
netbox/dcim/tables/devicetypes.py

@@ -128,6 +128,12 @@ class DeviceTypeTable(PrimaryModelTable):
     power_outlet_template_count = tables.Column(
         verbose_name=_('Power Outlets')
     )
+    cooling_intake_template_count = tables.Column(
+        verbose_name=_('Cooling Intakes')
+    )
+    cooling_outflow_template_count = tables.Column(
+        verbose_name=_('Cooling Outflows')
+    )
     interface_template_count = tables.Column(
         verbose_name=_('Interfaces')
     )
@@ -146,12 +152,16 @@ class DeviceTypeTable(PrimaryModelTable):
     inventory_item_template_count = tables.Column(
         verbose_name=_('Inventory Items')
     )
+    cooling_method = columns.ChoiceFieldColumn(
+        verbose_name=_('Cooling Method'),
+    )
 
     class Meta(PrimaryModelTable.Meta):
         model = models.DeviceType
         fields = (
             'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
-            'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'end_of_life',
+            'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'cooling_method', 'weight',
+            'end_of_life',
             'description', 'comments', 'device_count', 'tags', 'created', 'last_updated',
         )
         default_columns = (

+ 5 - 2
netbox/dcim/tables/modules.py

@@ -83,6 +83,9 @@ class ModuleTypeTable(PrimaryModelTable):
         linkify=True,
         verbose_name=_('Module Type')
     )
+    cooling_method = columns.ChoiceFieldColumn(
+        verbose_name=_('Cooling Method'),
+    )
     weight = columns.TemplateColumn(
         verbose_name=_('Weight'),
         template_code=WEIGHT,
@@ -103,8 +106,8 @@ class ModuleTypeTable(PrimaryModelTable):
     class Meta(PrimaryModelTable.Meta):
         model = ModuleType
         fields = (
-            'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'end_of_life',
-            'module_bay_types',
+            'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'cooling_method', 'weight',
+            'end_of_life', 'module_bay_types',
             'description', 'attributes', 'module_count', 'comments', 'tags', 'created', 'last_updated',
         )
         default_columns = (

+ 16 - 3
netbox/dcim/tables/racks.py

@@ -106,6 +106,12 @@ class RackTypeTable(PrimaryModelTable):
         url_params={'rack_type_id': 'pk'},
         verbose_name=_('Rack Count'),
     )
+    cooling_capability = columns.ChoiceFieldColumn(
+        verbose_name=_('Cooling Capability'),
+    )
+    cooling_capacity = tables.Column(
+        verbose_name=_('Cooling Capacity (kW)')
+    )
     tags = columns.TagColumn(
         url_name='dcim:rack_list'
     )
@@ -114,8 +120,8 @@ class RackTypeTable(PrimaryModelTable):
         model = RackType
         fields = (
             'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
-            'outer_height', 'outer_depth', 'mounting_depth', 'weight', 'max_weight', 'description', 'comments',
-            'rack_count', 'tags', 'created', 'last_updated',
+            'outer_height', 'outer_depth', 'mounting_depth', 'weight', 'max_weight', 'cooling_capability',
+            'cooling_capacity', 'description', 'comments', 'rack_count', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'rack_count',
@@ -196,13 +202,20 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
         template_code=WEIGHT,
         order_by=('_abs_max_weight', 'weight_unit')
     )
+    cooling_capability = columns.ChoiceFieldColumn(
+        verbose_name=_('Cooling Capability'),
+    )
+    cooling_capacity = tables.Column(
+        verbose_name=_('Cooling Capacity (kW)')
+    )
 
     class Meta(PrimaryModelTable.Meta):
         model = Rack
         fields = (
             'pk', 'id', 'name', 'site', 'location', 'group', 'status', 'facility_id', 'tenant', 'tenant_group', 'role',
             'rack_type', 'serial', 'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
-            'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments',
+            'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'cooling_capability',
+            'cooling_capacity', 'weight', 'max_weight', 'comments',
             'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts',
             'tags', 'created', 'last_updated',
         )

+ 10 - 0
netbox/dcim/tables/template_code.py

@@ -52,6 +52,16 @@ WEIGHT = """
 {% display_weight record.weight record.weight_unit record.abs_weight %}
 """
 
+DIAMETER = """
+{% load helpers %}
+{% display_diameter record.diameter record.diameter_unit record.abs_diameter %}
+"""
+
+MAX_FLOW = """
+{% load helpers %}
+{% display_flow_rate record.max_flow record.max_flow_unit record.abs_max_flow %}
+"""
+
 DEVICE_LINK = """
 {{ record.label|default:'<span class="badge text-bg-info">Unnamed device</span>' }}
 """

+ 10 - 0
netbox/dcim/tests/query_counts.json

@@ -11,6 +11,16 @@
   "consoleserverport:api_list_objects": 14,
   "consoleserverport:list_objects_with_permission": 21,
   "consoleserverporttemplate:api_list_objects": 11,
+  "coolingfeed:api_list_objects": 15,
+  "coolingfeed:list_objects_with_permission": 22,
+  "coolingintake:api_list_objects": 14,
+  "coolingintake:list_objects_with_permission": 21,
+  "coolingintaketemplate:api_list_objects": 11,
+  "coolingoutflow:api_list_objects": 14,
+  "coolingoutflow:list_objects_with_permission": 22,
+  "coolingoutflowtemplate:api_list_objects": 11,
+  "coolingsource:api_list_objects": 15,
+  "coolingsource:list_objects_with_permission": 22,
   "device:api_list_objects": 20,
   "device:list_objects_with_permission": 25,
   "devicebay:api_list_objects": 14,

+ 325 - 0
netbox/dcim/tests/test_api.py

@@ -679,6 +679,8 @@ class RackTypeTestCase(APIViewTestCases.APIViewTestCase):
     brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'rack_count', 'slug', 'url']
     bulk_update_data = {
         'description': 'new description',
+        'cooling_capability': RackCoolingCapabilityChoices.CAPABILITY_HYBRID,
+        'cooling_capacity': 50,
     }
     user_permissions = ('dcim.view_manufacturer',)
 
@@ -718,12 +720,15 @@ class RackTypeTestCase(APIViewTestCases.APIViewTestCase):
                 'model': 'Rack Type 4',
                 'slug': 'rack-type-4',
                 'form_factor': RackFormFactorChoices.TYPE_CABINET,
+                'cooling_capability': RackCoolingCapabilityChoices.CAPABILITY_LIQUID_ONLY,
+                'cooling_capacity': 80,
             },
             {
                 'manufacturer': manufacturers[1].pk,
                 'model': 'Rack Type 5',
                 'slug': 'rack-type-5',
                 'form_factor': RackFormFactorChoices.TYPE_CABINET,
+                'cooling_capability': RackCoolingCapabilityChoices.CAPABILITY_HYBRID,
             },
             {
                 'manufacturer': manufacturers[1].pk,
@@ -4238,6 +4243,326 @@ class PowerFeedTestCase(APIViewTestCases.APIViewTestCase):
         ]
 
 
+class CoolingIntakeTemplateTestCase(APIViewTestCases.APIViewTestCase):
+    model = CoolingIntakeTemplate
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        devicetype = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+        )
+        moduletype = ModuleType.objects.create(
+            manufacturer=manufacturer, model='Module Type 1'
+        )
+
+        cooling_intake_templates = (
+            CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 1'),
+            CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 2'),
+            CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 3'),
+        )
+        CoolingIntakeTemplate.objects.bulk_create(cooling_intake_templates)
+
+        cls.create_data = [
+            {
+                'device_type': devicetype.pk,
+                'name': 'Cooling Port Template 4',
+                'type': CoolingConnectorTypeChoices.TYPE_UQD,
+            },
+            {
+                'device_type': devicetype.pk,
+                'name': 'Cooling Port Template 5',
+            },
+            {
+                'module_type': moduletype.pk,
+                'name': 'Cooling Port Template 6',
+            },
+            {
+                'module_type': moduletype.pk,
+                'name': 'Cooling Port Template 7',
+            },
+        ]
+
+
+class CoolingOutflowTemplateTestCase(APIViewTestCases.APIViewTestCase):
+    model = CoolingOutflowTemplate
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+    user_permissions = ('dcim.view_devicetype', )
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        devicetype = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+        )
+        moduletype = ModuleType.objects.create(
+            manufacturer=manufacturer, model='Module Type 1'
+        )
+
+        cooling_intake_templates = (
+            CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 1'),
+            CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 2'),
+        )
+        CoolingIntakeTemplate.objects.bulk_create(cooling_intake_templates)
+
+        cooling_outflow_templates = (
+            CoolingOutflowTemplate(device_type=devicetype, name='Cooling Outlet Template 1'),
+            CoolingOutflowTemplate(device_type=devicetype, name='Cooling Outlet Template 2'),
+            CoolingOutflowTemplate(device_type=devicetype, name='Cooling Outlet Template 3'),
+        )
+        CoolingOutflowTemplate.objects.bulk_create(cooling_outflow_templates)
+
+        cls.create_data = [
+            {
+                'device_type': devicetype.pk,
+                'name': 'Cooling Outlet Template 4',
+                'type': CoolingConnectorTypeChoices.TYPE_UQD,
+                'cooling_intake': cooling_intake_templates[0].pk,
+            },
+            {
+                'device_type': devicetype.pk,
+                'name': 'Cooling Outlet Template 5',
+                'cooling_intake': cooling_intake_templates[1].pk,
+            },
+            {
+                'device_type': devicetype.pk,
+                'name': 'Cooling Outlet Template 6',
+                'cooling_intake': None,
+            },
+            {
+                'module_type': moduletype.pk,
+                'name': 'Cooling Outlet Template 7',
+            },
+            {
+                'module_type': moduletype.pk,
+                'name': 'Cooling Outlet Template 8',
+            },
+        ]
+
+
+class CoolingIntakeTestCase(APIViewTestCases.APIViewTestCase):
+    model = CoolingIntake
+    brief_fields = ['description', 'device', 'display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+    user_permissions = ('dcim.view_device', )
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
+        device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
+
+        cooling_outflow = CoolingOutflow.objects.create(device=device, name='Cooling Outlet 1')
+
+        cooling_intakes = (
+            CoolingIntake(device=device, name='Cooling Port 1'),
+            CoolingIntake(device=device, name='Cooling Port 2'),
+            CoolingIntake(device=device, name='Cooling Port 3'),
+        )
+        CoolingIntake.objects.bulk_create(cooling_intakes)
+
+        cls.create_data = [
+            {
+                'device': device.pk,
+                'name': 'Cooling Port 4',
+                'type': CoolingConnectorTypeChoices.TYPE_UQD,
+                'cooling_outflow': cooling_outflow.pk,
+            },
+            {
+                'device': device.pk,
+                'name': 'Cooling Port 5',
+                'type': CoolingConnectorTypeChoices.TYPE_QDC,
+            },
+            {
+                'device': device.pk,
+                'name': 'Cooling Port 6',
+            },
+        ]
+
+
+class CoolingOutflowTestCase(APIViewTestCases.APIViewTestCase):
+    model = CoolingOutflow
+    brief_fields = ['description', 'device', 'display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+    user_permissions = ('dcim.view_device', )
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
+        device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
+
+        cooling_intakes = (
+            CoolingIntake(device=device, name='Cooling Port 1'),
+            CoolingIntake(device=device, name='Cooling Port 2'),
+        )
+        CoolingIntake.objects.bulk_create(cooling_intakes)
+
+        cooling_outflows = (
+            CoolingOutflow(device=device, name='Cooling Outlet 1'),
+            CoolingOutflow(device=device, name='Cooling Outlet 2'),
+            CoolingOutflow(device=device, name='Cooling Outlet 3'),
+        )
+        CoolingOutflow.objects.bulk_create(cooling_outflows)
+
+        cls.create_data = [
+            {
+                'device': device.pk,
+                'name': 'Cooling Outlet 4',
+                'type': CoolingConnectorTypeChoices.TYPE_UQD,
+                'cooling_intake': cooling_intakes[0].pk,
+            },
+            {
+                'device': device.pk,
+                'name': 'Cooling Outlet 5',
+                'cooling_intake': cooling_intakes[1].pk,
+            },
+            {
+                'device': device.pk,
+                'name': 'Cooling Outlet 6',
+                'cooling_intake': None,
+            },
+        ]
+
+
+class CoolingSourceTestCase(APIViewTestCases.APIViewTestCase):
+    model = CoolingSource
+    brief_fields = ['coolingfeed_count', 'description', 'display', 'id', 'name', 'url']
+    user_permissions = ('dcim.view_site', )
+
+    @classmethod
+    def setUpTestData(cls):
+        sites = (
+            Site.objects.create(name='Site 1', slug='site-1'),
+            Site.objects.create(name='Site 2', slug='site-2'),
+        )
+
+        locations = (
+            Location.objects.create(name='Location 1', slug='location-1', site=sites[0]),
+            Location.objects.create(name='Location 2', slug='location-2', site=sites[0]),
+            Location.objects.create(name='Location 3', slug='location-3', site=sites[0]),
+            Location.objects.create(name='Location 4', slug='location-3', site=sites[1]),
+        )
+
+        cooling_sources = (
+            CoolingSource(
+                site=sites[0], location=locations[0], name='Cooling Source 1',
+                type=CoolingSourceTypeChoices.TYPE_CHILLER
+            ),
+            CoolingSource(
+                site=sites[0], location=locations[1], name='Cooling Source 2',
+                type=CoolingSourceTypeChoices.TYPE_COOLING_TOWER
+            ),
+            CoolingSource(
+                site=sites[0], location=locations[2], name='Cooling Source 3',
+                type=CoolingSourceTypeChoices.TYPE_DRY_COOLER
+            ),
+        )
+        CoolingSource.objects.bulk_create(cooling_sources)
+
+        cls.create_data = [
+            {
+                'name': 'Cooling Source 4',
+                'site': sites[0].pk,
+                'location': locations[0].pk,
+                'type': CoolingSourceTypeChoices.TYPE_CHILLER,
+            },
+            {
+                'name': 'Cooling Source 5',
+                'site': sites[0].pk,
+                'location': locations[1].pk,
+                'type': CoolingSourceTypeChoices.TYPE_CHILLER,
+            },
+            {
+                'name': 'Cooling Source 6',
+                'site': sites[0].pk,
+                'location': locations[2].pk,
+                'type': CoolingSourceTypeChoices.TYPE_CHILLER,
+            },
+        ]
+
+        cls.bulk_update_data = {
+            'site': sites[1].pk,
+            'location': locations[3].pk
+        }
+
+
+class CoolingFeedTestCase(APIViewTestCases.APIViewTestCase):
+    model = CoolingFeed
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'status': 'planned',
+    }
+    user_permissions = ('dcim.view_coolingsource', )
+
+    @classmethod
+    def setUpTestData(cls):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        location = Location.objects.create(site=site, name='Location 1', slug='location-1')
+        rackrole = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000')
+
+        racks = (
+            Rack(site=site, location=location, role=rackrole, name='Rack 1'),
+            Rack(site=site, location=location, role=rackrole, name='Rack 2'),
+            Rack(site=site, location=location, role=rackrole, name='Rack 3'),
+            Rack(site=site, location=location, role=rackrole, name='Rack 4'),
+        )
+        Rack.objects.bulk_create(racks)
+
+        cooling_sources = (
+            CoolingSource(
+                site=site, location=location, name='Cooling Source 1', type=CoolingSourceTypeChoices.TYPE_CHILLER
+            ),
+            CoolingSource(
+                site=site, location=location, name='Cooling Source 2', type=CoolingSourceTypeChoices.TYPE_CHILLER
+            ),
+        )
+        CoolingSource.objects.bulk_create(cooling_sources)
+
+        cooling_feeds = (
+            CoolingFeed(cooling_source=cooling_sources[0], rack=racks[0], name='Cooling Feed 1A'),
+            CoolingFeed(cooling_source=cooling_sources[1], rack=racks[0], name='Cooling Feed 1B'),
+            CoolingFeed(cooling_source=cooling_sources[0], rack=racks[1], name='Cooling Feed 2A'),
+            CoolingFeed(cooling_source=cooling_sources[1], rack=racks[1], name='Cooling Feed 2B'),
+            CoolingFeed(cooling_source=cooling_sources[0], rack=racks[2], name='Cooling Feed 3A'),
+            CoolingFeed(cooling_source=cooling_sources[1], rack=racks[2], name='Cooling Feed 3B'),
+        )
+        CoolingFeed.objects.bulk_create(cooling_feeds)
+
+        cls.create_data = [
+            {
+                'name': 'Cooling Feed 4A',
+                'cooling_source': cooling_sources[0].pk,
+                'rack': racks[3].pk,
+            },
+            {
+                'name': 'Cooling Feed 4B',
+                'cooling_source': cooling_sources[1].pk,
+                'rack': racks[3].pk,
+            },
+            {
+                'name': 'Cooling Feed 4C',
+                'cooling_source': cooling_sources[0].pk,
+                'rack': racks[3].pk,
+            },
+        ]
+
+
 class VirtualDeviceContextTestCase(APIViewTestCases.APIViewTestCase):
     model = VirtualDeviceContext
     brief_fields = ['description', 'device', 'display', 'id', 'identifier', 'name', 'url']

Fișier diff suprimat deoarece este prea mare
+ 835 - 173
netbox/dcim/tests/test_filtersets.py


+ 494 - 2
netbox/dcim/tests/test_models.py

@@ -12,7 +12,7 @@ from dcim.models import *
 from extras.events import serialize_for_event
 from extras.models import CustomField
 from ipam.models import Prefix
-from netbox.choices import WeightUnitChoices
+from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices, WeightUnitChoices
 from tenancy.models import Tenant
 from utilities.data import drange
 from virtualization.models import Cluster, ClusterType
@@ -243,6 +243,8 @@ class RackTypeTestCase(TestCase):
             weight_unit=WeightUnitChoices.UNIT_POUND,
             max_weight=7777,
             mounting_depth=8,
+            cooling_capability=RackCoolingCapabilityChoices.CAPABILITY_LIQUID_ONLY,
+            cooling_capacity=80,
         )
 
     def test_rack_creation(self):
@@ -262,7 +264,7 @@ class RackTypeTestCase(TestCase):
             facility_id='A101',
             site=sites[0],
             location=locations[0],
-            rack_type=rack_type
+            rack_type=rack_type,
         )
         self.assertEqual(rack.width, rack_type.width)
         self.assertEqual(rack.u_height, rack_type.u_height)
@@ -275,6 +277,9 @@ class RackTypeTestCase(TestCase):
         self.assertEqual(rack.weight_unit, rack_type.weight_unit)
         self.assertEqual(rack.max_weight, rack_type.max_weight)
         self.assertEqual(rack.mounting_depth, rack_type.mounting_depth)
+        # Cooling capability/capacity are inherited from the rack type
+        self.assertEqual(rack.cooling_capability, rack_type.cooling_capability)
+        self.assertEqual(rack.cooling_capacity, rack_type.cooling_capacity)
 
 
 class RackTestCase(TestCase):
@@ -3173,3 +3178,490 @@ class InventoryItemTemplateCycleTestCase(TestCase):
         a.parent = a
         with self.assertRaises(ValidationError):
             a.full_clean()
+
+
+class CoolingComponentTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site = Site.objects.create(name='Site 1', slug='site-1')
+        cls.manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        cls.role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+    def test_cooling_method_inherited_from_device_type(self):
+        """
+        A new Device should inherit its cooling_method from the DeviceType when not explicitly set.
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer,
+            model='Device Type 1',
+            slug='device-type-1',
+            cooling_method=CoolingMethodChoices.METHOD_LIQUID
+        )
+        device = Device.objects.create(
+            site=self.site,
+            device_type=device_type,
+            role=self.role,
+            name='Device 1'
+        )
+        self.assertEqual(device.cooling_method, CoolingMethodChoices.METHOD_LIQUID)
+
+    def test_cooling_method_not_overridden_when_set(self):
+        """
+        A new Device with an explicitly-set cooling_method should not be overridden by the DeviceType.
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer,
+            model='Device Type 2',
+            slug='device-type-2',
+            cooling_method=CoolingMethodChoices.METHOD_LIQUID
+        )
+        device = Device.objects.create(
+            site=self.site,
+            device_type=device_type,
+            role=self.role,
+            name='Device 2',
+            cooling_method=CoolingMethodChoices.METHOD_AIR
+        )
+        self.assertEqual(device.cooling_method, CoolingMethodChoices.METHOD_AIR)
+
+    def test_device_creation_instantiates_cooling_components(self):
+        """
+        Creating a Device from a DeviceType with cooling component templates should auto-instantiate
+        matching CoolingIntake and CoolingOutflow components.
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer,
+            model='Device Type 3',
+            slug='device-type-3'
+        )
+
+        cooling_intake_template = CoolingIntakeTemplate.objects.create(
+            device_type=device_type,
+            name='Cooling Port 1',
+            type=CoolingConnectorTypeChoices.TYPE_UQD,
+            diameter=Decimal('25'),
+            diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
+            max_flow=100,
+            max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE
+        )
+        CoolingOutflowTemplate.objects.create(
+            device_type=device_type,
+            name='Cooling Outlet 1',
+            type=CoolingConnectorTypeChoices.TYPE_UQD,
+            diameter=Decimal('25'),
+            diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER
+        )
+
+        device = Device.objects.create(
+            site=self.site,
+            device_type=device_type,
+            role=self.role,
+            name='Device 3'
+        )
+
+        cooling_intake = CoolingIntake.objects.get(
+            device=device,
+            name='Cooling Port 1',
+            type=CoolingConnectorTypeChoices.TYPE_UQD,
+            diameter=Decimal('25'),
+            diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
+            max_flow=100,
+            max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE
+        )
+        self.assertEqual(cooling_intake_template.max_flow, cooling_intake.max_flow)
+
+        CoolingOutflow.objects.get(
+            device=device,
+            name='Cooling Outlet 1',
+            type=CoolingConnectorTypeChoices.TYPE_UQD,
+            diameter=Decimal('25'),
+            diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER
+        )
+
+    def test_cooling_choice_colors_resolve(self):
+        """
+        ChoiceFieldColumn and ChoiceAttr both render a badge color by calling get_FOO_color() on the
+        instance, so every model exposing a colored cooling choice must implement the accessor.
+        """
+        for model in (Device, DeviceType, ModuleType):
+            with self.subTest(model=model.__name__):
+                instance = model(cooling_method=CoolingMethodChoices.METHOD_LIQUID)
+                self.assertEqual(
+                    instance.get_cooling_method_color(),
+                    CoolingMethodChoices.colors[CoolingMethodChoices.METHOD_LIQUID]
+                )
+                # An unset value has no color, which the consumers fall back on
+                self.assertIsNone(model().get_cooling_method_color())
+
+        for model in (Rack, RackType):
+            with self.subTest(model=model.__name__):
+                instance = model(cooling_capability=RackCoolingCapabilityChoices.CAPABILITY_HYBRID)
+                self.assertEqual(
+                    instance.get_cooling_capability_color(),
+                    RackCoolingCapabilityChoices.colors[RackCoolingCapabilityChoices.CAPABILITY_HYBRID]
+                )
+                self.assertIsNone(model().get_cooling_capability_color())
+
+    def test_measurements_below_minimum_rejected(self):
+        """
+        A populated diameter or flow rate must be positive and non-zero: anything below the smallest storable
+        value (0.01) should raise a ValidationError (via MinValueValidator) rather than a raw ValueError from
+        the unit conversion in save().
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer, model='Device Type 8', slug='device-type-8'
+        )
+        device = Device.objects.create(
+            site=self.site, device_type=device_type, role=self.role, name='Device F'
+        )
+        cooling_source = CoolingSource.objects.create(
+            site=self.site, name='Cooling Source F', type=CoolingSourceTypeChoices.TYPE_CHILLER
+        )
+
+        for value in (Decimal('-5'), Decimal('0')):
+            with self.subTest(diameter=value):
+                cooling_intake = CoolingIntake(
+                    device=device,
+                    name='Cooling Port 1',
+                    diameter=value,
+                    diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
+                )
+                with self.assertRaises(ValidationError):
+                    cooling_intake.full_clean()
+
+            with self.subTest(max_flow=value):
+                cooling_intake = CoolingIntake(
+                    device=device,
+                    name='Cooling Port 1',
+                    max_flow=value,
+                    max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
+                )
+                with self.assertRaises(ValidationError):
+                    cooling_intake.full_clean()
+
+            with self.subTest(feed_max_flow=value):
+                cooling_feed = CoolingFeed(
+                    cooling_source=cooling_source,
+                    name='Cooling Feed F',
+                    max_flow=value,
+                    max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
+                )
+                with self.assertRaises(ValidationError):
+                    cooling_feed.full_clean()
+
+        # The minimum itself is permitted
+        CoolingIntake(
+            device=device,
+            name='Cooling Port 1',
+            diameter=Decimal('0.01'),
+            diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER,
+            max_flow=Decimal('0.01'),
+            max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
+        ).full_clean()
+
+        # Sub-unit values are permitted: fractional-inch fittings (e.g. 1/2" NPT) and cold plates rated below
+        # one gallon per minute are both commonplace.
+        CoolingIntake(
+            device=device,
+            name='Cooling Port 1',
+            diameter=Decimal('0.5'),
+            diameter_unit=DiameterUnitChoices.UNIT_INCH,
+            max_flow=Decimal('0.75'),
+            max_flow_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE,
+        ).full_clean()
+
+        CoolingFeed(
+            cooling_source=cooling_source,
+            name='Cooling Feed F',
+            max_flow=Decimal('0.5'),
+            max_flow_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE,
+        ).full_clean()
+
+    def test_parent_intake_resolved_on_device_instantiation(self):
+        """
+        A CoolingOutflowTemplate with a parent CoolingIntakeTemplate should resolve to the newly created
+        CoolingIntake on the same device. This depends on intakes being instantiated before outflows.
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer, model='Device Type 12', slug='device-type-12'
+        )
+        cooling_intake_template = CoolingIntakeTemplate.objects.create(
+            device_type=device_type, name='Cooling Port 1'
+        )
+        CoolingOutflowTemplate.objects.create(
+            device_type=device_type, name='Cooling Outlet 1', cooling_intake=cooling_intake_template
+        )
+        # A second outflow with no parent must remain unassigned
+        CoolingOutflowTemplate.objects.create(device_type=device_type, name='Cooling Outlet 2')
+
+        device = Device.objects.create(
+            site=self.site, device_type=device_type, role=self.role, name='Device J'
+        )
+
+        cooling_intake = CoolingIntake.objects.get(device=device, name='Cooling Port 1')
+        self.assertEqual(
+            CoolingOutflow.objects.get(device=device, name='Cooling Outlet 1').cooling_intake,
+            cooling_intake
+        )
+        self.assertIsNone(CoolingOutflow.objects.get(device=device, name='Cooling Outlet 2').cooling_intake)
+
+    def test_parent_intake_resolved_on_module_instantiation(self):
+        """
+        The parent intake of a CoolingOutflowTemplate should likewise resolve when the components are
+        instantiated for a Module rather than a Device.
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer, model='Device Type 13', slug='device-type-13'
+        )
+        device = Device.objects.create(
+            site=self.site, device_type=device_type, role=self.role, name='Device K'
+        )
+        module_bay = ModuleBay.objects.create(device=device, name='Module Bay 1')
+
+        module_type = ModuleType.objects.create(manufacturer=self.manufacturer, model='Module Type 1')
+        cooling_intake_template = CoolingIntakeTemplate.objects.create(
+            module_type=module_type, name='Cooling Port 1'
+        )
+        CoolingOutflowTemplate.objects.create(
+            module_type=module_type, name='Cooling Outlet 1', cooling_intake=cooling_intake_template
+        )
+
+        module = Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
+
+        cooling_intake = CoolingIntake.objects.get(module=module, name='Cooling Port 1')
+        cooling_outflow = CoolingOutflow.objects.get(module=module, name='Cooling Outlet 1')
+        self.assertEqual(cooling_outflow.cooling_intake, cooling_intake)
+        self.assertEqual(cooling_outflow.device, device)
+
+    def test_measurements_normalized_on_save(self):
+        """
+        Saving a component should populate the normalized _abs_* columns, converting from the selected
+        unit to the canonical unit (millimeters for diameter, liters per minute for flow).
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer, model='Device Type 9', slug='device-type-9'
+        )
+        device = Device.objects.create(
+            site=self.site, device_type=device_type, role=self.role, name='Device G'
+        )
+        cooling_intake = CoolingIntake.objects.create(
+            device=device,
+            name='Cooling Port 1',
+            diameter=Decimal('1'),
+            diameter_unit=DiameterUnitChoices.UNIT_INCH,
+            max_flow=Decimal('10'),
+            max_flow_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE,
+        )
+        cooling_intake.refresh_from_db()
+        self.assertEqual(cooling_intake._abs_diameter, Decimal('25.4'))
+        self.assertEqual(cooling_intake._abs_max_flow, Decimal('37.8541'))
+        # The public aliases used by templates should mirror the underscore-prefixed columns
+        self.assertEqual(cooling_intake.abs_diameter, cooling_intake._abs_diameter)
+        self.assertEqual(cooling_intake.abs_max_flow, cooling_intake._abs_max_flow)
+
+        # Clearing a value should null both its unit and its normalized column
+        cooling_intake.diameter = None
+        cooling_intake.max_flow = None
+        cooling_intake.save()
+        cooling_intake.refresh_from_db()
+        self.assertIsNone(cooling_intake.diameter_unit)
+        self.assertIsNone(cooling_intake._abs_diameter)
+        self.assertIsNone(cooling_intake.max_flow_unit)
+        self.assertIsNone(cooling_intake._abs_max_flow)
+
+    def test_measurements_normalized_on_component_instantiation(self):
+        """
+        Components instantiated from templates are written via bulk_create, which bypasses save(); the
+        normalized _abs_* columns must still be populated.
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer, model='Device Type 10', slug='device-type-10'
+        )
+        CoolingIntakeTemplate.objects.create(
+            device_type=device_type,
+            name='Cooling Port 1',
+            diameter=Decimal('1'),
+            diameter_unit=DiameterUnitChoices.UNIT_INCH,
+            max_flow=Decimal('6'),
+            max_flow_unit=FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR,
+        )
+        CoolingOutflowTemplate.objects.create(
+            device_type=device_type,
+            name='Cooling Outlet 1',
+            diameter=Decimal('2.5'),
+            diameter_unit=DiameterUnitChoices.UNIT_CENTIMETER,
+        )
+
+        device = Device.objects.create(
+            site=self.site, device_type=device_type, role=self.role, name='Device H'
+        )
+
+        cooling_intake = CoolingIntake.objects.get(device=device, name='Cooling Port 1')
+        self.assertEqual(cooling_intake._abs_diameter, Decimal('25.4'))
+        self.assertEqual(cooling_intake._abs_max_flow, Decimal('100'))
+
+        cooling_outflow = CoolingOutflow.objects.get(device=device, name='Cooling Outlet 1')
+        self.assertEqual(cooling_outflow._abs_diameter, Decimal('25'))
+
+    def test_measurement_unit_required(self):
+        """
+        Setting a measurement without its accompanying unit should raise a ValidationError.
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer, model='Device Type 11', slug='device-type-11'
+        )
+        device = Device.objects.create(
+            site=self.site, device_type=device_type, role=self.role, name='Device I'
+        )
+
+        with self.assertRaises(ValidationError):
+            CoolingIntake(device=device, name='Cooling Port 1', diameter=Decimal('25')).full_clean()
+
+        with self.assertRaises(ValidationError):
+            CoolingIntake(device=device, name='Cooling Port 2', max_flow=Decimal('100')).full_clean()
+
+        with self.assertRaises(ValidationError):
+            CoolingOutflow(device=device, name='Cooling Outlet 1', diameter=Decimal('25')).full_clean()
+
+    def test_cooling_feed_flow_normalized(self):
+        """
+        CoolingFeed shares the flow-rate normalization applied to device components, and likewise requires
+        a unit whenever a flow rate is set.
+        """
+        cooling_source = CoolingSource.objects.create(
+            site=self.site,
+            name='Cooling Source 1',
+            type=CoolingSourceTypeChoices.TYPE_CHILLER,
+        )
+        cooling_feed = CoolingFeed.objects.create(
+            cooling_source=cooling_source,
+            name='Cooling Feed 1',
+            max_flow=Decimal('6'),
+            max_flow_unit=FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR,
+        )
+        cooling_feed.refresh_from_db()
+        self.assertEqual(cooling_feed._abs_max_flow, Decimal('100'))
+        self.assertEqual(cooling_feed.abs_max_flow, cooling_feed._abs_max_flow)
+
+        with self.assertRaises(ValidationError):
+            CoolingFeed(
+                cooling_source=cooling_source, name='Cooling Feed 2', max_flow=Decimal('100')
+            ).full_clean()
+
+    def test_cooling_outflow_clean_different_device(self):
+        """
+        CoolingOutflow.clean() should raise a ValidationError when its cooling_intake belongs to a
+        different device.
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer,
+            model='Device Type 4',
+            slug='device-type-4'
+        )
+        device1 = Device.objects.create(
+            site=self.site, device_type=device_type, role=self.role, name='Device A'
+        )
+        device2 = Device.objects.create(
+            site=self.site, device_type=device_type, role=self.role, name='Device B'
+        )
+
+        cooling_intake = CoolingIntake.objects.create(device=device1, name='Cooling Port 1')
+        cooling_outflow = CoolingOutflow(device=device2, name='Cooling Outlet 1', cooling_intake=cooling_intake)
+
+        with self.assertRaises(ValidationError):
+            cooling_outflow.full_clean()
+
+    def test_cooling_source_location_site_mismatch(self):
+        """
+        CoolingSource.clean() should raise a ValidationError when its location belongs to a different site.
+        """
+        site2 = Site.objects.create(name='Site 2', slug='site-2')
+        location = Location.objects.create(name='Location 1', slug='location-1', site=site2)
+        cooling_source = CoolingSource(
+            site=self.site,
+            location=location,
+            name='Cooling Source 1',
+            type=CoolingSourceTypeChoices.TYPE_CHILLER,
+            status=CoolingSourceStatusChoices.STATUS_ACTIVE,
+        )
+        with self.assertRaises(ValidationError):
+            cooling_source.full_clean()
+
+    def test_cooling_feed_rack_site_mismatch(self):
+        """
+        CoolingFeed.clean() should raise a ValidationError when its rack is in a different site than the
+        cooling source.
+        """
+        site2 = Site.objects.create(name='Site 3', slug='site-3')
+        cooling_source = CoolingSource.objects.create(
+            site=self.site,
+            name='Cooling Source 3',
+            type=CoolingSourceTypeChoices.TYPE_CHILLER,
+            status=CoolingSourceStatusChoices.STATUS_ACTIVE,
+        )
+        rack = Rack.objects.create(name='Rack 1', site=site2, status=RackStatusChoices.STATUS_ACTIVE)
+        cooling_feed = CoolingFeed(
+            cooling_source=cooling_source,
+            rack=rack,
+            name='Cooling Feed 1',
+            status=CoolingFeedStatusChoices.STATUS_ACTIVE,
+        )
+        with self.assertRaises(ValidationError):
+            cooling_feed.full_clean()
+
+    def test_cooling_chain_valid(self):
+        """
+        A non-looping intake/outflow assignment should pass validation.
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer, model='Device Type 5', slug='device-type-5'
+        )
+        device = Device.objects.create(
+            site=self.site, device_type=device_type, role=self.role, name='Device C'
+        )
+        cooling_outflow = CoolingOutflow.objects.create(device=device, name='Cooling Outlet 1')
+        cooling_intake = CoolingIntake(device=device, name='Cooling Port 1', cooling_outflow=cooling_outflow)
+
+        # Should not raise
+        cooling_intake.full_clean()
+
+    def test_cooling_intake_loop_rejected(self):
+        """
+        Closing the intake/outflow chain into a loop from the intake side should raise a ValidationError.
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer, model='Device Type 6', slug='device-type-6'
+        )
+        device = Device.objects.create(
+            site=self.site, device_type=device_type, role=self.role, name='Device D'
+        )
+        cooling_intake = CoolingIntake.objects.create(device=device, name='Cooling Port 1')
+        cooling_outflow = CoolingOutflow.objects.create(
+            device=device, name='Cooling Outlet 1', cooling_intake=cooling_intake
+        )
+
+        # The outflow is fed by the intake; supplying that same intake from the outflow closes the loop
+        cooling_intake.cooling_outflow = cooling_outflow
+        with self.assertRaises(ValidationError):
+            cooling_intake.full_clean()
+
+    def test_cooling_outflow_loop_rejected(self):
+        """
+        Closing the intake/outflow chain into a loop from the outflow side should raise a ValidationError.
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer, model='Device Type 7', slug='device-type-7'
+        )
+        device = Device.objects.create(
+            site=self.site, device_type=device_type, role=self.role, name='Device E'
+        )
+        cooling_outflow = CoolingOutflow.objects.create(device=device, name='Cooling Outlet 1')
+        cooling_intake = CoolingIntake.objects.create(
+            device=device, name='Cooling Port 1', cooling_outflow=cooling_outflow
+        )
+
+        # The intake is supplied by the outflow; feeding that same intake into the outflow closes the loop
+        cooling_outflow.cooling_intake = cooling_intake
+        with self.assertRaises(ValidationError):
+            cooling_outflow.full_clean()

+ 20 - 0
netbox/dcim/tests/test_tables.py

@@ -195,6 +195,26 @@ class PowerFeedTableTestCase(TableTestCases.StandardTableTestCase):
     table = PowerFeedTable
 
 
+#
+# Cooling
+#
+
+class CoolingSourceTableTestCase(TableTestCases.StandardTableTestCase):
+    table = CoolingSourceTable
+
+
+class CoolingFeedTableTestCase(TableTestCases.StandardTableTestCase):
+    table = CoolingFeedTable
+
+
+class CoolingIntakeTableTestCase(TableTestCases.StandardTableTestCase):
+    table = CoolingIntakeTable
+
+
+class CoolingOutflowTableTestCase(TableTestCases.StandardTableTestCase):
+    table = CoolingOutflowTable
+
+
 #
 # Virtual chassis
 #

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

@@ -19,7 +19,13 @@ 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, ImportFormatChoices, WeightUnitChoices
+from netbox.choices import (
+    CSVDelimiterChoices,
+    DiameterUnitChoices,
+    FlowRateUnitChoices,
+    ImportFormatChoices,
+    WeightUnitChoices,
+)
 from tenancy.models import Tenant
 from users.models import ObjectPermission, User
 from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
@@ -930,6 +936,36 @@ power-outlets:
     type: iec-60320-c13
     power_port: Power Port 1
     feed_leg: A
+cooling-intakes:
+  - name: Cooling Intake 1
+    type: uqd
+    diameter: 1
+    diameter_unit: in
+    max_flow: 100
+    max_flow_unit: lpm
+  - name: Cooling Intake 2
+    type: uqd
+    diameter: 1
+    diameter_unit: in
+  - name: Cooling Intake 3
+    type: uqd
+    diameter: 1
+    diameter_unit: in
+cooling-outflows:
+  - name: Cooling Outflow 1
+    type: uqdb
+    diameter: 25
+    diameter_unit: mm
+    cooling_intake: Cooling Intake 1
+  - name: Cooling Outflow 2
+    type: uqdb
+    diameter: 25
+    diameter_unit: mm
+    cooling_intake: Cooling Intake 1
+  - name: Cooling Outflow 3
+    type: uqdb
+    diameter: 25
+    diameter_unit: mm
 interfaces:
   - name: Interface 1
     type: 1000base-t
@@ -991,6 +1027,8 @@ inventory-items:
             'dcim.add_consoleserverporttemplate',
             'dcim.add_powerporttemplate',
             'dcim.add_poweroutlettemplate',
+            'dcim.add_coolingintaketemplate',
+            'dcim.add_coolingoutflowtemplate',
             'dcim.add_interfacetemplate',
             'dcim.add_frontporttemplate',
             'dcim.add_rearporttemplate',
@@ -1041,6 +1079,26 @@ inventory-items:
         self.assertEqual(po1.power_port, pp1)
         self.assertEqual(po1.feed_leg, PowerOutletFeedLegChoices.FEED_LEG_A)
 
+        self.assertEqual(device_type.coolingintaketemplates.count(), 3)
+        ci1 = CoolingIntakeTemplate.objects.first()
+        self.assertEqual(ci1.name, 'Cooling Intake 1')
+        self.assertEqual(ci1.type, CoolingConnectorTypeChoices.TYPE_UQD)
+        self.assertEqual(ci1.diameter, 1)
+        self.assertEqual(ci1.diameter_unit, DiameterUnitChoices.UNIT_INCH)
+        self.assertEqual(ci1.max_flow, 100)
+        self.assertEqual(ci1.max_flow_unit, FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE)
+        # The normalized columns are populated on import
+        self.assertEqual(ci1._abs_diameter, Decimal('25.4'))
+        self.assertEqual(ci1._abs_max_flow, Decimal('100'))
+
+        self.assertEqual(device_type.coolingoutflowtemplates.count(), 3)
+        co1 = CoolingOutflowTemplate.objects.first()
+        self.assertEqual(co1.name, 'Cooling Outflow 1')
+        self.assertEqual(co1.type, CoolingConnectorTypeChoices.TYPE_UQDB)
+        self.assertEqual(co1.diameter, 25)
+        self.assertEqual(co1.diameter_unit, DiameterUnitChoices.UNIT_MILLIMETER)
+        self.assertEqual(co1.cooling_intake, ci1)
+
         self.assertEqual(device_type.interfacetemplates.count(), 3)
         iface1 = InterfaceTemplate.objects.first()
         self.assertEqual(iface1.name, 'Interface 1')
@@ -1087,6 +1145,8 @@ inventory-items:
             'dcim.add_consoleserverporttemplate',
             'dcim.add_powerporttemplate',
             'dcim.add_poweroutlettemplate',
+            'dcim.add_coolingintaketemplate',
+            'dcim.add_coolingoutflowtemplate',
             'dcim.add_interfacetemplate',
             'dcim.add_frontporttemplate',
             'dcim.add_rearporttemplate',
@@ -1139,6 +1199,8 @@ module-bays:
             'dcim.add_consoleserverporttemplate',
             'dcim.add_powerporttemplate',
             'dcim.add_poweroutlettemplate',
+            'dcim.add_coolingintaketemplate',
+            'dcim.add_coolingoutflowtemplate',
             'dcim.add_interfacetemplate',
             'dcim.add_frontporttemplate',
             'dcim.add_rearporttemplate',
@@ -1175,6 +1237,8 @@ console-ports: {value}
             'dcim.add_consoleserverporttemplate',
             'dcim.add_powerporttemplate',
             'dcim.add_poweroutlettemplate',
+            'dcim.add_coolingintaketemplate',
+            'dcim.add_coolingoutflowtemplate',
             'dcim.add_interfacetemplate',
             'dcim.add_frontporttemplate',
             'dcim.add_rearporttemplate',
@@ -1299,6 +1363,8 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'dcim.add_consoleserverporttemplate',
             'dcim.add_powerporttemplate',
             'dcim.add_poweroutlettemplate',
+            'dcim.add_coolingintaketemplate',
+            'dcim.add_coolingoutflowtemplate',
             'dcim.add_interfacetemplate',
             'dcim.add_frontporttemplate',
             'dcim.add_rearporttemplate',
@@ -1315,6 +1381,8 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'dcim.add_consoleserverporttemplate',
             'dcim.add_powerporttemplate',
             'dcim.add_poweroutlettemplate',
+            'dcim.add_coolingintaketemplate',
+            'dcim.add_coolingoutflowtemplate',
             'dcim.add_interfacetemplate',
             'dcim.add_frontporttemplate',
             'dcim.add_rearporttemplate',
@@ -1331,6 +1399,8 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'dcim.add_consoleserverporttemplate',
             'dcim.add_powerporttemplate',
             'dcim.add_poweroutlettemplate',
+            'dcim.add_coolingintaketemplate',
+            'dcim.add_coolingoutflowtemplate',
             'dcim.add_interfacetemplate',
             'dcim.add_frontporttemplate',
             'dcim.add_rearporttemplate',
@@ -1352,6 +1422,8 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'dcim.add_consoleserverporttemplate',
             'dcim.add_powerporttemplate',
             'dcim.add_poweroutlettemplate',
+            'dcim.add_coolingintaketemplate',
+            'dcim.add_coolingoutflowtemplate',
             'dcim.add_interfacetemplate',
             'dcim.add_frontporttemplate',
             'dcim.add_rearporttemplate',
@@ -1524,6 +1596,26 @@ power-outlets:
     type: iec-60320-c13
     power_port: Power Port 1
     feed_leg: A
+cooling-intakes:
+  - name: Cooling Intake 1
+    type: uqd
+    diameter: 1
+    diameter_unit: in
+    max_flow: 6
+    max_flow_unit: m3ph
+  - name: Cooling Intake 2
+    type: uqd
+  - name: Cooling Intake 3
+    type: uqd
+cooling-outflows:
+  - name: Cooling Outflow 1
+    type: uqdb
+    cooling_intake: Cooling Intake 1
+  - name: Cooling Outflow 2
+    type: uqdb
+    cooling_intake: Cooling Intake 1
+  - name: Cooling Outflow 3
+    type: uqdb
 interfaces:
   - name: Interface 1
     type: 1000base-t
@@ -1574,6 +1666,8 @@ module-bays:
             'dcim.add_consoleserverporttemplate',
             'dcim.add_powerporttemplate',
             'dcim.add_poweroutlettemplate',
+            'dcim.add_coolingintaketemplate',
+            'dcim.add_coolingoutflowtemplate',
             'dcim.add_interfacetemplate',
             'dcim.add_frontporttemplate',
             'dcim.add_rearporttemplate',
@@ -1616,6 +1710,24 @@ module-bays:
         self.assertEqual(po1.power_port, pp1)
         self.assertEqual(po1.feed_leg, PowerOutletFeedLegChoices.FEED_LEG_A)
 
+        self.assertEqual(module_type.coolingintaketemplates.count(), 3)
+        ci1 = CoolingIntakeTemplate.objects.first()
+        self.assertEqual(ci1.name, 'Cooling Intake 1')
+        self.assertEqual(ci1.type, CoolingConnectorTypeChoices.TYPE_UQD)
+        self.assertEqual(ci1.diameter, 1)
+        self.assertEqual(ci1.diameter_unit, DiameterUnitChoices.UNIT_INCH)
+        self.assertEqual(ci1.max_flow, 6)
+        self.assertEqual(ci1.max_flow_unit, FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR)
+        # The normalized columns are populated on import
+        self.assertEqual(ci1._abs_diameter, Decimal('25.4'))
+        self.assertEqual(ci1._abs_max_flow, Decimal('100'))
+
+        self.assertEqual(module_type.coolingoutflowtemplates.count(), 3)
+        co1 = CoolingOutflowTemplate.objects.first()
+        self.assertEqual(co1.name, 'Cooling Outflow 1')
+        self.assertEqual(co1.type, CoolingConnectorTypeChoices.TYPE_UQDB)
+        self.assertEqual(co1.cooling_intake, ci1)
+
         self.assertEqual(module_type.interfacetemplates.count(), 3)
         iface1 = InterfaceTemplate.objects.first()
         self.assertEqual(iface1.name, 'Interface 1')
@@ -4660,6 +4772,358 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertHttpStatus(response, 200)
 
 
+class CoolingIntakeTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+    model = CoolingIntakeTemplate
+    validation_excluded_fields = ('name', 'label')
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+
+        CoolingIntakeTemplate.objects.bulk_create((
+            CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 1'),
+            CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 2'),
+            CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 3'),
+        ))
+
+        cls.form_data = {
+            'device_type': devicetype.pk,
+            'name': 'Cooling Port Template X',
+            'type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': Decimal('25'),
+            'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
+            'max_flow': 100,
+            'max_flow_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
+        }
+
+        cls.bulk_create_data = {
+            'device_type': devicetype.pk,
+            'name': 'Cooling Port Template [4-6]',
+            'type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': Decimal('25'),
+            'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
+            'max_flow': 100,
+            'max_flow_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
+        }
+
+        cls.bulk_edit_data = {
+            'type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': Decimal('25'),
+            'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
+            'max_flow': 100,
+            'max_flow_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
+        }
+
+
+class CoolingOutflowTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+    model = CoolingOutflowTemplate
+    validation_excluded_fields = ('name', 'label')
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+
+        CoolingOutflowTemplate.objects.bulk_create((
+            CoolingOutflowTemplate(device_type=devicetype, name='Cooling Outlet Template 1'),
+            CoolingOutflowTemplate(device_type=devicetype, name='Cooling Outlet Template 2'),
+            CoolingOutflowTemplate(device_type=devicetype, name='Cooling Outlet Template 3'),
+        ))
+
+        coolingintakes = (
+            CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 1'),
+        )
+        CoolingIntakeTemplate.objects.bulk_create(coolingintakes)
+
+        cls.form_data = {
+            'device_type': devicetype.pk,
+            'name': 'Cooling Outlet Template X',
+            'type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': Decimal('25'),
+            'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
+            'cooling_intake': coolingintakes[0].pk,
+        }
+
+        cls.bulk_create_data = {
+            'device_type': devicetype.pk,
+            'name': 'Cooling Outlet Template [4-6]',
+            'type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': Decimal('25'),
+            'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
+            'cooling_intake': coolingintakes[0].pk,
+        }
+
+        cls.bulk_edit_data = {
+            'type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': Decimal('25'),
+            'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
+        }
+
+
+class CoolingIntakeTestCase(ViewTestCases.DeviceComponentViewTestCase):
+    model = CoolingIntake
+    validation_excluded_fields = ('name', 'label')
+
+    @classmethod
+    def setUpTestData(cls):
+        device = create_test_device('Device 1')
+
+        cooling_intakes = (
+            CoolingIntake(device=device, name='Cooling Port 1'),
+            CoolingIntake(device=device, name='Cooling Port 2'),
+            CoolingIntake(device=device, name='Cooling Port 3'),
+        )
+        CoolingIntake.objects.bulk_create(cooling_intakes)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'device': device.pk,
+            'name': 'Cooling Port X',
+            'type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': Decimal('25'),
+            'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
+            'max_flow': 100,
+            'max_flow_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
+            'description': 'A cooling port',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.bulk_create_data = {
+            'device': device.pk,
+            'name': 'Cooling Port [4-6]]',
+            'type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': Decimal('25'),
+            'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
+            'max_flow': 100,
+            'max_flow_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
+            'description': 'A cooling port',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.bulk_edit_data = {
+            'type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': Decimal('25'),
+            'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
+            'max_flow': 100,
+            'max_flow_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
+            'description': 'New description',
+        }
+
+        cls.csv_data = (
+            "device,name",
+            "Device 1,Cooling Port 4",
+            "Device 1,Cooling Port 5",
+            "Device 1,Cooling Port 6",
+        )
+
+        cls.csv_update_data = (
+            "id,name,description",
+            f"{cooling_intakes[0].pk},Cooling Port 7,New description7",
+            f"{cooling_intakes[1].pk},Cooling Port 8,New description8",
+            f"{cooling_intakes[2].pk},Cooling Port 9,New description9",
+        )
+
+
+class CoolingOutflowTestCase(ViewTestCases.DeviceComponentViewTestCase):
+    model = CoolingOutflow
+    validation_excluded_fields = ('name', 'label')
+
+    @classmethod
+    def setUpTestData(cls):
+        device = create_test_device('Device 1')
+
+        coolingintakes = (
+            CoolingIntake(device=device, name='Cooling Port 1'),
+            CoolingIntake(device=device, name='Cooling Port 2'),
+        )
+        CoolingIntake.objects.bulk_create(coolingintakes)
+
+        cooling_outflows = (
+            CoolingOutflow(device=device, name='Cooling Outlet 1', cooling_intake=coolingintakes[0]),
+            CoolingOutflow(device=device, name='Cooling Outlet 2', cooling_intake=coolingintakes[0]),
+            CoolingOutflow(device=device, name='Cooling Outlet 3', cooling_intake=coolingintakes[0]),
+        )
+        CoolingOutflow.objects.bulk_create(cooling_outflows)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'device': device.pk,
+            'name': 'Cooling Outlet X',
+            'type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': Decimal('25'),
+            'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
+            'cooling_intake': coolingintakes[1].pk,
+            'description': 'A cooling outlet',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.bulk_create_data = {
+            'device': device.pk,
+            'name': 'Cooling Outlet [4-6]',
+            'type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': Decimal('25'),
+            'diameter_unit': DiameterUnitChoices.UNIT_MILLIMETER,
+            'cooling_intake': coolingintakes[1].pk,
+            'description': 'A cooling outlet',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.bulk_edit_data = {
+            'cooling_intake': coolingintakes[1].pk,
+            'description': 'New description',
+        }
+
+        cls.csv_data = (
+            "device,name",
+            "Device 1,Cooling Outlet 4",
+            "Device 1,Cooling Outlet 5",
+            "Device 1,Cooling Outlet 6",
+        )
+
+        cls.csv_update_data = (
+            "id,name,description",
+            f"{cooling_outflows[0].pk},Cooling Outlet 7,New description7",
+            f"{cooling_outflows[1].pk},Cooling Outlet 8,New description8",
+            f"{cooling_outflows[2].pk},Cooling Outlet 9,New description9",
+        )
+
+
+class CoolingSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = CoolingSource
+
+    @classmethod
+    def setUpTestData(cls):
+
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
+
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+        )
+        for location in locations:
+            location.save()
+
+        cooling_sources = (
+            CoolingSource(
+                site=sites[0], location=locations[0], name='Cooling Source 1',
+                type=CoolingSourceTypeChoices.TYPE_CHILLER
+            ),
+            CoolingSource(
+                site=sites[0], location=locations[0], name='Cooling Source 2',
+                type=CoolingSourceTypeChoices.TYPE_CHILLER
+            ),
+            CoolingSource(
+                site=sites[0], location=locations[0], name='Cooling Source 3',
+                type=CoolingSourceTypeChoices.TYPE_CHILLER
+            ),
+        )
+        CoolingSource.objects.bulk_create(cooling_sources)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'site': sites[1].pk,
+            'location': locations[1].pk,
+            'name': 'Cooling Source X',
+            'type': CoolingSourceTypeChoices.TYPE_COOLING_TOWER,
+            'status': CoolingSourceStatusChoices.STATUS_ACTIVE,
+            'fluid_type': FluidTypeChoices.FLUID_WATER,
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "site,location,name,type,status",
+            "Site 1,Location 1,Cooling Source 4,chiller,active",
+            "Site 1,Location 1,Cooling Source 5,chiller,active",
+            "Site 1,Location 1,Cooling Source 6,chiller,active",
+        )
+
+        cls.csv_update_data = (
+            "id,name",
+            f"{cooling_sources[0].pk},Cooling Source 7",
+            f"{cooling_sources[1].pk},Cooling Source 8",
+            f"{cooling_sources[2].pk},Cooling Source 9",
+        )
+
+        cls.bulk_edit_data = {
+            'site': sites[1].pk,
+            'location': locations[1].pk,
+        }
+
+
+class CoolingFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = CoolingFeed
+
+    @classmethod
+    def setUpTestData(cls):
+
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        cooling_sources = (
+            CoolingSource(site=site, name='Cooling Source 1', type=CoolingSourceTypeChoices.TYPE_CHILLER),
+            CoolingSource(site=site, name='Cooling Source 2', type=CoolingSourceTypeChoices.TYPE_CHILLER),
+        )
+        CoolingSource.objects.bulk_create(cooling_sources)
+
+        racks = (
+            Rack(site=site, name='Rack 1'),
+            Rack(site=site, name='Rack 2'),
+        )
+        Rack.objects.bulk_create(racks)
+
+        cooling_feeds = (
+            CoolingFeed(name='Cooling Feed 1', cooling_source=cooling_sources[0], rack=racks[0]),
+            CoolingFeed(name='Cooling Feed 2', cooling_source=cooling_sources[0], rack=racks[0]),
+            CoolingFeed(name='Cooling Feed 3', cooling_source=cooling_sources[0], rack=racks[0]),
+        )
+        CoolingFeed.objects.bulk_create(cooling_feeds)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'Cooling Feed X',
+            'cooling_source': cooling_sources[1].pk,
+            'rack': racks[1].pk,
+            'status': CoolingFeedStatusChoices.STATUS_PLANNED,
+            'cooling_capacity': 100,
+            'max_flow': 50,
+            'max_flow_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
+            'comments': 'New comments',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "site,cooling_source,name,status",
+            "Site 1,Cooling Source 1,Cooling Feed 4,active",
+            "Site 1,Cooling Source 1,Cooling Feed 5,active",
+            "Site 1,Cooling Source 1,Cooling Feed 6,active",
+        )
+
+        cls.csv_update_data = (
+            "id,name,status",
+            f"{cooling_feeds[0].pk},Cooling Feed 7,{CoolingFeedStatusChoices.STATUS_PLANNED}",
+            f"{cooling_feeds[1].pk},Cooling Feed 8,{CoolingFeedStatusChoices.STATUS_PLANNED}",
+            f"{cooling_feeds[2].pk},Cooling Feed 9,{CoolingFeedStatusChoices.STATUS_PLANNED}",
+        )
+
+        cls.bulk_edit_data = {
+            'cooling_source': cooling_sources[1].pk,
+            'rack': racks[1].pk,
+            'status': CoolingFeedStatusChoices.STATUS_PLANNED,
+            'cooling_capacity': 100,
+            'max_flow': 50,
+            'max_flow_unit': FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE,
+            'comments': 'New comments',
+        }
+
+
 class VirtualDeviceContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = VirtualDeviceContext
 

+ 55 - 0
netbox/dcim/ui/panels.py

@@ -79,6 +79,8 @@ class RackPanel(panels.ObjectAttributesPanel):
     serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
     asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
     airflow = attrs.ChoiceAttr('airflow')
+    cooling_capability = attrs.ChoiceAttr('cooling_capability')
+    cooling_capacity = attrs.TextAttr('cooling_capacity', format_string=_('{} kW'))
     space_utilization = attrs.UtilizationAttr('get_utilization')
     power_utilization = attrs.UtilizationAttr('get_power_utilization')
 
@@ -106,6 +108,8 @@ class RackTypePanel(panels.ObjectAttributesPanel):
     manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
     model = attrs.TextAttr('model')
     description = attrs.TextAttr('description')
+    cooling_capability = attrs.ChoiceAttr('cooling_capability')
+    cooling_capacity = attrs.TextAttr('cooling_capacity', format_string=_('{} kW'))
 
 
 class DevicePanel(panels.ObjectAttributesPanel):
@@ -118,6 +122,7 @@ class DevicePanel(panels.ObjectAttributesPanel):
     gps_coordinates = attrs.GPSCoordinatesAttr()
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     description = attrs.TextAttr('description')
+    cooling_method = attrs.ChoiceAttr('cooling_method')
     airflow = attrs.ChoiceAttr('airflow')
     serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
     asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
@@ -181,6 +186,7 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
     full_depth = attrs.BooleanAttr('is_full_depth')
     weight = attrs.WeightAttr('weight')
     subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child'))
+    cooling_method = attrs.ChoiceAttr('cooling_method')
     airflow = attrs.ChoiceAttr('airflow')
     end_of_life = attrs.DateTimeAttr('end_of_life', spec='date')
     front_image = attrs.ImageAttr('front_image')
@@ -216,6 +222,7 @@ class ModuleTypePanel(panels.ObjectAttributesPanel):
     model = attrs.TextAttr('model', label=_('Model name'))
     part_number = attrs.TextAttr('part_number')
     description = attrs.TextAttr('description')
+    cooling_method = attrs.ChoiceAttr('cooling_method')
     airflow = attrs.ChoiceAttr('airflow')
     weight = attrs.WeightAttr('weight')
     end_of_life = attrs.DateTimeAttr('end_of_life', spec='date')
@@ -273,6 +280,29 @@ class PowerOutletPanel(panels.ObjectAttributesPanel):
     feed_leg = attrs.ChoiceAttr('feed_leg')
 
 
+class CoolingIntakePanel(panels.ObjectAttributesPanel):
+    device = attrs.RelatedObjectAttr('device', linkify=True)
+    module = attrs.RelatedObjectAttr('module', linkify=True)
+    name = attrs.TextAttr('name')
+    label = attrs.TextAttr('label')
+    type = attrs.ChoiceAttr('type')
+    diameter = attrs.DiameterAttr('diameter')
+    max_flow = attrs.FlowRateAttr('max_flow')
+    cooling_outflow = attrs.RelatedObjectAttr('cooling_outflow', linkify=True)
+    description = attrs.TextAttr('description')
+
+
+class CoolingOutflowPanel(panels.ObjectAttributesPanel):
+    device = attrs.RelatedObjectAttr('device', linkify=True)
+    module = attrs.RelatedObjectAttr('module', linkify=True)
+    name = attrs.TextAttr('name')
+    label = attrs.TextAttr('label')
+    type = attrs.ChoiceAttr('type')
+    diameter = attrs.DiameterAttr('diameter')
+    cooling_intake = attrs.RelatedObjectAttr('cooling_intake', linkify=True)
+    description = attrs.TextAttr('description')
+
+
 class FrontPortPanel(panels.ObjectAttributesPanel):
     device = attrs.RelatedObjectAttr('device', linkify=True)
     module = attrs.RelatedObjectAttr('module', linkify=True)
@@ -399,6 +429,31 @@ class PowerFeedElectricalPanel(panels.ObjectAttributesPanel):
     max_utilization = attrs.TextAttr('max_utilization', format_string='{}%')
 
 
+class CoolingSourcePanel(panels.ObjectAttributesPanel):
+    site = attrs.RelatedObjectAttr('site', linkify=True)
+    location = attrs.NestedObjectAttr('location', linkify=True)
+    type = attrs.ChoiceAttr('type')
+    status = attrs.ChoiceAttr('status')
+    fluid_type = attrs.ChoiceAttr('fluid_type')
+    cooling_capacity = attrs.TextAttr('cooling_capacity', format_string=_('{} kW'))
+    description = attrs.TextAttr('description')
+
+
+class CoolingFeedPanel(panels.ObjectAttributesPanel):
+    cooling_source = attrs.RelatedObjectAttr('cooling_source', linkify=True)
+    rack = attrs.RelatedObjectAttr('rack', linkify=True)
+    status = attrs.ChoiceAttr('status')
+    description = attrs.TextAttr('description')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+
+
+class CoolingFeedCharacteristicsPanel(panels.ObjectAttributesPanel):
+    title = _('Cooling Characteristics')
+
+    cooling_capacity = attrs.TextAttr('cooling_capacity', format_string=_('{} kW'))
+    max_flow = attrs.FlowRateAttr('max_flow')
+
+
 class VirtualDeviceContextPanel(panels.ObjectAttributesPanel):
     name = attrs.TextAttr('name')
     device = attrs.RelatedObjectAttr('device', linkify=True)

+ 28 - 0
netbox/dcim/urls.py

@@ -62,6 +62,12 @@ urlpatterns = [
     path('power-outlet-templates/', include(get_model_urls('dcim', 'poweroutlettemplate', detail=False))),
     path('power-outlet-templates/<int:pk>/', include(get_model_urls('dcim', 'poweroutlettemplate'))),
 
+    path('cooling-intake-templates/', include(get_model_urls('dcim', 'coolingintaketemplate', detail=False))),
+    path('cooling-intake-templates/<int:pk>/', include(get_model_urls('dcim', 'coolingintaketemplate'))),
+
+    path('cooling-outflow-templates/', include(get_model_urls('dcim', 'coolingoutflowtemplate', detail=False))),
+    path('cooling-outflow-templates/<int:pk>/', include(get_model_urls('dcim', 'coolingoutflowtemplate'))),
+
     path('interface-templates/', include(get_model_urls('dcim', 'interfacetemplate', detail=False))),
     path('interface-templates/<int:pk>/', include(get_model_urls('dcim', 'interfacetemplate'))),
 
@@ -123,6 +129,22 @@ urlpatterns = [
         name='device_bulk_add_poweroutlet'
     ),
 
+    path('cooling-intakes/', include(get_model_urls('dcim', 'coolingintake', detail=False))),
+    path('cooling-intakes/<int:pk>/', include(get_model_urls('dcim', 'coolingintake'))),
+    path(
+        'devices/cooling-intakes/add/',
+        views.DeviceBulkAddCoolingIntakeView.as_view(),
+        name='device_bulk_add_coolingintake'
+    ),
+
+    path('cooling-outflows/', include(get_model_urls('dcim', 'coolingoutflow', detail=False))),
+    path('cooling-outflows/<int:pk>/', include(get_model_urls('dcim', 'coolingoutflow'))),
+    path(
+        'devices/cooling-outflows/add/',
+        views.DeviceBulkAddCoolingOutflowView.as_view(),
+        name='device_bulk_add_coolingoutflow'
+    ),
+
     path('interfaces/', include(get_model_urls('dcim', 'interface', detail=False))),
     path('interfaces/<int:pk>/', include(get_model_urls('dcim', 'interface'))),
     path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
@@ -178,6 +200,12 @@ urlpatterns = [
     path('power-feeds/', include(get_model_urls('dcim', 'powerfeed', detail=False))),
     path('power-feeds/<int:pk>/', include(get_model_urls('dcim', 'powerfeed'))),
 
+    path('cooling-sources/', include(get_model_urls('dcim', 'coolingsource', detail=False))),
+    path('cooling-sources/<int:pk>/', include(get_model_urls('dcim', 'coolingsource'))),
+
+    path('cooling-feeds/', include(get_model_urls('dcim', 'coolingfeed', detail=False))),
+    path('cooling-feeds/<int:pk>/', include(get_model_urls('dcim', 'coolingfeed'))),
+
     path('mac-addresses/', include(get_model_urls('dcim', 'macaddress', detail=False))),
     path('mac-addresses/<int:pk>/', include(get_model_urls('dcim', 'macaddress'))),
 

+ 531 - 9
netbox/dcim/views.py

@@ -1464,9 +1464,9 @@ class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView):
     def get_extra_context(self, request, instance):
         return {
             'related_models': self.get_related_models(request, instance, omit=[
-                ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate,
-                InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate,
-                RearPortTemplate,
+                ConsolePortTemplate, ConsoleServerPortTemplate, CoolingIntakeTemplate, CoolingOutflowTemplate,
+                DeviceBayTemplate, FrontPortTemplate, InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate,
+                PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
             ]),
         }
 
@@ -1543,6 +1543,36 @@ class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
     )
 
 
+@register_model_view(DeviceType, 'coolingintakes', path='cooling-intakes')
+class DeviceTypeCoolingIntakesView(DeviceTypeComponentsView):
+    child_model = CoolingIntakeTemplate
+    table = tables.CoolingIntakeTemplateTable
+    filterset = filtersets.CoolingIntakeTemplateFilterSet
+    viewname = 'dcim:devicetype_coolingintakes'
+    tab = ViewTab(
+        label=_('Cooling Intakes'),
+        badge=lambda obj: obj.cooling_intake_template_count,
+        permission='dcim.view_coolingintaketemplate',
+        weight=590,
+        hide_if_empty=True
+    )
+
+
+@register_model_view(DeviceType, 'coolingoutflows', path='cooling-outflows')
+class DeviceTypeCoolingOutflowsView(DeviceTypeComponentsView):
+    child_model = CoolingOutflowTemplate
+    table = tables.CoolingOutflowTemplateTable
+    filterset = filtersets.CoolingOutflowTemplateFilterSet
+    viewname = 'dcim:devicetype_coolingoutflows'
+    tab = ViewTab(
+        label=_('Cooling Outflows'),
+        badge=lambda obj: obj.cooling_outflow_template_count,
+        permission='dcim.view_coolingoutflowtemplate',
+        weight=600,
+        hide_if_empty=True
+    )
+
+
 @register_model_view(DeviceType, 'interfaces')
 class DeviceTypeInterfacesView(DeviceTypeComponentsView):
     child_model = InterfaceTemplate
@@ -1628,7 +1658,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
         label=_('Inventory Items'),
         badge=lambda obj: obj.inventory_item_template_count,
         permission='dcim.view_inventoryitemtemplate',
-        weight=590,
+        weight=610,
         hide_if_empty=True
     )
 
@@ -1641,6 +1671,8 @@ class DeviceTypeImportView(generic.BulkImportView):
         'dcim.add_consoleserverporttemplate',
         'dcim.add_powerporttemplate',
         'dcim.add_poweroutlettemplate',
+        'dcim.add_coolingintaketemplate',
+        'dcim.add_coolingoutflowtemplate',
         'dcim.add_interfacetemplate',
         'dcim.add_frontporttemplate',
         'dcim.add_rearporttemplate',
@@ -1655,6 +1687,8 @@ class DeviceTypeImportView(generic.BulkImportView):
         'console-server-ports': forms.ConsoleServerPortTemplateImportForm,
         'power-ports': forms.PowerPortTemplateImportForm,
         'power-outlets': forms.PowerOutletTemplateImportForm,
+        'cooling-intakes': forms.CoolingIntakeTemplateImportForm,
+        'cooling-outflows': forms.CoolingOutflowTemplateImportForm,
         'interfaces': forms.InterfaceTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
@@ -1889,9 +1923,9 @@ class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
     def get_extra_context(self, request, instance):
         return {
             'related_models': self.get_related_models(request, instance, omit=[
-                ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate,
-                InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate,
-                RearPortTemplate,
+                ConsolePortTemplate, ConsoleServerPortTemplate, CoolingIntakeTemplate, CoolingOutflowTemplate,
+                DeviceBayTemplate, FrontPortTemplate, InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate,
+                PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
             ]),
         }
 
@@ -1995,6 +2029,36 @@ class ModuleTypePowerOutletsView(ModuleTypeComponentsView):
     )
 
 
+@register_model_view(ModuleType, 'coolingintakes', path='cooling-intakes')
+class ModuleTypeCoolingIntakesView(ModuleTypeComponentsView):
+    child_model = CoolingIntakeTemplate
+    table = tables.CoolingIntakeTemplateTable
+    filterset = filtersets.CoolingIntakeTemplateFilterSet
+    viewname = 'dcim:moduletype_coolingintakes'
+    tab = ViewTab(
+        label=_('Cooling Intakes'),
+        badge=lambda obj: obj.cooling_intake_template_count,
+        permission='dcim.view_coolingintaketemplate',
+        weight=570,
+        hide_if_empty=True
+    )
+
+
+@register_model_view(ModuleType, 'coolingoutflows', path='cooling-outflows')
+class ModuleTypeCoolingOutflowsView(ModuleTypeComponentsView):
+    child_model = CoolingOutflowTemplate
+    table = tables.CoolingOutflowTemplateTable
+    filterset = filtersets.CoolingOutflowTemplateFilterSet
+    viewname = 'dcim:moduletype_coolingoutflows'
+    tab = ViewTab(
+        label=_('Cooling Outflows'),
+        badge=lambda obj: obj.cooling_outflow_template_count,
+        permission='dcim.view_coolingoutflowtemplate',
+        weight=580,
+        hide_if_empty=True
+    )
+
+
 @register_model_view(ModuleType, 'interfaces')
 class ModuleTypeInterfacesView(ModuleTypeComponentsView):
     child_model = InterfaceTemplate
@@ -2050,7 +2114,7 @@ class ModuleTypeModuleBaysView(ModuleTypeComponentsView):
         label=_('Module Bays'),
         badge=lambda obj: obj.module_bay_template_count,
         permission='dcim.view_modulebaytemplate',
-        weight=570,
+        weight=590,
         hide_if_empty=True
     )
 
@@ -2063,6 +2127,8 @@ class ModuleTypeImportView(generic.BulkImportView):
         'dcim.add_consoleserverporttemplate',
         'dcim.add_powerporttemplate',
         'dcim.add_poweroutlettemplate',
+        'dcim.add_coolingintaketemplate',
+        'dcim.add_coolingoutflowtemplate',
         'dcim.add_interfacetemplate',
         'dcim.add_frontporttemplate',
         'dcim.add_rearporttemplate',
@@ -2075,6 +2141,8 @@ class ModuleTypeImportView(generic.BulkImportView):
         'console-server-ports': forms.ConsoleServerPortTemplateImportForm,
         'power-ports': forms.PowerPortTemplateImportForm,
         'power-outlets': forms.PowerOutletTemplateImportForm,
+        'cooling-intakes': forms.CoolingIntakeTemplateImportForm,
+        'cooling-outflows': forms.CoolingOutflowTemplateImportForm,
         'interfaces': forms.InterfaceTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
@@ -2306,6 +2374,88 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView):
     table = tables.PowerOutletTemplateTable
 
 
+#
+# Cooling port templates
+#
+
+@register_model_view(CoolingIntakeTemplate, 'add', detail=False)
+class CoolingIntakeTemplateCreateView(generic.ComponentCreateView):
+    queryset = CoolingIntakeTemplate.objects.all()
+    form = forms.CoolingIntakeTemplateCreateForm
+    model_form = forms.CoolingIntakeTemplateForm
+
+
+@register_model_view(CoolingIntakeTemplate, 'edit')
+class CoolingIntakeTemplateEditView(generic.ObjectEditView):
+    queryset = CoolingIntakeTemplate.objects.all()
+    form = forms.CoolingIntakeTemplateForm
+
+
+@register_model_view(CoolingIntakeTemplate, 'delete')
+class CoolingIntakeTemplateDeleteView(generic.ObjectDeleteView):
+    queryset = CoolingIntakeTemplate.objects.all()
+
+
+@register_model_view(CoolingIntakeTemplate, 'bulk_edit', path='edit', detail=False)
+class CoolingIntakeTemplateBulkEditView(generic.BulkEditView):
+    queryset = CoolingIntakeTemplate.objects.all()
+    table = tables.CoolingIntakeTemplateTable
+    form = forms.CoolingIntakeTemplateBulkEditForm
+
+
+@register_model_view(CoolingIntakeTemplate, 'bulk_rename', path='rename', detail=False)
+class CoolingIntakeTemplateBulkRenameView(generic.BulkRenameView):
+    queryset = CoolingIntakeTemplate.objects.all()
+    rename_fields = ('name', 'label')
+
+
+@register_model_view(CoolingIntakeTemplate, 'bulk_delete', path='delete', detail=False)
+class CoolingIntakeTemplateBulkDeleteView(generic.BulkDeleteView):
+    queryset = CoolingIntakeTemplate.objects.all()
+    table = tables.CoolingIntakeTemplateTable
+
+
+#
+# Cooling outlet templates
+#
+
+@register_model_view(CoolingOutflowTemplate, 'add', detail=False)
+class CoolingOutflowTemplateCreateView(generic.ComponentCreateView):
+    queryset = CoolingOutflowTemplate.objects.all()
+    form = forms.CoolingOutflowTemplateCreateForm
+    model_form = forms.CoolingOutflowTemplateForm
+
+
+@register_model_view(CoolingOutflowTemplate, 'edit')
+class CoolingOutflowTemplateEditView(generic.ObjectEditView):
+    queryset = CoolingOutflowTemplate.objects.all()
+    form = forms.CoolingOutflowTemplateForm
+
+
+@register_model_view(CoolingOutflowTemplate, 'delete')
+class CoolingOutflowTemplateDeleteView(generic.ObjectDeleteView):
+    queryset = CoolingOutflowTemplate.objects.all()
+
+
+@register_model_view(CoolingOutflowTemplate, 'bulk_edit', path='edit', detail=False)
+class CoolingOutflowTemplateBulkEditView(generic.BulkEditView):
+    queryset = CoolingOutflowTemplate.objects.all()
+    table = tables.CoolingOutflowTemplateTable
+    form = forms.CoolingOutflowTemplateBulkEditForm
+
+
+@register_model_view(CoolingOutflowTemplate, 'bulk_rename', path='rename', detail=False)
+class CoolingOutflowTemplateBulkRenameView(generic.BulkRenameView):
+    queryset = CoolingOutflowTemplate.objects.all()
+    rename_fields = ('name', 'label')
+
+
+@register_model_view(CoolingOutflowTemplate, 'bulk_delete', path='delete', detail=False)
+class CoolingOutflowTemplateBulkDeleteView(generic.BulkDeleteView):
+    queryset = CoolingOutflowTemplate.objects.all()
+    table = tables.CoolingOutflowTemplateTable
+
+
 #
 # Interface templates
 #
@@ -2918,6 +3068,38 @@ class DevicePowerOutletsView(DeviceComponentsView):
     )
 
 
+@register_model_view(Device, 'coolingintakes', path='cooling-intakes')
+class DeviceCoolingIntakesView(DeviceComponentsView):
+    child_model = CoolingIntake
+    table = tables.DeviceCoolingIntakeTable
+    filterset = filtersets.CoolingIntakeFilterSet
+    filterset_form = forms.CoolingIntakeFilterForm
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
+    tab = ViewTab(
+        label=_('Cooling Intakes'),
+        badge=lambda obj: obj.cooling_intake_count,
+        permission='dcim.view_coolingintake',
+        weight=590,
+        hide_if_empty=True
+    )
+
+
+@register_model_view(Device, 'coolingoutflows', path='cooling-outflows')
+class DeviceCoolingOutflowsView(DeviceComponentsView):
+    child_model = CoolingOutflow
+    table = tables.DeviceCoolingOutflowTable
+    filterset = filtersets.CoolingOutflowFilterSet
+    filterset_form = forms.CoolingOutflowFilterForm
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
+    tab = ViewTab(
+        label=_('Cooling Outflows'),
+        badge=lambda obj: obj.cooling_outflow_count,
+        permission='dcim.view_coolingoutflow',
+        weight=600,
+        hide_if_empty=True
+    )
+
+
 @register_model_view(Device, 'interfaces')
 class DeviceInterfacesView(DeviceComponentsView):
     child_model = Interface
@@ -3016,7 +3198,7 @@ class DeviceInventoryView(DeviceComponentsView):
         label=_('Inventory Items'),
         badge=lambda obj: obj.inventory_item_count,
         permission='dcim.view_inventoryitem',
-        weight=590,
+        weight=610,
         hide_if_empty=True
     )
 
@@ -3565,6 +3747,160 @@ class PowerOutletBulkDeleteView(generic.BulkDeleteView):
 register_model_view(PowerOutlet, 'trace', kwargs={'model': PowerOutlet})(PathTraceView)
 
 
+#
+# Cooling ports
+#
+
+@register_model_view(CoolingIntake, 'list', path='', detail=False)
+class CoolingIntakeListView(generic.ObjectListView):
+    queryset = CoolingIntake.objects.all()
+    filterset = filtersets.CoolingIntakeFilterSet
+    filterset_form = forms.CoolingIntakeFilterForm
+    table = tables.CoolingIntakeTable
+
+
+@register_model_view(CoolingIntake)
+class CoolingIntakeView(generic.ObjectView):
+    queryset = CoolingIntake.objects.all()
+    template_name = 'generic/object.html'
+    layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('device', url=object_view_url('dcim:device_coolingintakes')),
+        ],
+        left_panels=[
+            panels.CoolingIntakePanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            panels.InventoryItemsPanel(),
+        ],
+    )
+
+
+@register_model_view(CoolingIntake, 'add', detail=False)
+class CoolingIntakeCreateView(generic.ComponentCreateView):
+    queryset = CoolingIntake.objects.all()
+    form = forms.CoolingIntakeCreateForm
+    model_form = forms.CoolingIntakeForm
+
+
+@register_model_view(CoolingIntake, 'edit')
+class CoolingIntakeEditView(generic.ObjectEditView):
+    queryset = CoolingIntake.objects.all()
+    form = forms.CoolingIntakeForm
+
+
+@register_model_view(CoolingIntake, 'delete')
+class CoolingIntakeDeleteView(generic.ObjectDeleteView):
+    queryset = CoolingIntake.objects.all()
+
+
+@register_model_view(CoolingIntake, 'bulk_import', path='import', detail=False)
+class CoolingIntakeBulkImportView(generic.BulkImportView):
+    queryset = CoolingIntake.objects.all()
+    model_form = forms.CoolingIntakeImportForm
+
+
+@register_model_view(CoolingIntake, 'bulk_edit', path='edit', detail=False)
+class CoolingIntakeBulkEditView(generic.BulkEditView):
+    queryset = CoolingIntake.objects.all()
+    filterset = filtersets.CoolingIntakeFilterSet
+    table = tables.CoolingIntakeTable
+    form = forms.CoolingIntakeBulkEditForm
+
+
+@register_model_view(CoolingIntake, 'bulk_rename', path='rename', detail=False)
+class CoolingIntakeBulkRenameView(generic.BulkRenameView):
+    queryset = CoolingIntake.objects.all()
+    filterset = filtersets.CoolingIntakeFilterSet
+    rename_fields = ('name', 'label')
+
+
+@register_model_view(CoolingIntake, 'bulk_delete', path='delete', detail=False)
+class CoolingIntakeBulkDeleteView(generic.BulkDeleteView):
+    queryset = CoolingIntake.objects.all()
+    filterset = filtersets.CoolingIntakeFilterSet
+    table = tables.CoolingIntakeTable
+
+
+#
+# Cooling outlets
+#
+
+@register_model_view(CoolingOutflow, 'list', path='', detail=False)
+class CoolingOutflowListView(generic.ObjectListView):
+    queryset = CoolingOutflow.objects.all()
+    filterset = filtersets.CoolingOutflowFilterSet
+    filterset_form = forms.CoolingOutflowFilterForm
+    table = tables.CoolingOutflowTable
+
+
+@register_model_view(CoolingOutflow)
+class CoolingOutflowView(generic.ObjectView):
+    queryset = CoolingOutflow.objects.all()
+    template_name = 'generic/object.html'
+    layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('device', url=object_view_url('dcim:device_coolingoutflows')),
+        ],
+        left_panels=[
+            panels.CoolingOutflowPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            panels.InventoryItemsPanel(),
+        ],
+    )
+
+
+@register_model_view(CoolingOutflow, 'add', detail=False)
+class CoolingOutflowCreateView(generic.ComponentCreateView):
+    queryset = CoolingOutflow.objects.all()
+    form = forms.CoolingOutflowCreateForm
+    model_form = forms.CoolingOutflowForm
+
+
+@register_model_view(CoolingOutflow, 'edit')
+class CoolingOutflowEditView(generic.ObjectEditView):
+    queryset = CoolingOutflow.objects.all()
+    form = forms.CoolingOutflowForm
+
+
+@register_model_view(CoolingOutflow, 'delete')
+class CoolingOutflowDeleteView(generic.ObjectDeleteView):
+    queryset = CoolingOutflow.objects.all()
+
+
+@register_model_view(CoolingOutflow, 'bulk_import', path='import', detail=False)
+class CoolingOutflowBulkImportView(generic.BulkImportView):
+    queryset = CoolingOutflow.objects.all()
+    model_form = forms.CoolingOutflowImportForm
+
+
+@register_model_view(CoolingOutflow, 'bulk_edit', path='edit', detail=False)
+class CoolingOutflowBulkEditView(generic.BulkEditView):
+    queryset = CoolingOutflow.objects.all()
+    filterset = filtersets.CoolingOutflowFilterSet
+    table = tables.CoolingOutflowTable
+    form = forms.CoolingOutflowBulkEditForm
+
+
+@register_model_view(CoolingOutflow, 'bulk_rename', path='rename', detail=False)
+class CoolingOutflowBulkRenameView(generic.BulkRenameView):
+    queryset = CoolingOutflow.objects.all()
+    filterset = filtersets.CoolingOutflowFilterSet
+    rename_fields = ('name', 'label')
+
+
+@register_model_view(CoolingOutflow, 'bulk_delete', path='delete', detail=False)
+class CoolingOutflowBulkDeleteView(generic.BulkDeleteView):
+    queryset = CoolingOutflow.objects.all()
+    filterset = filtersets.CoolingOutflowFilterSet
+    table = tables.CoolingOutflowTable
+
+
 #
 # Interfaces
 #
@@ -4476,6 +4812,28 @@ class DeviceBulkAddPowerOutletView(generic.BulkComponentCreateView):
     default_return_url = 'dcim:device_list'
 
 
+class DeviceBulkAddCoolingIntakeView(generic.BulkComponentCreateView):
+    parent_model = Device
+    parent_field = 'device'
+    form = forms.CoolingIntakeBulkCreateForm
+    queryset = CoolingIntake.objects.all()
+    model_form = forms.CoolingIntakeForm
+    filterset = filtersets.DeviceFilterSet
+    table = tables.DeviceTable
+    default_return_url = 'dcim:device_list'
+
+
+class DeviceBulkAddCoolingOutflowView(generic.BulkComponentCreateView):
+    parent_model = Device
+    parent_field = 'device'
+    form = forms.CoolingOutflowBulkCreateForm
+    queryset = CoolingOutflow.objects.all()
+    model_form = forms.CoolingOutflowForm
+    filterset = filtersets.DeviceFilterSet
+    table = tables.DeviceTable
+    default_return_url = 'dcim:device_list'
+
+
 class DeviceBulkAddInterfaceView(generic.BulkComponentCreateView):
     parent_model = Device
     parent_field = 'device'
@@ -5185,6 +5543,170 @@ class PowerFeedBulkDeleteView(generic.BulkDeleteView):
 register_model_view(PowerFeed, 'trace', kwargs={'model': PowerFeed})(PathTraceView)
 
 
+#
+# Cooling sources
+#
+
+@register_model_view(CoolingSource, 'list', path='', detail=False)
+class CoolingSourceListView(generic.ObjectListView):
+    queryset = CoolingSource.objects.annotate(
+        coolingfeed_count=count_related(CoolingFeed, 'cooling_source')
+    )
+    filterset = filtersets.CoolingSourceFilterSet
+    filterset_form = forms.CoolingSourceFilterForm
+    table = tables.CoolingSourceTable
+
+
+@register_model_view(CoolingSource)
+class CoolingSourceView(GetRelatedModelsMixin, generic.ObjectView):
+    queryset = CoolingSource.objects.all()
+    template_name = 'generic/object.html'
+    layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('site', url=filtered_list_url('dcim:coolingsource_list', 'site_id')),
+            Breadcrumb('location'),
+        ],
+        left_panels=[
+            panels.CoolingSourcePanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+            ImageAttachmentsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='dcim.CoolingFeed',
+                filters={'cooling_source_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject('dcim.CoolingFeed', url_params={'cooling_source': lambda ctx: ctx['object'].pk}),
+                ],
+            ),
+        ],
+    )
+
+    def get_extra_context(self, request, instance):
+        return {
+            'related_models': self.get_related_models(request, instance),
+        }
+
+
+@register_model_view(CoolingSource, 'add', detail=False)
+@register_model_view(CoolingSource, 'edit')
+class CoolingSourceEditView(generic.ObjectEditView):
+    queryset = CoolingSource.objects.all()
+    form = forms.CoolingSourceForm
+
+
+@register_model_view(CoolingSource, 'delete')
+class CoolingSourceDeleteView(generic.ObjectDeleteView):
+    queryset = CoolingSource.objects.all()
+
+
+@register_model_view(CoolingSource, 'bulk_import', path='import', detail=False)
+class CoolingSourceBulkImportView(generic.BulkImportView):
+    queryset = CoolingSource.objects.all()
+    model_form = forms.CoolingSourceImportForm
+
+
+@register_model_view(CoolingSource, 'bulk_edit', path='edit', detail=False)
+class CoolingSourceBulkEditView(generic.BulkEditView):
+    queryset = CoolingSource.objects.all()
+    filterset = filtersets.CoolingSourceFilterSet
+    table = tables.CoolingSourceTable
+    form = forms.CoolingSourceBulkEditForm
+
+
+@register_model_view(CoolingSource, 'bulk_rename', path='rename', detail=False)
+class CoolingSourceBulkRenameView(generic.BulkRenameView):
+    queryset = CoolingSource.objects.all()
+    filterset = filtersets.CoolingSourceFilterSet
+
+
+@register_model_view(CoolingSource, 'bulk_delete', path='delete', detail=False)
+class CoolingSourceBulkDeleteView(generic.BulkDeleteView):
+    queryset = CoolingSource.objects.annotate(
+        coolingfeed_count=count_related(CoolingFeed, 'cooling_source')
+    )
+    filterset = filtersets.CoolingSourceFilterSet
+    table = tables.CoolingSourceTable
+
+
+#
+# Cooling feeds
+#
+
+@register_model_view(CoolingFeed, 'list', path='', detail=False)
+class CoolingFeedListView(generic.ObjectListView):
+    queryset = CoolingFeed.objects.all()
+    filterset = filtersets.CoolingFeedFilterSet
+    filterset_form = forms.CoolingFeedFilterForm
+    table = tables.CoolingFeedTable
+
+
+@register_model_view(CoolingFeed)
+class CoolingFeedView(generic.ObjectView):
+    queryset = CoolingFeed.objects.all()
+    template_name = 'generic/object.html'
+    layout = layout.SimpleLayout(
+        breadcrumbs=[
+            Breadcrumb('cooling_source.site', url=filtered_list_url('dcim:coolingfeed_list', 'site_id')),
+            Breadcrumb('cooling_source', url=filtered_list_url('dcim:coolingfeed_list', 'cooling_source_id')),
+            Breadcrumb('rack', url=filtered_list_url('dcim:coolingfeed_list', 'rack_id')),
+        ],
+        left_panels=[
+            panels.CoolingFeedPanel(),
+            panels.CoolingFeedCharacteristicsPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            CommentsPanel(),
+        ],
+    )
+
+
+@register_model_view(CoolingFeed, 'add', detail=False)
+@register_model_view(CoolingFeed, 'edit')
+class CoolingFeedEditView(generic.ObjectEditView):
+    queryset = CoolingFeed.objects.all()
+    form = forms.CoolingFeedForm
+
+
+@register_model_view(CoolingFeed, 'delete')
+class CoolingFeedDeleteView(generic.ObjectDeleteView):
+    queryset = CoolingFeed.objects.all()
+
+
+@register_model_view(CoolingFeed, 'bulk_import', path='import', detail=False)
+class CoolingFeedBulkImportView(generic.BulkImportView):
+    queryset = CoolingFeed.objects.all()
+    model_form = forms.CoolingFeedImportForm
+
+
+@register_model_view(CoolingFeed, 'bulk_edit', path='edit', detail=False)
+class CoolingFeedBulkEditView(generic.BulkEditView):
+    queryset = CoolingFeed.objects.all()
+    filterset = filtersets.CoolingFeedFilterSet
+    table = tables.CoolingFeedTable
+    form = forms.CoolingFeedBulkEditForm
+
+
+@register_model_view(CoolingFeed, 'bulk_rename', path='rename', detail=False)
+class CoolingFeedBulkRenameView(generic.BulkRenameView):
+    queryset = CoolingFeed.objects.all()
+    filterset = filtersets.CoolingFeedFilterSet
+
+
+@register_model_view(CoolingFeed, 'bulk_delete', path='delete', detail=False)
+class CoolingFeedBulkDeleteView(generic.BulkDeleteView):
+    queryset = CoolingFeed.objects.all()
+    filterset = filtersets.CoolingFeedFilterSet
+    table = tables.CoolingFeedTable
+
+
 #
 # Virtual device contexts
 #

+ 34 - 0
netbox/netbox/choices.py

@@ -7,7 +7,9 @@ __all__ = (
     'ButtonColorChoices',
     'CSVDelimiterChoices',
     'ColorChoices',
+    'DiameterUnitChoices',
     'DistanceUnitChoices',
+    'FlowRateUnitChoices',
     'ImportFormatChoices',
     'ImportMethodChoices',
     'WeightUnitChoices',
@@ -197,3 +199,35 @@ class WeightUnitChoices(ChoiceSet):
         Choice(UNIT_POUND, _('Pounds')),
         Choice(UNIT_OUNCE, _('Ounces')),
     )
+
+
+class DiameterUnitChoices(ChoiceSet):
+
+    # Metric
+    UNIT_MILLIMETER = 'mm'
+    UNIT_CENTIMETER = 'cm'
+
+    # Imperial
+    UNIT_INCH = 'in'
+
+    CHOICES = (
+        Choice(UNIT_MILLIMETER, _('Millimeters')),
+        Choice(UNIT_CENTIMETER, _('Centimeters')),
+        Choice(UNIT_INCH, _('Inches')),
+    )
+
+
+class FlowRateUnitChoices(ChoiceSet):
+
+    # Metric
+    UNIT_LITERS_PER_MINUTE = 'lpm'
+    UNIT_CUBIC_METERS_PER_HOUR = 'm3ph'
+
+    # Imperial
+    UNIT_GALLONS_PER_MINUTE = 'gpm'
+
+    CHOICES = (
+        Choice(UNIT_LITERS_PER_MINUTE, _('Liters per minute (L/min)')),
+        Choice(UNIT_CUBIC_METERS_PER_HOUR, _('Cubic meters per hour (m³/h)')),
+        Choice(UNIT_GALLONS_PER_MINUTE, _('Gallons per minute (GPM)')),
+    )

+ 17 - 0
netbox/netbox/navigation/menu.py

@@ -109,6 +109,8 @@ DEVICES_MENU = Menu(
                 get_model_item('dcim', 'consoleserverport', _('Console Server Ports')),
                 get_model_item('dcim', 'powerport', _('Power Ports')),
                 get_model_item('dcim', 'poweroutlet', _('Power Outlets')),
+                get_model_item('dcim', 'coolingintake', _('Cooling Intakes')),
+                get_model_item('dcim', 'coolingoutflow', _('Cooling Outflows')),
                 get_model_item('dcim', 'modulebay', _('Module Bays')),
                 get_model_item('dcim', 'devicebay', _('Device Bays')),
                 get_model_item('dcim', 'inventoryitem', _('Inventory Items')),
@@ -335,6 +337,20 @@ POWER_MENU = Menu(
     ),
 )
 
+COOLING_MENU = Menu(
+    label=_('Cooling'),
+    icon_class='mdi mdi-snowflake',
+    groups=(
+        MenuGroup(
+            label=_('Cooling'),
+            items=(
+                get_model_item('dcim', 'coolingsource', _('Cooling Sources')),
+                get_model_item('dcim', 'coolingfeed', _('Cooling Feeds')),
+            ),
+        ),
+    ),
+)
+
 PROVISIONING_MENU = Menu(
     label=_('Provisioning'),
     icon_class='mdi mdi-file-document-multiple-outline',
@@ -482,6 +498,7 @@ def get_menus():
         VIRTUALIZATION_MENU,
         CIRCUITS_MENU,
         POWER_MENU,
+        COOLING_MENU,
         PROVISIONING_MENU,
         CUSTOMIZATION_MENU,
         OPERATIONS_MENU,

+ 166 - 0
netbox/netbox/tests/test_ui.py

@@ -813,6 +813,172 @@ class DisplayDistanceTagTestCase(SimpleTestCase):
         self.assertEqual(self._render(10, 'mi', 16093.44, system='imperial'), '10 mi')
 
 
+class DiameterAttrTestCase(SimpleTestCase):
+
+    def _ctx(self, system=''):
+        return {'name': 'diameter', 'preferences': {'ui.measurement_system': system}}
+
+    def _obj(self, diameter, unit, abs_mm):
+        return SimpleNamespace(diameter=diameter, diameter_unit=unit, _abs_diameter=abs_mm)
+
+    def test_none_returns_placeholder(self):
+        attr = attrs.DiameterAttr('diameter')
+        obj = SimpleNamespace(diameter=None)
+        self.assertEqual(attr.render(obj, self._ctx()), attr.placeholder)
+
+    def test_inherit_shows_stored_value(self):
+        attr = attrs.DiameterAttr('diameter')
+        result = attr.render(self._obj(25, 'mm', 25), self._ctx(system=''))
+        self.assertIn('25', result)
+        self.assertIn('mm', result)
+
+    def test_metric_converts_in_to_mm(self):
+        # 1 in = 25.4 mm
+        attr = attrs.DiameterAttr('diameter')
+        result = attr.render(self._obj(1, 'in', 25.4), self._ctx(system='metric'))
+        self.assertIn('25.4', result)
+        self.assertIn('mm', result)
+
+    def test_imperial_converts_mm_to_in(self):
+        # 25 mm → 25 / 25.4 = 0.98 in
+        attr = attrs.DiameterAttr('diameter')
+        result = attr.render(self._obj(25, 'mm', 25), self._ctx(system='imperial'))
+        self.assertIn('0.98', result)
+        self.assertIn('in', result)
+
+    def test_metric_no_conversion_for_metric_unit(self):
+        attr = attrs.DiameterAttr('diameter')
+        result = attr.render(self._obj(2.5, 'cm', 25), self._ctx(system='metric'))
+        self.assertIn('2.5', result)
+        self.assertIn('cm', result)
+
+    def test_metric_no_conversion_when_abs_diameter_is_none(self):
+        attr = attrs.DiameterAttr('diameter')
+        result = attr.render(self._obj(1, 'in', None), self._ctx(system='metric'))
+        self.assertIn('1', result)
+        self.assertIn('in', result)
+
+
+class FlowRateAttrTestCase(SimpleTestCase):
+
+    def _ctx(self, system=''):
+        return {'name': 'max_flow', 'preferences': {'ui.measurement_system': system}}
+
+    def _obj(self, max_flow, unit, abs_lpm):
+        return SimpleNamespace(max_flow=max_flow, max_flow_unit=unit, _abs_max_flow=abs_lpm)
+
+    def test_none_returns_placeholder(self):
+        attr = attrs.FlowRateAttr('max_flow')
+        obj = SimpleNamespace(max_flow=None)
+        self.assertEqual(attr.render(obj, self._ctx()), attr.placeholder)
+
+    def test_inherit_abbreviates_stored_unit(self):
+        attr = attrs.FlowRateAttr('max_flow')
+        result = attr.render(self._obj(100, 'lpm', 100), self._ctx(system=''))
+        self.assertIn('100', result)
+        self.assertIn('L/min', result)
+
+    def test_inherit_abbreviates_cubic_meters_per_hour(self):
+        attr = attrs.FlowRateAttr('max_flow')
+        result = attr.render(self._obj(6, 'm3ph', 100), self._ctx(system=''))
+        self.assertIn('6', result)
+        self.assertIn('m³/h', result)
+
+    def test_metric_converts_gpm_to_lpm(self):
+        # 10 GPM = 37.8541 L/min
+        attr = attrs.FlowRateAttr('max_flow')
+        result = attr.render(self._obj(10, 'gpm', 37.8541), self._ctx(system='metric'))
+        self.assertIn('37.85', result)
+        self.assertIn('L/min', result)
+
+    def test_imperial_converts_lpm_to_gpm(self):
+        # 100 L/min → 100 / 3.785411784 = 26.42 GPM
+        attr = attrs.FlowRateAttr('max_flow')
+        result = attr.render(self._obj(100, 'lpm', 100), self._ctx(system='imperial'))
+        self.assertIn('26.42', result)
+        self.assertIn('GPM', result)
+
+    def test_imperial_no_conversion_for_imperial_unit(self):
+        attr = attrs.FlowRateAttr('max_flow')
+        result = attr.render(self._obj(10, 'gpm', 37.8541), self._ctx(system='imperial'))
+        self.assertIn('10', result)
+        self.assertIn('GPM', result)
+
+    def test_metric_no_conversion_when_abs_flow_is_none(self):
+        attr = attrs.FlowRateAttr('max_flow')
+        result = attr.render(self._obj(10, 'gpm', None), self._ctx(system='metric'))
+        self.assertIn('10', result)
+        self.assertIn('GPM', result)
+
+
+class DisplayDiameterTagTestCase(SimpleTestCase):
+    TEMPLATE = Template('{% load helpers %}{% display_diameter diameter diameter_unit abs_diameter %}')
+
+    def _render(self, diameter, diameter_unit, abs_diameter, system=''):
+        ctx = Context({
+            'preferences': {'ui.measurement_system': system},
+            'diameter': diameter,
+            'diameter_unit': diameter_unit,
+            'abs_diameter': abs_diameter,
+        })
+        return self.TEMPLATE.render(ctx).strip()
+
+    def test_none_diameter_returns_empty(self):
+        self.assertEqual(self._render(None, 'mm', None), '')
+
+    def test_zero_diameter_is_not_suppressed(self):
+        self.assertEqual(self._render(0, 'mm', 0), '0 mm')
+
+    def test_inherit_stores_mm(self):
+        # An unconverted Decimal keeps its stored scale, as with display_weight
+        self.assertEqual(self._render(Decimal('25.00'), 'mm', Decimal('25.0000')), '25.00 mm')
+
+    def test_metric_converts_in_to_mm(self):
+        self.assertEqual(self._render(1, 'in', Decimal('25.4000'), system='metric'), '25.4 mm')
+
+    def test_imperial_converts_cm_to_in(self):
+        # 2.5 cm = 25 mm → 25 / 25.4 = 0.98 in
+        self.assertEqual(self._render(Decimal('2.50'), 'cm', Decimal('25.0000'), system='imperial'), '0.98 in')
+
+    def test_imperial_no_conversion_for_imperial_unit(self):
+        self.assertEqual(self._render(1, 'in', Decimal('25.4000'), system='imperial'), '1 in')
+
+
+class DisplayFlowRateTagTestCase(SimpleTestCase):
+    TEMPLATE = Template('{% load helpers %}{% display_flow_rate max_flow max_flow_unit abs_max_flow %}')
+
+    def _render(self, max_flow, max_flow_unit, abs_max_flow, system=''):
+        ctx = Context({
+            'preferences': {'ui.measurement_system': system},
+            'max_flow': max_flow,
+            'max_flow_unit': max_flow_unit,
+            'abs_max_flow': abs_max_flow,
+        })
+        return self.TEMPLATE.render(ctx).strip()
+
+    def test_none_flow_rate_returns_empty(self):
+        self.assertEqual(self._render(None, 'lpm', None), '')
+
+    def test_zero_flow_rate_is_not_suppressed(self):
+        self.assertEqual(self._render(0, 'lpm', 0), '0 L/min')
+
+    def test_inherit_abbreviates_stored_unit(self):
+        # An unconverted Decimal keeps its stored scale, as with display_weight
+        self.assertEqual(self._render(Decimal('100.00'), 'lpm', Decimal('100.0000')), '100.00 L/min')
+        self.assertEqual(self._render(Decimal('6.00'), 'm3ph', Decimal('100.0000')), '6.00 m³/h')
+        self.assertEqual(self._render(Decimal('10.00'), 'gpm', Decimal('37.8541')), '10.00 GPM')
+
+    def test_metric_converts_gpm_to_lpm(self):
+        self.assertEqual(self._render(10, 'gpm', Decimal('37.8541'), system='metric'), '37.85 L/min')
+
+    def test_imperial_converts_lpm_to_gpm(self):
+        # 100 L/min → 100 / 3.785411784 = 26.42 GPM
+        self.assertEqual(self._render(100, 'lpm', Decimal('100.0000'), system='imperial'), '26.42 GPM')
+
+    def test_imperial_no_conversion_for_imperial_unit(self):
+        self.assertEqual(self._render(10, 'gpm', Decimal('37.8541'), system='imperial'), '10 GPM')
+
+
 class ObjectsTablePanelTestCase(TestCase):
     """
     Verify that ObjectsTablePanel.should_render() hides the panel when

+ 103 - 0
netbox/netbox/ui/attrs.py

@@ -14,8 +14,10 @@ __all__ = (
     'ChoiceAttr',
     'ColorAttr',
     'DateTimeAttr',
+    'DiameterAttr',
     'DistanceAttr',
     'DurationAttr',
+    'FlowRateAttr',
     'GPSCoordinatesAttr',
     'GenericForeignKeyAttr',
     'ImageAttr',
@@ -605,6 +607,17 @@ IMPERIAL_WEIGHT = {'lb', 'oz'}
 METRIC_WEIGHT = {'kg', 'g'}
 IMPERIAL_DISTANCE = {'mi', 'ft'}
 METRIC_DISTANCE = {'km', 'm'}
+IMPERIAL_DIAMETER = {'in'}
+METRIC_DIAMETER = {'mm', 'cm'}
+IMPERIAL_FLOW_RATE = {'gpm'}
+METRIC_FLOW_RATE = {'lpm', 'm3ph'}
+
+# Abbreviations for the flow rate units, whose stored values are not themselves presentable
+FLOW_RATE_ABBREVIATIONS = {
+    'lpm': 'L/min',
+    'm3ph': 'm³/h',
+    'gpm': 'GPM',
+}
 
 
 def compute_weight_display(weight, weight_unit, abs_weight, system):
@@ -623,6 +636,30 @@ def compute_weight_display(weight, weight_unit, abs_weight, system):
     return weight, weight_unit
 
 
+def compute_diameter_display(diameter, diameter_unit, abs_diameter, system):
+    """
+    Return (display_value, display_unit) for a diameter, respecting the user's measurement system.
+    abs_diameter is in millimeters (from DiameterMixin._abs_diameter).
+    """
+    if system == 'metric' and diameter_unit in IMPERIAL_DIAMETER and abs_diameter is not None:
+        return round(float(abs_diameter), 2), 'mm'
+    if system == 'imperial' and diameter_unit in METRIC_DIAMETER and abs_diameter is not None:
+        return round(float(abs_diameter) / 25.4, 2), 'in'
+    return diameter, diameter_unit
+
+
+def compute_flow_rate_display(flow_rate, flow_rate_unit, abs_flow_rate, system):
+    """
+    Return (display_value, display_unit) for a flow rate, respecting the user's measurement system.
+    abs_flow_rate is in liters per minute (from MaxFlowMixin._abs_max_flow).
+    """
+    if system == 'metric' and flow_rate_unit in IMPERIAL_FLOW_RATE and abs_flow_rate is not None:
+        return round(float(abs_flow_rate), 2), FLOW_RATE_ABBREVIATIONS['lpm']
+    if system == 'imperial' and flow_rate_unit in METRIC_FLOW_RATE and abs_flow_rate is not None:
+        return round(float(abs_flow_rate) / 3.785411784, 2), FLOW_RATE_ABBREVIATIONS['gpm']
+    return flow_rate, FLOW_RATE_ABBREVIATIONS.get(flow_rate_unit, flow_rate_unit)
+
+
 def compute_distance_display(distance, distance_unit, abs_distance, system):
     """
     Return (display_value, display_unit) for a distance, respecting the user's measurement system.
@@ -706,3 +743,69 @@ class DistanceAttr(ObjectAttribute):
             'value': display_value,
             'unit': display_unit,
         })
+
+
+class DiameterAttr(ObjectAttribute):
+    """
+    A diameter attribute that converts to the user's preferred measurement system.
+
+    Parameters:
+        unit_attr (str): Name of the field holding the diameter unit (default: 'diameter_unit')
+        abs_attr (str): The internal _abs_diameter field name on DiameterMixin (stored in millimeters).
+            Accessed via Python — not subject to Django's template underscore restriction.
+    """
+    template_name = 'ui/attrs/measurement.html'
+
+    def __init__(self, *args, unit_attr='diameter_unit', abs_attr='_abs_diameter', **kwargs):
+        super().__init__(*args, **kwargs)
+        self.unit_attr = unit_attr
+        self.abs_attr = abs_attr
+
+    def render(self, obj, context):
+        diameter = resolve_attr_path(obj, self.accessor)
+        if diameter is None:
+            return self.placeholder
+
+        system = (context.get('preferences') or {}).get('ui.measurement_system') or ''
+        unit = resolve_attr_path(obj, self.unit_attr)
+        abs_diameter = resolve_attr_path(obj, self.abs_attr)
+        display_value, display_unit = compute_diameter_display(diameter, unit, abs_diameter, system)
+
+        return render_to_string(self.template_name, {
+            'name': context['name'],
+            'value': display_value,
+            'unit': display_unit,
+        })
+
+
+class FlowRateAttr(ObjectAttribute):
+    """
+    A flow rate attribute that converts to the user's preferred measurement system.
+
+    Parameters:
+        unit_attr (str): Name of the field holding the flow rate unit (default: 'max_flow_unit')
+        abs_attr (str): The internal _abs_max_flow field name on MaxFlowMixin (stored in liters per
+            minute). Accessed via Python — not subject to Django's template underscore restriction.
+    """
+    template_name = 'ui/attrs/measurement.html'
+
+    def __init__(self, *args, unit_attr='max_flow_unit', abs_attr='_abs_max_flow', **kwargs):
+        super().__init__(*args, **kwargs)
+        self.unit_attr = unit_attr
+        self.abs_attr = abs_attr
+
+    def render(self, obj, context):
+        flow_rate = resolve_attr_path(obj, self.accessor)
+        if flow_rate is None:
+            return self.placeholder
+
+        system = (context.get('preferences') or {}).get('ui.measurement_system') or ''
+        unit = resolve_attr_path(obj, self.unit_attr)
+        abs_flow_rate = resolve_attr_path(obj, self.abs_attr)
+        display_value, display_unit = compute_flow_rate_display(flow_rate, unit, abs_flow_rate, system)
+
+        return render_to_string(self.template_name, {
+            'name': context['name'],
+            'value': display_value,
+            'unit': display_unit,
+        })

+ 14 - 0
netbox/templates/dcim/buttons/bulk_add_components.html

@@ -32,6 +32,20 @@
         </button>
       </li>
     {% endif %}
+    {% if perms.dcim.add_coolingintake %}
+      <li>
+        <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_coolingintake' %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="dropdown-item">
+          {% trans "Cooling Intakes" %}
+        </button>
+      </li>
+    {% endif %}
+    {% if perms.dcim.add_coolingoutflow %}
+      <li>
+        <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_coolingoutflow' %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="dropdown-item">
+          {% trans "Cooling Outflows" %}
+        </button>
+      </li>
+    {% endif %}
     {% if perms.dcim.add_interface %}
       <li>
           <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_interface' %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="dropdown-item">

+ 6 - 0
netbox/templates/dcim/device/base.html

@@ -33,6 +33,12 @@
                 {% if perms.dcim.add_poweroutlet %}
                     <li><a class="dropdown-item" href="{% url 'dcim:poweroutlet_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}">{% trans "Power Outlets" %}</a></li>
                 {% endif %}
+                {% if perms.dcim.add_coolingintake %}
+                    <li><a class="dropdown-item" href="{% url 'dcim:coolingintake_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_coolingintakes' pk=object.pk %}">{% trans "Cooling Intakes" %}</a></li>
+                {% endif %}
+                {% if perms.dcim.add_coolingoutflow %}
+                    <li><a class="dropdown-item" href="{% url 'dcim:coolingoutflow_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_coolingoutflows' pk=object.pk %}">{% trans "Cooling Outflows" %}</a></li>
+                {% endif %}
                 {% if perms.dcim.add_interface %}
                     <li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">{% trans "Interfaces" %}</a></li>
                 {% endif %}

+ 8 - 1
netbox/templates/dcim/device_edit.html

@@ -22,11 +22,18 @@
         <h2 class="col-9 offset-3">{% trans "Hardware" %}</h2>
       </div>
       {% render_field form.device_type %}
-      {% render_field form.airflow %}
       {% render_field form.serial %}
       {% render_field form.asset_tag %}
     </div>
 
+    <div class="field-group my-5">
+      <div class="row">
+        <h2 class="col-9 offset-3">{% trans "Cooling" %}</h2>
+      </div>
+      {% render_field form.cooling_method %}
+      {% render_field form.airflow %}
+    </div>
+
     <div class="field-group my-5">
       <div class="row">
         <h2 class="col-9 offset-3">{% trans "Location" %}</h2>

+ 6 - 0
netbox/templates/dcim/devicetype/base.html

@@ -25,6 +25,12 @@
         {% if perms.dcim.add_poweroutlettemplate %}
           <li><a class="dropdown-item" href="{% url 'dcim:poweroutlettemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_poweroutlets' pk=object.pk %}">{% trans "Power Outlets" %}</a></li>
         {% endif %}
+        {% if perms.dcim.add_coolingintaketemplate %}
+          <li><a class="dropdown-item" href="{% url 'dcim:coolingintaketemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_coolingintakes' pk=object.pk %}">{% trans "Cooling Intakes" %}</a></li>
+        {% endif %}
+        {% if perms.dcim.add_coolingoutflowtemplate %}
+          <li><a class="dropdown-item" href="{% url 'dcim:coolingoutflowtemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_coolingoutflows' pk=object.pk %}">{% trans "Cooling Outflows" %}</a></li>
+        {% endif %}
         {% if perms.dcim.add_interfacetemplate %}
           <li><a class="dropdown-item" href="{% url 'dcim:interfacetemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_interfaces' pk=object.pk %}">{% trans "Interfaces" %}</a></li>
         {% endif %}

+ 8 - 0
netbox/templates/ui/attrs/measurement.html

@@ -0,0 +1,8 @@
+{# As numeric.html, but renders the unit verbatim. Attributes which resolve their own unit abbreviation #}
+{# (e.g. DiameterAttr, FlowRateAttr) already supply correct casing, which "L/min" and "GPM" depend on. #}
+<span>
+  <span{% if name %} id="attr_{{ name }}"{% endif %}>{{ value }}</span>
+  {% if unit %}
+    {{ unit }}
+  {% endif %}
+</span>

+ 70 - 45
netbox/utilities/conversion.py

@@ -3,66 +3,91 @@ from decimal import Decimal, InvalidOperation
 from django.utils.translation import gettext as _
 
 from dcim.choices import CableLengthUnitChoices
-from netbox.choices import WeightUnitChoices
+from netbox.choices import (
+    DiameterUnitChoices,
+    FlowRateUnitChoices,
+    WeightUnitChoices,
+)
 
 __all__ = (
     'to_grams',
+    'to_liters_per_minute',
     'to_meters',
+    'to_millimeters',
 )
 
 
-def to_grams(weight, unit) -> int:
+def _normalized_measurement(value, unit, converters, quantity, precision=4) -> Decimal:
     """
-    Convert the given weight to integer grams.
+    Shared implementation for the unit-normalization helpers below. Coerce `value` to a non-negative
+    Decimal, apply the matching per-unit converter, and round the result to `precision` decimal places.
+    `converters` maps each valid unit to a callable receiving the Decimal value; `quantity` labels the
+    value in error messages. Pass `precision=None` to return the unrounded result.
     """
     try:
-        if weight < 0:
-            raise ValueError(_("Weight must be a positive number"))
-    except TypeError:
-        raise TypeError(_("Invalid value '{weight}' for weight (must be a number)").format(weight=weight))
-
-    if unit == WeightUnitChoices.UNIT_KILOGRAM:
-        return int(weight * 1000)
-    if unit == WeightUnitChoices.UNIT_GRAM:
-        return int(weight)
-    if unit == WeightUnitChoices.UNIT_POUND:
-        return int(weight * Decimal(453.592))
-    if unit == WeightUnitChoices.UNIT_OUNCE:
-        return int(weight * Decimal(28.3495))
-    raise ValueError(
-        _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
-            unit=unit,
-            valid_units=', '.join(WeightUnitChoices.values())
+        value = Decimal(value)
+    except (InvalidOperation, TypeError, ValueError):
+        raise TypeError(
+            _("Invalid value '{value}' for {quantity} (must be a number)").format(value=value, quantity=quantity)
+        )
+    if value < 0:
+        raise ValueError(_("Invalid value for {quantity}: must be a positive number").format(quantity=quantity))
+    if unit not in converters:
+        raise ValueError(
+            _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
+                unit=unit,
+                valid_units=', '.join(converters)
+            )
         )
-    )
+    result = converters[unit](value)
+    return result if precision is None else round(result, precision)
+
+
+def to_grams(weight, unit) -> int:
+    """
+    Convert the given weight to integer grams.
+    """
+    # Rounding is suppressed so that the result is truncated exactly as the caller's int() expects; rounding
+    # first could nudge a value across an integer boundary.
+    return int(_normalized_measurement(weight, unit, {
+        WeightUnitChoices.UNIT_KILOGRAM: lambda v: v * 1000,
+        WeightUnitChoices.UNIT_GRAM: lambda v: v,
+        WeightUnitChoices.UNIT_POUND: lambda v: v * Decimal(453.592),
+        WeightUnitChoices.UNIT_OUNCE: lambda v: v * Decimal(28.3495),
+    }, _('weight'), precision=None))
 
 
 def to_meters(length, unit) -> Decimal:
     """
     Convert the given length to meters, returning a Decimal value.
     """
-    try:
-        length = Decimal(length)
-    except InvalidOperation:
-        raise TypeError(_("Invalid value '{length}' for length (must be a number)").format(length=length))
-    if length < 0:
-        raise ValueError(_("Length must be a positive number"))
+    return _normalized_measurement(length, unit, {
+        CableLengthUnitChoices.UNIT_KILOMETER: lambda v: v * 1000,
+        CableLengthUnitChoices.UNIT_METER: lambda v: v,
+        CableLengthUnitChoices.UNIT_CENTIMETER: lambda v: v / 100,
+        CableLengthUnitChoices.UNIT_MILE: lambda v: v * Decimal(1609.344),
+        CableLengthUnitChoices.UNIT_FOOT: lambda v: v * Decimal(0.3048),
+        CableLengthUnitChoices.UNIT_INCH: lambda v: v * Decimal(0.0254),
+    }, _('length'))
 
-    if unit == CableLengthUnitChoices.UNIT_KILOMETER:
-        return round(Decimal(length * 1000), 4)
-    if unit == CableLengthUnitChoices.UNIT_METER:
-        return round(Decimal(length), 4)
-    if unit == CableLengthUnitChoices.UNIT_CENTIMETER:
-        return round(Decimal(length / 100), 4)
-    if unit == CableLengthUnitChoices.UNIT_MILE:
-        return round(length * Decimal(1609.344), 4)
-    if unit == CableLengthUnitChoices.UNIT_FOOT:
-        return round(length * Decimal(0.3048), 4)
-    if unit == CableLengthUnitChoices.UNIT_INCH:
-        return round(length * Decimal(0.0254), 4)
-    raise ValueError(
-        _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
-            unit=unit,
-            valid_units=', '.join(CableLengthUnitChoices.values())
-        )
-    )
+
+def to_millimeters(diameter, unit) -> Decimal:
+    """
+    Convert the given diameter to millimeters, returning a Decimal value.
+    """
+    return _normalized_measurement(diameter, unit, {
+        DiameterUnitChoices.UNIT_MILLIMETER: lambda v: v,
+        DiameterUnitChoices.UNIT_CENTIMETER: lambda v: v * 10,
+        DiameterUnitChoices.UNIT_INCH: lambda v: v * Decimal('25.4'),
+    }, _('diameter'))
+
+
+def to_liters_per_minute(flow_rate, unit) -> Decimal:
+    """
+    Convert the given flow rate to liters per minute, returning a Decimal value.
+    """
+    return _normalized_measurement(flow_rate, unit, {
+        FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE: lambda v: v,
+        FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR: lambda v: v * Decimal(1000) / Decimal(60),
+        FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE: lambda v: v * Decimal('3.785411784'),
+    }, _('flow rate'))

+ 28 - 0
netbox/utilities/templatetags/helpers.py

@@ -10,7 +10,9 @@ from django.utils.translation import gettext_lazy as _
 from core.models import ObjectType
 from netbox.settings import DISK_BASE_UNIT, RAM_BASE_UNIT
 from netbox.ui.attrs import (
+    compute_diameter_display,
     compute_distance_display,
+    compute_flow_rate_display,
     compute_weight_display,
 )
 from utilities.forms import TableConfigForm, get_selected_values
@@ -21,7 +23,9 @@ __all__ = (
     'action_url',
     'applied_filters',
     'as_range',
+    'display_diameter',
     'display_distance',
+    'display_flow_rate',
     'display_weight',
     'divide',
     'get_item',
@@ -360,6 +364,30 @@ def display_distance(context, distance, distance_unit, abs_distance):
     return f'{value:g} {unit}'
 
 
+@register.simple_tag(takes_context=True)
+def display_diameter(context, diameter, diameter_unit, abs_diameter):
+    """
+    Render a diameter value respecting the user's ui.measurement_system preference.
+    """
+    if diameter is None:
+        return ''
+    system = (context.get('preferences') or {}).get('ui.measurement_system') or ''
+    value, unit = compute_diameter_display(diameter, diameter_unit, abs_diameter, system)
+    return f'{value:g} {unit}'
+
+
+@register.simple_tag(takes_context=True)
+def display_flow_rate(context, flow_rate, flow_rate_unit, abs_flow_rate):
+    """
+    Render a flow rate value respecting the user's ui.measurement_system preference.
+    """
+    if flow_rate is None:
+        return ''
+    system = (context.get('preferences') or {}).get('ui.measurement_system') or ''
+    value, unit = compute_flow_rate_display(flow_rate, flow_rate_unit, abs_flow_rate, system)
+    return f'{value:g} {unit}'
+
+
 @register.filter("startswith")
 def startswith(text: str, starts: str) -> bool:
     """

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

@@ -1,8 +1,17 @@
 from decimal import Decimal
 
 from dcim.choices import CableLengthUnitChoices
-from netbox.choices import WeightUnitChoices
-from utilities.conversion import to_grams, to_meters
+from netbox.choices import (
+    DiameterUnitChoices,
+    FlowRateUnitChoices,
+    WeightUnitChoices,
+)
+from utilities.conversion import (
+    to_grams,
+    to_liters_per_minute,
+    to_meters,
+    to_millimeters,
+)
 from utilities.testing.base import TestCase
 
 
@@ -25,6 +34,34 @@ class ConversionsTestCase(TestCase):
             to_grams(1, WeightUnitChoices.UNIT_OUNCE),
             28
         )
+        # The result is truncated, not rounded
+        self.assertEqual(
+            to_grams(Decimal('1.9999'), WeightUnitChoices.UNIT_GRAM),
+            1
+        )
+        with self.assertRaises(ValueError):
+            to_grams(1, 'invalid')
+        with self.assertRaises(ValueError):
+            to_grams(-1, WeightUnitChoices.UNIT_GRAM)
+        with self.assertRaises(TypeError):
+            to_grams('abc', WeightUnitChoices.UNIT_GRAM)
+        with self.assertRaises(TypeError):
+            to_grams(None, WeightUnitChoices.UNIT_GRAM)
+
+    def test_invalid_values(self):
+        # A non-numeric value is reported as a TypeError rather than surfacing a raw Decimal error
+        for converter, unit in (
+            (to_meters, CableLengthUnitChoices.UNIT_METER),
+            (to_millimeters, DiameterUnitChoices.UNIT_MILLIMETER),
+            (to_liters_per_minute, FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE),
+        ):
+            with self.subTest(converter=converter.__name__):
+                with self.assertRaises(TypeError):
+                    converter(None, unit)
+                with self.assertRaises(TypeError):
+                    converter('abc', unit)
+                with self.assertRaises(ValueError):
+                    converter(-1, unit)
 
     def test_to_meters(self):
         self.assertEqual(
@@ -51,3 +88,37 @@ class ConversionsTestCase(TestCase):
             to_meters(1, CableLengthUnitChoices.UNIT_INCH),
             Decimal('0.0254')
         )
+
+    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')

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff