Quellcode durchsuchen

22447 - Add Cooling infrastructure modeling

Arthur vor 1 Woche
Ursprung
Commit
c09b3ee614
62 geänderte Dateien mit 7018 neuen und 48 gelöschten Zeilen
  1. 54 0
      docs/models/dcim/coolingfeed.md
  2. 48 0
      docs/models/dcim/coolingoutlet.md
  3. 3 0
      docs/models/dcim/coolingoutlettemplate.md
  4. 48 0
      docs/models/dcim/coolingport.md
  5. 3 0
      docs/models/dcim/coolingporttemplate.md
  6. 40 0
      docs/models/dcim/coolingsource.md
  7. 4 0
      docs/models/dcim/device.md
  8. 4 0
      docs/models/dcim/devicetype.md
  9. 12 0
      docs/models/dcim/rack.md
  10. 6 0
      mkdocs.yml
  11. 1 0
      netbox/dcim/api/serializers.py
  12. 87 0
      netbox/dcim/api/serializers_/cooling.py
  13. 95 0
      netbox/dcim/api/serializers_/device_components.py
  14. 7 4
      netbox/dcim/api/serializers_/devices.py
  15. 91 0
      netbox/dcim/api/serializers_/devicetype_components.py
  16. 9 5
      netbox/dcim/api/serializers_/devicetypes.py
  17. 8 0
      netbox/dcim/api/urls.py
  18. 50 0
      netbox/dcim/api/views.py
  19. 159 0
      netbox/dcim/choices.py
  20. 10 0
      netbox/dcim/constants.py
  21. 257 5
      netbox/dcim/filtersets.py
  22. 25 0
      netbox/dcim/forms/bulk_create.py
  23. 354 5
      netbox/dcim/forms/bulk_edit.py
  24. 238 6
      netbox/dcim/forms/bulk_import.py
  25. 259 2
      netbox/dcim/forms/filtersets.py
  26. 171 6
      netbox/dcim/forms/model_forms.py
  27. 28 0
      netbox/dcim/forms/object_create.py
  28. 42 0
      netbox/dcim/forms/object_import.py
  29. 16 0
      netbox/dcim/graphql/enums.py
  30. 144 0
      netbox/dcim/graphql/filters.py
  31. 18 0
      netbox/dcim/graphql/schema.py
  32. 88 0
      netbox/dcim/graphql/types.py
  33. 896 0
      netbox/dcim/migrations/0241_device_cooling_method_device_cooling_outlet_count_and_more.py
  34. 1 0
      netbox/dcim/models/__init__.py
  35. 251 0
      netbox/dcim/models/cooling.py
  36. 172 0
      netbox/dcim/models/device_component_templates.py
  37. 124 0
      netbox/dcim/models/device_components.py
  38. 48 3
      netbox/dcim/models/devices.py
  39. 18 0
      netbox/dcim/models/modules.py
  40. 23 3
      netbox/dcim/models/racks.py
  41. 45 0
      netbox/dcim/search.py
  42. 1 0
      netbox/dcim/tables/__init__.py
  43. 324 0
      netbox/dcim/tables/cooling.py
  44. 5 1
      netbox/dcim/tables/devices.py
  45. 4 1
      netbox/dcim/tables/devicetypes.py
  46. 12 1
      netbox/dcim/tables/racks.py
  47. 94 0
      netbox/dcim/tables/template_code.py
  48. 10 0
      netbox/dcim/tests/query_counts.json
  49. 297 0
      netbox/dcim/tests/test_api.py
  50. 1063 0
      netbox/dcim/tests/test_filtersets.py
  51. 123 0
      netbox/dcim/tests/test_models.py
  52. 409 0
      netbox/dcim/tests/test_views.py
  53. 67 0
      netbox/dcim/ui/panels.py
  54. 28 0
      netbox/dcim/urls.py
  55. 558 6
      netbox/dcim/views.py
  56. 17 0
      netbox/netbox/navigation/menu.py
  57. 10 0
      netbox/templates/dcim/coolingfeed.html
  58. 9 0
      netbox/templates/dcim/coolingoutlet.html
  59. 9 0
      netbox/templates/dcim/coolingport.html
  60. 9 0
      netbox/templates/dcim/coolingsource.html
  61. 6 0
      netbox/templates/dcim/device/base.html
  62. 6 0
      netbox/templates/dcim/devicetype/base.html

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

@@ -0,0 +1,54 @@
+# 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). It is the cooling equivalent of a [power feed](./powerfeed.md). A [cooling port](./coolingport.md) on a device can be connected via a cooling hose cable to a cooling feed.
+
+Because a coolant loop has both a cold (supply) and a warm (return) side, supply and return are represented as separate feeds so that each path can be traced independently.
+
+## 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.
+
+### Type
+
+Indicates whether the feed carries supply (cold) or return (warm) coolant.
+
+### Fluid Type
+
+The coolant used in the loop (e.g. water, water/glycol, dielectric fluid, or refrigerant).
+
+### Cooling Capacity
+
+The heat-removal capacity of the feed, in kilowatts (kW).
+
+### Flow Rate
+
+The coolant flow rate, in litres per minute (L/min).
+
+### Pressure
+
+The operating pressure of the loop, in kilopascals (kPa).
+
+### Supply / Return Temperature
+
+The supply and return coolant temperatures, in degrees Celsius (°C).
+
+### Mark Connected
+
+If selected, the cooling feed will be treated as if a cable has been connected.

+ 48 - 0
docs/models/dcim/coolingoutlet.md

@@ -0,0 +1,48 @@
+# Cooling Outlets
+
+A cooling outlet is a device component which delivers coolant to a downstream [cooling port](./coolingport.md), and generally represents an outlet on a coolant distribution unit (CDU) or manifold. A cooling outlet may optionally be associated with an upstream cooling port on the same device for path tracing.
+
+!!! tip
+    Like most device components, cooling outlets are instantiated automatically from [cooling outlet templates](./coolingoutlettemplate.md) assigned to the selected device type when a device is created.
+
+## Fields
+
+### Device
+
+The device to which this cooling outlet belongs.
+
+### Module
+
+The installed module within the assigned device to which this cooling outlet belongs (optional).
+
+### Name
+
+The name of the cooling outlet. Must be unique to the parent device.
+
+### Label
+
+An alternative physical label identifying the cooling outlet.
+
+### Type
+
+Indicates whether the outlet carries supply (cold) or return (warm) coolant.
+
+### Connector Type
+
+The physical coolant connector type (e.g. UQD, QDC, blind-mate, or threaded).
+
+### Diameter
+
+The nominal connector diameter.
+
+### Cooling Port
+
+The upstream [cooling port](./coolingport.md) on the same device which feeds this outlet (optional).
+
+### Color
+
+The color of the outlet (for organizational purposes).
+
+### Mark Connected
+
+If selected, this component will be treated as if a cable has been connected.

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

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

+ 48 - 0
docs/models/dcim/coolingport.md

@@ -0,0 +1,48 @@
+# Cooling Ports
+
+A cooling port is a device component which represents a coolant intake or outlet on a device, such as a server cold-plate inlet or a coolant distribution unit (CDU) intake. A cooling port can be connected via a cooling hose cable to a [cooling outlet](./coolingoutlet.md) or a [cooling feed](./coolingfeed.md).
+
+!!! tip
+    Like most device components, cooling ports are instantiated automatically from [cooling port templates](./coolingporttemplate.md) assigned to the selected device type when a device is created.
+
+## Fields
+
+### Device
+
+The device to which this cooling port belongs.
+
+### Module
+
+The installed module within the assigned device to which this cooling port belongs (optional).
+
+### Name
+
+The name of the cooling port. Must be unique to the parent device.
+
+### Label
+
+An alternative physical label identifying the cooling port.
+
+### Type
+
+Indicates whether the port carries supply (cold) or return (warm) coolant.
+
+### Connector Type
+
+The physical coolant connector type (e.g. UQD, QDC, blind-mate, or threaded).
+
+### Diameter
+
+The nominal connector diameter.
+
+### Maximum Flow
+
+The maximum coolant flow rate this port supports, in litres per minute (L/min).
+
+### Heat Capacity
+
+The heat-removal capacity of this port, in kilowatts (kW).
+
+### Mark Connected
+
+If selected, this component will be treated as if a cable has been connected.

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

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

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

@@ -0,0 +1,40 @@
+# Cooling Source
+
+A cooling source represents a facility-level source of cooling, such as a chiller, cooling tower, or dry cooler. It is the cooling equivalent of a [power panel](./powerpanel.md): 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](../../models/dcim/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.
+
+### Cooling Capacity
+
+The total heat-removal capacity of the source, expressed in kilowatts (kW).
+
+### Supply Temperature
+
+The design supply (cold) coolant temperature, in degrees Celsius (°C).
+
+### Return Temperature
+
+The design return (warm) coolant temperature, in degrees Celsius (°C).

+ 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).

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

@@ -51,5 +51,17 @@ The unique physical serial number assigned to this rack.
 
 A unique, locally-administered label used to identify hardware resources.
 
+### Cooling Capability
+
+The rack's coolant capability: air-only, liquid-capable, or liquid-required.
+
+### Has RDHx
+
+Indicates whether the rack is equipped with a rear-door heat exchanger (RDHx).
+
+### Cooling Capacity
+
+The rack's cooling capacity, expressed in kilowatts (kW).
+
 !!! note
     Some additional fields pertaining to physical attributes such as height and weight can also be defined on each rack, but should generally be defined instead on the [rack type](./racktype.md).

+ 6 - 0
mkdocs.yml

@@ -199,6 +199,12 @@ nav:
             - ConsolePortTemplate: 'models/dcim/consoleporttemplate.md'
             - ConsoleServerPort: 'models/dcim/consoleserverport.md'
             - ConsoleServerPortTemplate: 'models/dcim/consoleserverporttemplate.md'
+            - CoolingFeed: 'models/dcim/coolingfeed.md'
+            - CoolingOutlet: 'models/dcim/coolingoutlet.md'
+            - CoolingOutletTemplate: 'models/dcim/coolingoutlettemplate.md'
+            - CoolingPort: 'models/dcim/coolingport.md'
+            - CoolingPortTemplate: 'models/dcim/coolingporttemplate.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 *

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

@@ -0,0 +1,87 @@
+from dcim.choices import *
+from dcim.models import CoolingFeed, CoolingSource
+from netbox.api.fields import ChoiceField, RelatedObjectCountField
+from netbox.api.serializers import PrimaryModelSerializer
+from tenancy.api.serializers_.tenants import TenantSerializer
+
+from .base import ConnectedEndpointsSerializer
+from .cables import CabledObjectSerializer
+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,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    status = ChoiceField(
+        choices=CoolingSourceStatusChoices,
+        default=lambda: CoolingSourceStatusChoices.STATUS_ACTIVE,
+    )
+
+    # Related object counts
+    cooling_feed_count = RelatedObjectCountField('cooling_feeds')
+
+    class Meta:
+        model = CoolingSource
+        fields = [
+            'id', 'url', 'display_url', 'display', 'site', 'location', 'name', 'type', 'status', 'cooling_capacity',
+            'supply_temperature', 'return_temperature', 'description', 'owner', 'comments', 'tags', 'custom_fields',
+            'cooling_feed_count', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'cooling_feed_count')
+
+
+class CoolingFeedSerializer(PrimaryModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
+    cooling_source = CoolingSourceSerializer(nested=True)
+    rack = RackSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    type = ChoiceField(
+        choices=CoolingFeedTypeChoices,
+        default=lambda: CoolingFeedTypeChoices.TYPE_SUPPLY,
+    )
+    status = ChoiceField(
+        choices=CoolingFeedStatusChoices,
+        default=lambda: CoolingFeedStatusChoices.STATUS_ACTIVE,
+    )
+    fluid_type = ChoiceField(
+        choices=FluidTypeChoices,
+        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', 'type', 'fluid_type',
+            'cooling_capacity', 'flow_rate', 'pressure', 'supply_temperature', 'return_temperature', 'mark_connected',
+            'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type',
+            'connected_endpoints_reachable', 'description', 'tenant', 'owner', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated', '_occupied',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'cable', '_occupied')

+ 95 - 0
netbox/dcim/api/serializers_/device_components.py

@@ -7,6 +7,8 @@ from dcim.constants import *
 from dcim.models import (
     ConsolePort,
     ConsoleServerPort,
+    CoolingOutlet,
+    CoolingPort,
     DeviceBay,
     FrontPort,
     Interface,
@@ -41,6 +43,8 @@ from .roles import InventoryItemRoleSerializer
 __all__ = (
     'ConsolePortSerializer',
     'ConsoleServerPortSerializer',
+    'CoolingOutletSerializer',
+    'CoolingPortSerializer',
     'DeviceBaySerializer',
     'FrontPortSerializer',
     'InterfaceSerializer',
@@ -196,6 +200,97 @@ class PowerOutletSerializer(
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
+class CoolingPortSerializer(
+    OwnerMixin,
+    NetBoxModelSerializer,
+    CabledObjectSerializer,
+    ConnectedEndpointsSerializer
+):
+    device = DeviceSerializer(nested=True)
+    module = ModuleSerializer(
+        nested=True,
+        fields=('id', 'url', 'display', 'device', 'module_bay'),
+        required=False,
+        allow_null=True
+    )
+    type = ChoiceField(
+        choices=CoolingFeedTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    connector_type = ChoiceField(
+        choices=CoolingConnectorTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    diameter = ChoiceField(
+        choices=CoolingDiameterChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = CoolingPort
+        fields = [
+            'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'connector_type',
+            'diameter', 'maximum_flow', 'heat_capacity', 'description', 'mark_connected', 'cable', 'cable_end',
+            'link_peers', 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type',
+            'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
+        ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
+
+
+class CoolingOutletSerializer(
+    OwnerMixin,
+    NetBoxModelSerializer,
+    CabledObjectSerializer,
+    ConnectedEndpointsSerializer
+):
+    device = DeviceSerializer(nested=True)
+    module = ModuleSerializer(
+        nested=True,
+        fields=('id', 'url', 'display', 'device', 'module_bay'),
+        required=False,
+        allow_null=True
+    )
+    type = ChoiceField(
+        choices=CoolingFeedTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    connector_type = ChoiceField(
+        choices=CoolingConnectorTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    diameter = ChoiceField(
+        choices=CoolingDiameterChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    cooling_port = CoolingPortSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = CoolingOutlet
+        fields = [
+            'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'connector_type',
+            'diameter', 'color', 'cooling_port', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
+            'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
+            'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
+        ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
+
+
 class InterfaceSerializer(
     OwnerMixin,
     NetBoxModelSerializer,

+ 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_port_count',
+            'cooling_outlet_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')
 

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

@@ -6,6 +6,8 @@ from dcim.constants import *
 from dcim.models import (
     ConsolePortTemplate,
     ConsoleServerPortTemplate,
+    CoolingOutletTemplate,
+    CoolingPortTemplate,
     DeviceBayTemplate,
     FrontPortTemplate,
     InterfaceTemplate,
@@ -30,6 +32,8 @@ from .roles import InventoryItemRoleSerializer
 __all__ = (
     'ConsolePortTemplateSerializer',
     'ConsoleServerPortTemplateSerializer',
+    'CoolingOutletTemplateSerializer',
+    'CoolingPortTemplateSerializer',
     'DeviceBayTemplateSerializer',
     'FrontPortTemplateSerializer',
     'InterfaceTemplateSerializer',
@@ -170,6 +174,93 @@ class PowerOutletTemplateSerializer(ComponentTemplateSerializer):
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
+class CoolingPortTemplateSerializer(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=CoolingFeedTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    connector_type = ChoiceField(
+        choices=CoolingConnectorTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    diameter = ChoiceField(
+        choices=CoolingDiameterChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = CoolingPortTemplate
+        fields = [
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'connector_type',
+            'diameter', 'maximum_flow', 'heat_capacity', 'description', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
+class CoolingOutletTemplateSerializer(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=CoolingFeedTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    connector_type = ChoiceField(
+        choices=CoolingConnectorTypeChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    diameter = ChoiceField(
+        choices=CoolingDiameterChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
+    cooling_port = CoolingPortTemplateSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = CoolingOutletTemplate
+        fields = [
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'connector_type',
+            'diameter', 'color', 'cooling_port', 'description', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
 class InterfaceTemplateSerializer(ComponentTemplateSerializer):
     device_type = DeviceTypeSerializer(
         nested=True,

+ 9 - 5
netbox/dcim/api/serializers_/devicetypes.py

@@ -31,6 +31,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)
@@ -40,6 +41,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_port_template_count = serializers.IntegerField(read_only=True)
+    cooling_outlet_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)
@@ -52,12 +55,13 @@ 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', 'front_image', 'rear_image', 'description', 'owner', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated', 'device_count', 'console_port_template_count',
+            'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'cooling_method',
+            'weight', 'weight_unit', '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_port_template_count', 'cooling_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',
         ]
         brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
 

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

@@ -29,6 +29,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-port-templates', views.CoolingPortTemplateViewSet)
+router.register('cooling-outlet-templates', views.CoolingOutletTemplateViewSet)
 router.register('interface-templates', views.InterfaceTemplateViewSet)
 router.register('front-port-templates', views.FrontPortTemplateViewSet)
 router.register('rear-port-templates', views.RearPortTemplateViewSet)
@@ -48,6 +50,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-ports', views.CoolingPortViewSet)
+router.register('cooling-outlets', views.CoolingOutletViewSet)
 router.register('interfaces', views.InterfaceViewSet)
 router.register('front-ports', views.FrontPortViewSet)
 router.register('rear-ports', views.RearPortViewSet)
@@ -73,6 +77,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')
 

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

@@ -326,6 +326,18 @@ class PowerOutletTemplateViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.PowerOutletTemplateFilterSet
 
 
+class CoolingPortTemplateViewSet(NetBoxModelViewSet):
+    queryset = CoolingPortTemplate.objects.all()
+    serializer_class = serializers.CoolingPortTemplateSerializer
+    filterset_class = filtersets.CoolingPortTemplateFilterSet
+
+
+class CoolingOutletTemplateViewSet(NetBoxModelViewSet):
+    queryset = CoolingOutletTemplate.objects.all()
+    serializer_class = serializers.CoolingOutletTemplateSerializer
+    filterset_class = filtersets.CoolingOutletTemplateFilterSet
+
+
 class InterfaceTemplateViewSet(NetBoxModelViewSet):
     queryset = InterfaceTemplate.objects.all()
     serializer_class = serializers.InterfaceTemplateSerializer
@@ -468,6 +480,22 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
     filterset_class = filtersets.PowerOutletFilterSet
 
 
+class CoolingPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
+    queryset = CoolingPort.objects.prefetch_related(
+        '_path', 'cable__terminations',
+    )
+    serializer_class = serializers.CoolingPortSerializer
+    filterset_class = filtersets.CoolingPortFilterSet
+
+
+class CoolingOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
+    queryset = CoolingOutlet.objects.prefetch_related(
+        '_path', 'cable__terminations',
+    )
+    serializer_class = serializers.CoolingOutletSerializer
+    filterset_class = filtersets.CoolingOutletFilterSet
+
+
 class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = Interface.objects.prefetch_related(
         GenericPrefetch(
@@ -609,6 +637,28 @@ 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(PathEndpointMixin, NetBoxModelViewSet):
+    queryset = CoolingFeed.objects.prefetch_related(
+        '_path', 'cable__terminations',
+    )
+    serializer_class = serializers.CoolingFeedSerializer
+    filterset_class = filtersets.CoolingFeedFilterSet
+
+
 #
 # Miscellaneous
 #

+ 159 - 0
netbox/dcim/choices.py

@@ -1883,6 +1883,9 @@ class CableTypeChoices(ChoiceSet):
     # USB
     TYPE_USB = 'usb'
 
+    # Cooling
+    TYPE_COOLING_HOSE = 'cooling-hose'
+
     CHOICES = (
         (
             _('Copper - Twisted Pair (UTP/STP)'),
@@ -1955,6 +1958,12 @@ class CableTypeChoices(ChoiceSet):
                 (TYPE_USB, 'USB'),
             ),
         ),
+        (
+            _('Cooling'),
+            (
+                (TYPE_COOLING_HOSE, 'Cooling Hose'),
+            ),
+        ),
     )
 
 
@@ -2079,6 +2088,156 @@ class PowerOutletStatusChoices(ChoiceSet):
     ]
 
 
+#
+# Cooling
+#
+
+class CoolingMethodChoices(ChoiceSet):
+    key = 'CoolingMethod'
+
+    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):
+
+    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 CoolingFeedTypeChoices(ChoiceSet):
+
+    TYPE_SUPPLY = 'supply'
+    TYPE_RETURN = 'return'
+
+    CHOICES = [
+        (TYPE_SUPPLY, _('Supply'), 'blue'),
+        (TYPE_RETURN, _('Return'), 'red'),
+    ]
+
+
+class FluidTypeChoices(ChoiceSet):
+
+    FLUID_WATER = 'water'
+    FLUID_WATER_GLYCOL = 'water-glycol'
+    FLUID_DIELECTRIC = 'dielectric'
+    FLUID_REFRIGERANT = 'refrigerant'
+    FLUID_OTHER = 'other'
+
+    CHOICES = [
+        (FLUID_WATER, _('Water')),
+        (FLUID_WATER_GLYCOL, _('Water/glycol')),
+        (FLUID_DIELECTRIC, _('Dielectric')),
+        (FLUID_REFRIGERANT, _('Refrigerant')),
+        (FLUID_OTHER, _('Other')),
+    ]
+
+
+class RackCoolingCapabilityChoices(ChoiceSet):
+
+    AIR_ONLY = 'air-only'
+    LIQUID_CAPABLE = 'liquid-capable'
+    LIQUID_REQUIRED = 'liquid-required'
+
+    CHOICES = [
+        (AIR_ONLY, _('Air only'), 'cyan'),
+        (LIQUID_CAPABLE, _('Liquid capable'), 'blue'),
+        (LIQUID_REQUIRED, _('Liquid required'), 'purple'),
+    ]
+
+
+class CoolingConnectorTypeChoices(ChoiceSet):
+
+    TYPE_UQD = 'uqd'
+    TYPE_QDC = 'qdc'
+    TYPE_BLIND_MATE = 'blind-mate'
+    TYPE_THREADED_NPT = 'threaded-npt'
+    TYPE_THREADED_BSP = 'threaded-bsp'
+    TYPE_OTHER = 'other'
+
+    CHOICES = [
+        (TYPE_UQD, _('UQD (Universal Quick Disconnect)')),
+        (TYPE_QDC, _('QDC (Quick Disconnect Coupling)')),
+        (TYPE_BLIND_MATE, _('Blind-mate')),
+        (TYPE_THREADED_NPT, _('Threaded (NPT)')),
+        (TYPE_THREADED_BSP, _('Threaded (BSP)')),
+        (TYPE_OTHER, _('Other')),
+    ]
+
+
+class CoolingDiameterChoices(ChoiceSet):
+
+    DN10 = 'dn10'
+    DN15 = 'dn15'
+    DN20 = 'dn20'
+    DN25 = 'dn25'
+    DN32 = 'dn32'
+    DN40 = 'dn40'
+    DN50 = 'dn50'
+
+    CHOICES = [
+        (DN10, 'DN10'),
+        (DN15, 'DN15'),
+        (DN20, 'DN20'),
+        (DN25, 'DN25'),
+        (DN32, 'DN32'),
+        (DN40, 'DN40'),
+        (DN50, 'DN50'),
+    ]
+
+
 #
 # VDC
 #

+ 10 - 0
netbox/dcim/constants.py

@@ -88,6 +88,8 @@ MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
     model__in=(
         'consoleporttemplate',
         'consoleserverporttemplate',
+        'coolingoutlettemplate',
+        'coolingporttemplate',
         'frontporttemplate',
         'interfacetemplate',
         'poweroutlettemplate',
@@ -100,6 +102,8 @@ MODULAR_COMPONENT_MODELS = Q(
     model__in=(
         'consoleport',
         'consoleserverport',
+        'coolingoutlet',
+        'coolingport',
         'frontport',
         'interface',
         'poweroutlet',
@@ -122,6 +126,9 @@ CABLE_TERMINATION_MODELS = Q(
     Q(app_label='dcim', model__in=(
         'consoleport',
         'consoleserverport',
+        'coolingfeed',
+        'coolingoutlet',
+        'coolingport',
         'frontport',
         'interface',
         'powerfeed',
@@ -135,6 +142,9 @@ COMPATIBLE_TERMINATION_TYPES = {
     'circuittermination': ['interface', 'frontport', 'rearport', 'circuittermination'],
     'consoleport': ['consoleserverport', 'frontport', 'rearport'],
     'consoleserverport': ['consoleport', 'frontport', 'rearport'],
+    'coolingfeed': ['coolingport'],
+    'coolingoutlet': ['coolingport'],
+    'coolingport': ['coolingoutlet', 'coolingfeed'],
     'interface': ['interface', 'circuittermination', 'frontport', 'rearport'],
     'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
     'powerfeed': ['powerport'],

+ 257 - 5
netbox/dcim/filtersets.py

@@ -57,6 +57,12 @@ __all__ = (
     'ConsolePortTemplateFilterSet',
     'ConsoleServerPortFilterSet',
     'ConsoleServerPortTemplateFilterSet',
+    'CoolingFeedFilterSet',
+    'CoolingOutletFilterSet',
+    'CoolingOutletTemplateFilterSet',
+    'CoolingPortFilterSet',
+    'CoolingPortTemplateFilterSet',
+    'CoolingSourceFilterSet',
     'DeviceBayFilterSet',
     'DeviceBayTemplateFilterSet',
     'DeviceFilterSet',
@@ -494,12 +500,18 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS
         lookup_expr='iexact'
     )
 
+    cooling_capability = django_filters.MultipleChoiceFilter(
+        choices=RackCoolingCapabilityChoices,
+        distinct=False,
+    )
+    has_rdhx = django_filters.BooleanFilter()
+
     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):
@@ -727,7 +739,7 @@ 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', 'description',
+            'subdevice_role', 'airflow', 'cooling_method', 'weight', 'weight_unit', 'description',
 
             # Counters
             'console_port_template_count',
@@ -992,6 +1004,30 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
         fields = ('id', 'name', 'label', 'type', 'color', 'feed_leg', 'description')
 
 
+@register_filterset
+class CoolingPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
+
+    class Meta:
+        model = CoolingPortTemplate
+        fields = (
+            'id', 'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity',
+            'description',
+        )
+
+
+@register_filterset
+class CoolingOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
+    cooling_port_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=CoolingPortTemplate.objects.all(),
+        distinct=False,
+        label=_('Cooling port (ID)'),
+    )
+
+    class Meta:
+        model = CoolingOutletTemplate
+        fields = ('id', 'name', 'label', 'type', 'connector_type', 'diameter', 'color', 'description')
+
+
 @register_filterset
 class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
@@ -1465,8 +1501,8 @@ 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',
@@ -2025,6 +2061,62 @@ class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
         )
 
 
+@register_filterset
+class CoolingPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=CoolingFeedTypeChoices,
+        distinct=False,
+        null_value=None
+    )
+    connector_type = django_filters.MultipleChoiceFilter(
+        choices=CoolingConnectorTypeChoices,
+        distinct=False,
+        null_value=None
+    )
+    diameter = django_filters.MultipleChoiceFilter(
+        choices=CoolingDiameterChoices,
+        distinct=False,
+        null_value=None
+    )
+
+    class Meta:
+        model = CoolingPort
+        fields = (
+            'id', 'name', 'label', 'maximum_flow', 'heat_capacity', 'description', 'mark_connected', 'cable_end',
+            'cable_connector',
+        )
+
+
+@register_filterset
+class CoolingOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=CoolingFeedTypeChoices,
+        distinct=False,
+        null_value=None
+    )
+    connector_type = django_filters.MultipleChoiceFilter(
+        choices=CoolingConnectorTypeChoices,
+        distinct=False,
+        null_value=None
+    )
+    diameter = django_filters.MultipleChoiceFilter(
+        choices=CoolingDiameterChoices,
+        distinct=False,
+        null_value=None
+    )
+    cooling_port_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=CoolingPort.objects.all(),
+        distinct=False,
+        label=_('Cooling port (ID)'),
+    )
+
+    class Meta:
+        model = CoolingOutlet
+        fields = (
+            'id', 'name', 'label', 'description', 'color', 'mark_connected', 'cable_end', 'cable_connector',
+        )
+
+
 @register_filterset
 class MACAddressFilterSet(PrimaryModelFilterSet):
     mac_address = MultiValueMACAddressFilter()
@@ -3031,6 +3123,166 @@ 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)'),
+    )
+    type = django_filters.MultipleChoiceFilter(
+        choices=CoolingSourceTypeChoices,
+        distinct=False,
+        null_value=None
+    )
+    status = django_filters.MultipleChoiceFilter(
+        choices=CoolingSourceStatusChoices,
+        distinct=False,
+        null_value=None
+    )
+
+    class Meta:
+        model = CoolingSource
+        fields = (
+            'id', 'name', 'cooling_capacity', 'supply_temperature', 'return_temperature', 'description',
+        )
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )
+        return queryset.filter(qs_filter)
+
+
+@register_filterset
+class CoolingFeedFilterSet(PrimaryModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet, 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
+    )
+    type = django_filters.MultipleChoiceFilter(
+        choices=CoolingFeedTypeChoices,
+        distinct=False,
+        null_value=None
+    )
+    fluid_type = django_filters.MultipleChoiceFilter(
+        choices=FluidTypeChoices,
+        distinct=False,
+        null_value=None
+    )
+
+    class Meta:
+        model = CoolingFeed
+        fields = (
+            'id', 'name', 'cooling_capacity', 'flow_rate', 'pressure', 'supply_temperature', 'return_temperature',
+            'mark_connected', 'cable_end', 'cable_connector', '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
 #

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

@@ -13,6 +13,8 @@ from .object_create import ComponentCreateForm
 __all__ = (
     'ConsolePortBulkCreateForm',
     'ConsoleServerPortBulkCreateForm',
+    'CoolingOutletBulkCreateForm',
+    'CoolingPortBulkCreateForm',
     'DeviceBayBulkCreateForm',
     # 'FrontPortBulkCreateForm',
     'InterfaceBulkCreateForm',
@@ -81,6 +83,29 @@ class PowerOutletBulkCreateForm(
     )
 
 
+class CoolingPortBulkCreateForm(
+    form_from_model(
+        CoolingPort, ['type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity', 'mark_connected']
+    ),
+    DeviceBulkAddComponentForm
+):
+    model = CoolingPort
+    field_order = (
+        'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity', 'mark_connected',
+        'description', 'tags',
+    )
+
+
+class CoolingOutletBulkCreateForm(
+    form_from_model(CoolingOutlet, ['type', 'connector_type', 'diameter', 'color', 'mark_connected']),
+    DeviceBulkAddComponentForm
+):
+    model = CoolingOutlet
+    field_order = (
+        'name', 'label', 'type', 'connector_type', 'diameter', 'color', 'mark_connected', 'description', 'tags',
+    )
+
+
 class InterfaceBulkCreateForm(
     form_from_model(Interface, [
         'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type', 'rf_role'

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

@@ -40,6 +40,12 @@ __all__ = (
     'ConsolePortTemplateBulkEditForm',
     'ConsoleServerPortBulkEditForm',
     'ConsoleServerPortTemplateBulkEditForm',
+    'CoolingFeedBulkEditForm',
+    'CoolingOutletBulkEditForm',
+    'CoolingOutletTemplateBulkEditForm',
+    'CoolingPortBulkEditForm',
+    'CoolingPortTemplateBulkEditForm',
+    'CoolingSourceBulkEditForm',
     'DeviceBayBulkEditForm',
     'DeviceBayTemplateBulkEditForm',
     'DeviceBulkEditForm',
@@ -437,6 +443,21 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         choices=add_blank_choice(RackAirflowChoices),
         required=False
     )
+    cooling_capability = forms.ChoiceField(
+        label=_('Cooling capability'),
+        choices=add_blank_choice(RackCoolingCapabilityChoices),
+        required=False
+    )
+    has_rdhx = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect,
+        label=_('Has RDHx')
+    )
+    cooling_capacity = forms.DecimalField(
+        label=_('Cooling capacity'),
+        min_value=0,
+        required=False
+    )
     weight = forms.DecimalField(
         label=_('Weight'),
         min_value=0,
@@ -462,11 +483,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', 'has_rdhx', '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',
     )
 
 
@@ -538,6 +561,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,
@@ -554,11 +582,11 @@ 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')),
     )
-    nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
+    nullable_fields = ('part_number', 'airflow', 'cooling_method', 'weight', 'weight_unit', 'description', 'comments')
 
 
 class ModuleTypeProfileBulkEditForm(PrimaryModelBulkEditForm):
@@ -725,6 +753,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,
@@ -748,12 +781,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',
     )
 
 
@@ -992,6 +1025,156 @@ 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=''
+    )
+    cooling_capacity = forms.DecimalField(
+        label=_('Cooling capacity'),
+        min_value=0,
+        required=False
+    )
+    supply_temperature = forms.DecimalField(
+        label=_('Supply temperature'),
+        required=False
+    )
+    return_temperature = forms.DecimalField(
+        label=_('Return temperature'),
+        required=False
+    )
+
+    model = CoolingSource
+    fieldsets = (
+        FieldSet('region', 'site_group', 'site', 'location', 'type', 'status', 'description'),
+        FieldSet('cooling_capacity', 'supply_temperature', 'return_temperature', name=_('Characteristics')),
+    )
+    nullable_fields = (
+        'location', 'type', 'cooling_capacity', 'supply_temperature', 'return_temperature', '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=''
+    )
+    type = forms.ChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(CoolingFeedTypeChoices),
+        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
+    )
+    flow_rate = forms.DecimalField(
+        label=_('Flow rate'),
+        min_value=0,
+        required=False
+    )
+    pressure = forms.DecimalField(
+        label=_('Pressure'),
+        min_value=0,
+        required=False
+    )
+    supply_temperature = forms.DecimalField(
+        label=_('Supply temperature'),
+        required=False
+    )
+    return_temperature = forms.DecimalField(
+        label=_('Return temperature'),
+        required=False
+    )
+    mark_connected = forms.NullBooleanField(
+        label=_('Mark connected'),
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+
+    model = CoolingFeed
+    fieldsets = (
+        FieldSet('cooling_source', 'rack', 'status', 'type', 'fluid_type', 'mark_connected', 'description', 'tenant'),
+        FieldSet(
+            'cooling_capacity', 'flow_rate', 'pressure', 'supply_temperature', 'return_temperature',
+            name=_('Characteristics')
+        ),
+    )
+    nullable_fields = (
+        'rack', 'fluid_type', 'cooling_capacity', 'flow_rate', 'pressure', 'supply_temperature', 'return_temperature',
+        'tenant', 'description', 'comments',
+    )
+
+
 #
 # Device component templates
 #
@@ -1132,6 +1315,113 @@ class PowerOutletTemplateBulkEditForm(ComponentTemplateBulkEditForm):
             self.fields['power_port'].widget.attrs['disabled'] = True
 
 
+class CoolingPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=CoolingPortTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    label = forms.CharField(
+        label=_('Label'),
+        max_length=64,
+        required=False
+    )
+    type = forms.ChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(CoolingFeedTypeChoices),
+        required=False
+    )
+    connector_type = forms.ChoiceField(
+        label=_('Connector type'),
+        choices=add_blank_choice(CoolingConnectorTypeChoices),
+        required=False
+    )
+    diameter = forms.ChoiceField(
+        label=_('Diameter'),
+        choices=add_blank_choice(CoolingDiameterChoices),
+        required=False
+    )
+    maximum_flow = forms.DecimalField(
+        label=_('Maximum flow'),
+        min_value=0,
+        required=False
+    )
+    heat_capacity = forms.DecimalField(
+        label=_('Heat capacity'),
+        min_value=0,
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        required=False
+    )
+
+    nullable_fields = (
+        'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity', 'description',
+    )
+
+
+class CoolingOutletTemplateBulkEditForm(ComponentTemplateBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=CoolingOutletTemplate.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(CoolingFeedTypeChoices),
+        required=False
+    )
+    connector_type = forms.ChoiceField(
+        label=_('Connector type'),
+        choices=add_blank_choice(CoolingConnectorTypeChoices),
+        required=False
+    )
+    diameter = forms.ChoiceField(
+        label=_('Diameter'),
+        choices=add_blank_choice(CoolingDiameterChoices),
+        required=False
+    )
+    color = ColorField(
+        label=_('Color'),
+        required=False
+    )
+    cooling_port = forms.ModelChoiceField(
+        label=_('Cooling port'),
+        queryset=CoolingPortTemplate.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        required=False
+    )
+
+    nullable_fields = (
+        'label', 'type', 'connector_type', 'diameter', 'cooling_port', 'description',
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit cooling_port queryset to CoolingPortTemplates 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_port'].queryset = CoolingPortTemplate.objects.filter(device_type=device_type)
+        else:
+            self.fields['cooling_port'].choices = ()
+            self.fields['cooling_port'].widget.attrs['disabled'] = True
+
+
 class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=InterfaceTemplate.objects.all(),
@@ -1430,6 +1720,65 @@ class PowerOutletBulkEditForm(
             self.fields['power_port'].widget.attrs['disabled'] = True
 
 
+class CoolingPortBulkEditForm(
+    ComponentBulkEditForm,
+    form_from_model(CoolingPort, [
+        'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity', 'mark_connected',
+        'description'
+    ])
+):
+    mark_connected = forms.NullBooleanField(
+        label=_('Mark connected'),
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+
+    model = CoolingPort
+    fieldsets = (
+        FieldSet('module', 'type', 'connector_type', 'diameter', 'label', 'description', 'mark_connected'),
+        FieldSet('maximum_flow', 'heat_capacity', name=_('Characteristics')),
+    )
+    nullable_fields = (
+        'module', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity', 'description',
+    )
+
+
+class CoolingOutletBulkEditForm(
+    ComponentBulkEditForm,
+    form_from_model(
+        CoolingOutlet,
+        ['label', 'type', 'connector_type', 'diameter', 'color', 'cooling_port', 'mark_connected', 'description']
+    )
+):
+    mark_connected = forms.NullBooleanField(
+        label=_('Mark connected'),
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+
+    model = CoolingOutlet
+    fieldsets = (
+        FieldSet(
+            'module', 'type', 'connector_type', 'diameter', 'label', 'description', 'mark_connected', 'color',
+            'cooling_port'
+        ),
+    )
+    nullable_fields = (
+        'module', 'label', 'type', 'connector_type', 'diameter', 'cooling_port', 'description',
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit cooling_port queryset to CoolingPorts which belong to the parent Device
+        if self.device_id:
+            device = Device.objects.filter(pk=self.device_id).first()
+            self.fields['cooling_port'].queryset = CoolingPort.objects.filter(device=device)
+        else:
+            self.fields['cooling_port'].choices = ()
+            self.fields['cooling_port'].widget.attrs['disabled'] = True
+
+
 class InterfaceBulkEditForm(
     ComponentBulkEditForm,
     form_from_model(Interface, [

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

@@ -38,6 +38,10 @@ __all__ = (
     'CableImportForm',
     'ConsolePortImportForm',
     'ConsoleServerPortImportForm',
+    'CoolingFeedImportForm',
+    'CoolingOutletImportForm',
+    'CoolingPortImportForm',
+    'CoolingSourceImportForm',
     'DeviceBayImportForm',
     'DeviceImportForm',
     'DeviceRoleImportForm',
@@ -324,6 +328,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,
@@ -336,8 +346,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', 'has_rdhx', 'cooling_capacity', 'weight',
+            'max_weight', 'weight_unit', 'description', 'owner', 'comments', 'tags',
         )
 
     def __init__(self, data=None, *args, **kwargs):
@@ -457,8 +467,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', 'owner', 'comments',
-            'tags',
+            'is_full_depth', 'subdevice_role', 'airflow', 'cooling_method', 'description', 'weight', 'weight_unit',
+            'owner', 'comments', 'tags',
         ]
 
 
@@ -698,6 +708,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(),
@@ -710,8 +726,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):
@@ -948,6 +964,101 @@ class PowerOutletImportForm(OwnerCSVMixin, NetBoxModelImportForm):
             self.fields['power_port'].queryset = PowerPort.objects.none()
 
 
+class CoolingPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
+    device = CSVModelChoiceField(
+        label=_('Device'),
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+    type = CSVChoiceField(
+        label=_('Type'),
+        choices=CoolingFeedTypeChoices,
+        required=False,
+        help_text=_('Port type')
+    )
+    connector_type = CSVChoiceField(
+        label=_('Connector type'),
+        choices=CoolingConnectorTypeChoices,
+        required=False,
+        help_text=_('Physical connector type')
+    )
+    diameter = CSVChoiceField(
+        label=_('Diameter'),
+        choices=CoolingDiameterChoices,
+        required=False,
+        help_text=_('Nominal connector diameter')
+    )
+
+    class Meta:
+        model = CoolingPort
+        fields = (
+            'device', 'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity',
+            'mark_connected', 'description', 'owner', 'tags',
+        )
+
+
+class CoolingOutletImportForm(OwnerCSVMixin, NetBoxModelImportForm):
+    device = CSVModelChoiceField(
+        label=_('Device'),
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+    type = CSVChoiceField(
+        label=_('Type'),
+        choices=CoolingFeedTypeChoices,
+        required=False,
+        help_text=_('Outlet type')
+    )
+    connector_type = CSVChoiceField(
+        label=_('Connector type'),
+        choices=CoolingConnectorTypeChoices,
+        required=False,
+        help_text=_('Physical connector type')
+    )
+    diameter = CSVChoiceField(
+        label=_('Diameter'),
+        choices=CoolingDiameterChoices,
+        required=False,
+        help_text=_('Nominal connector diameter')
+    )
+    cooling_port = CSVModelChoiceField(
+        label=_('Cooling port'),
+        queryset=CoolingPort.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Local cooling port which feeds this outlet')
+    )
+
+    class Meta:
+        model = CoolingOutlet
+        fields = (
+            'device', 'name', 'label', 'type', 'connector_type', 'diameter', 'color', 'mark_connected', 'cooling_port',
+            'description', 'owner', 'tags',
+        )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit CoolingPort 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_port'].queryset = CoolingPort.objects.filter(
+                device__in=[device, device.get_vc_master()]
+            )
+        else:
+            self.fields['cooling_port'].queryset = CoolingPort.objects.none()
+
+
 class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
     device = CSVModelChoiceField(
         label=_('Device'),
@@ -1806,6 +1917,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,
+        required=False,
+        help_text=_('Cooling source type')
+    )
+    status = CSVChoiceField(
+        label=_('Status'),
+        choices=CoolingSourceStatusChoices,
+        help_text=_('Operational status')
+    )
+
+    class Meta:
+        model = CoolingSource
+        fields = (
+            'site', 'location', 'name', 'type', 'status', 'cooling_capacity', 'supply_temperature',
+            'return_temperature', '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')
+    )
+    type = CSVChoiceField(
+        label=_('Type'),
+        choices=CoolingFeedTypeChoices,
+        help_text=_('Supply or return')
+    )
+    fluid_type = CSVChoiceField(
+        label=_('Fluid type'),
+        choices=FluidTypeChoices,
+        required=False,
+        help_text=_('Coolant fluid type')
+    )
+
+    class Meta:
+        model = CoolingFeed
+        fields = (
+            'site', 'cooling_source', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'fluid_type',
+            'cooling_capacity', 'flow_rate', 'pressure', 'supply_temperature', 'return_temperature', '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'),

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

@@ -34,6 +34,12 @@ __all__ = (
     'ConsolePortTemplateFilterForm',
     'ConsoleServerPortFilterForm',
     'ConsoleServerPortTemplateFilterForm',
+    'CoolingFeedFilterForm',
+    'CoolingOutletFilterForm',
+    'CoolingOutletTemplateFilterForm',
+    'CoolingPortFilterForm',
+    'CoolingPortTemplateFilterForm',
+    'CoolingSourceFilterForm',
     'DeviceBayFilterForm',
     'DeviceBayTemplateFilterForm',
     'DeviceFilterForm',
@@ -369,6 +375,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', 'has_rdhx', 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')),
@@ -438,6 +445,18 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
         choices=add_blank_choice(RackAirflowChoices),
         required=False
     )
+    cooling_capability = forms.MultipleChoiceField(
+        label=_('Cooling capability'),
+        choices=RackCoolingCapabilityChoices,
+        required=False
+    )
+    has_rdhx = forms.NullBooleanField(
+        required=False,
+        label=_('Has RDHx'),
+        widget=forms.Select(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
     serial = forms.CharField(
         label=_('Serial'),
         required=False
@@ -561,7 +580,7 @@ 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', 'airflow', 'cooling_method', name=_('Hardware')
         ),
         FieldSet('has_front_image', 'has_rear_image', name=_('Images')),
         FieldSet(
@@ -601,6 +620,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'),
@@ -859,7 +883,7 @@ class DeviceFilterForm(
         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('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')),
+        FieldSet('manufacturer_id', 'device_type_id', 'platform_id', 'cooling_method', name=_('Hardware')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
@@ -947,6 +971,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
@@ -1427,6 +1456,120 @@ 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', name=_('Attributes')),
+        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
+    )
+    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', 'type', 'fluid_type', name=_('Attributes')),
+        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
+    )
+    type = forms.ChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(CoolingFeedTypeChoices),
+        required=False
+    )
+    fluid_type = forms.ChoiceField(
+        label=_('Fluid type'),
+        choices=add_blank_choice(FluidTypeChoices),
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
 #
 # Device components
 #
@@ -1634,6 +1777,120 @@ class PowerOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
     )
 
 
+class CoolingPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
+    model = CoolingPort
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('name', 'label', 'type', 'connector_type', 'diameter', 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('cabled', 'connected', 'occupied', name=_('Connection')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
+    )
+    type = forms.MultipleChoiceField(
+        label=_('Type'),
+        choices=CoolingFeedTypeChoices,
+        required=False
+    )
+    connector_type = forms.MultipleChoiceField(
+        label=_('Connector type'),
+        choices=CoolingConnectorTypeChoices,
+        required=False
+    )
+    diameter = forms.MultipleChoiceField(
+        label=_('Diameter'),
+        choices=CoolingDiameterChoices,
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+class CoolingPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
+    model = CoolingPortTemplate
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('name', 'label', 'type', 'connector_type', 'diameter', name=_('Attributes')),
+        FieldSet('device_type_id', 'module_type_id', name=_('Device')),
+    )
+    type = forms.MultipleChoiceField(
+        label=_('Type'),
+        choices=CoolingFeedTypeChoices,
+        required=False
+    )
+    connector_type = forms.MultipleChoiceField(
+        label=_('Connector type'),
+        choices=CoolingConnectorTypeChoices,
+        required=False
+    )
+    diameter = forms.MultipleChoiceField(
+        label=_('Diameter'),
+        choices=CoolingDiameterChoices,
+        required=False
+    )
+
+
+class CoolingOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
+    model = CoolingOutlet
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('name', 'label', 'type', 'connector_type', 'diameter', 'color', 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('cabled', 'connected', 'occupied', name=_('Connection')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
+    )
+    type = forms.MultipleChoiceField(
+        label=_('Type'),
+        choices=CoolingFeedTypeChoices,
+        required=False
+    )
+    connector_type = forms.MultipleChoiceField(
+        label=_('Connector type'),
+        choices=CoolingConnectorTypeChoices,
+        required=False
+    )
+    diameter = forms.MultipleChoiceField(
+        label=_('Diameter'),
+        choices=CoolingDiameterChoices,
+        required=False
+    )
+    color = ColorField(
+        label=_('Color'),
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+class CoolingOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
+    model = CoolingOutletTemplate
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('name', 'label', 'type', 'connector_type', 'diameter', name=_('Attributes')),
+        FieldSet('device_type_id', 'module_type_id', name=_('Device')),
+    )
+    type = forms.MultipleChoiceField(
+        label=_('Type'),
+        choices=CoolingFeedTypeChoices,
+        required=False
+    )
+    connector_type = forms.MultipleChoiceField(
+        label=_('Connector type'),
+        choices=CoolingConnectorTypeChoices,
+        required=False
+    )
+    diameter = forms.MultipleChoiceField(
+        label=_('Diameter'),
+        choices=CoolingDiameterChoices,
+        required=False
+    )
+
+
 class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = Interface
     fieldsets = (

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

@@ -45,6 +45,12 @@ __all__ = (
     'ConsolePortTemplateForm',
     'ConsoleServerPortForm',
     'ConsoleServerPortTemplateForm',
+    'CoolingFeedForm',
+    'CoolingOutletForm',
+    'CoolingOutletTemplateForm',
+    'CoolingPortForm',
+    'CoolingPortTemplateForm',
+    'CoolingSourceForm',
     'DeviceBayForm',
     'DeviceBayTemplateForm',
     'DeviceForm',
@@ -326,6 +332,7 @@ class RackForm(TenancyForm, PrimaryModelForm):
             'site', 'location', 'group', 'name', 'status', 'role', 'rack_type', 'description', 'airflow', 'tags',
             name=_('Rack')
         ),
+        FieldSet('cooling_capability', 'has_rdhx', 'cooling_capacity', name=_('Cooling')),
         FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
@@ -335,8 +342,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', 'has_rdhx',
+            'cooling_capacity', 'weight', 'max_weight', 'weight_unit', 'description', 'owner', 'comments', 'tags',
         ]
 
     def __init__(self, *args, **kwargs):
@@ -430,7 +437,7 @@ class DeviceTypeForm(PrimaryModelForm):
         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',
-            'weight', 'weight_unit', name=_('Chassis')
+            'cooling_method', 'weight', 'weight_unit', name=_('Chassis')
         ),
         FieldSet('front_image', 'rear_image', name=_('Images')),
     )
@@ -439,8 +446,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', 'front_image', 'rear_image',
-            'description', 'owner', 'comments', 'tags',
+            'is_full_depth', 'subdevice_role', 'airflow', 'cooling_method', 'weight', 'weight_unit', 'front_image',
+            'rear_image', 'description', 'owner', 'comments', 'tags',
         ]
         widgets = {
             'front_image': ClearableFileInput(attrs={
@@ -722,7 +729,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',
         ]
@@ -953,6 +961,75 @@ 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(
+            'cooling_capacity', 'supply_temperature', 'return_temperature', name=_('Characteristics')
+        ),
+    )
+
+    class Meta:
+        model = CoolingSource
+        fields = [
+            'site', 'location', 'name', 'type', 'status', 'cooling_capacity', 'supply_temperature',
+            'return_temperature', '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', 'type', 'fluid_type', 'description', 'mark_connected', 'tags',
+            name=_('Cooling Feed')
+        ),
+        FieldSet(
+            'cooling_capacity', 'flow_rate', 'pressure', 'supply_temperature', 'return_temperature',
+            name=_('Characteristics')
+        ),
+        FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
+    )
+
+    class Meta:
+        model = CoolingFeed
+        fields = [
+            'cooling_source', 'rack', 'name', 'status', 'type', 'mark_connected', 'fluid_type', 'cooling_capacity',
+            'flow_rate', 'pressure', 'supply_temperature', 'return_temperature', 'tenant_group', 'tenant',
+            'description', 'owner', 'comments', 'tags',
+        ]
+
+
 #
 # Virtual chassis
 #
@@ -1166,6 +1243,53 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
         ]
 
 
+class CoolingPortTemplateForm(ModularComponentTemplateForm):
+    fieldsets = (
+        FieldSet(
+            TabbedGroups(
+                FieldSet('device_type', name=_('Device Type')),
+                FieldSet('module_type', name=_('Module Type')),
+            ),
+            'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity', 'description',
+        ),
+    )
+
+    class Meta:
+        model = CoolingPortTemplate
+        fields = [
+            'device_type', 'module_type', 'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow',
+            'heat_capacity', 'description',
+        ]
+
+
+class CoolingOutletTemplateForm(ModularComponentTemplateForm):
+    cooling_port = DynamicModelChoiceField(
+        label=_('Cooling port'),
+        queryset=CoolingPortTemplate.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', 'connector_type', 'diameter', 'color', 'cooling_port', 'description',
+        ),
+    )
+
+    class Meta:
+        model = CoolingOutletTemplate
+        fields = [
+            'device_type', 'module_type', 'name', 'label', 'type', 'connector_type', 'diameter', 'color',
+            'cooling_port', 'description',
+        ]
+
+
 class InterfaceTemplateForm(ModularComponentTemplateForm):
     bridge = DynamicModelChoiceField(
         label=_('Bridge'),
@@ -1529,6 +1653,47 @@ class PowerOutletForm(ModularDeviceComponentForm):
         ]
 
 
+class CoolingPortForm(ModularDeviceComponentForm):
+    fieldsets = (
+        FieldSet(
+            'device', 'module', 'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow',
+            'heat_capacity', 'mark_connected', 'description', 'tags',
+        ),
+    )
+
+    class Meta:
+        model = CoolingPort
+        fields = [
+            'device', 'module', 'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity',
+            'mark_connected', 'description', 'owner', 'tags',
+        ]
+
+
+class CoolingOutletForm(ModularDeviceComponentForm):
+    cooling_port = DynamicModelChoiceField(
+        label=_('Cooling port'),
+        queryset=CoolingPort.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device',
+        }
+    )
+
+    fieldsets = (
+        FieldSet(
+            'device', 'module', 'name', 'label', 'type', 'connector_type', 'diameter', 'color', 'cooling_port',
+            'mark_connected', 'description', 'tags',
+        ),
+    )
+
+    class Meta:
+        model = CoolingOutlet
+        fields = [
+            'device', 'module', 'name', 'label', 'type', 'connector_type', 'diameter', 'color', 'cooling_port',
+            'mark_connected', 'description', 'tags',
+        ]
+
+
 class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
     vdcs = DynamicModelMultipleChoiceField(
         queryset=VirtualDeviceContext.objects.all(),

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

@@ -16,6 +16,10 @@ __all__ = (
     'ConsolePortTemplateCreateForm',
     'ConsoleServerPortCreateForm',
     'ConsoleServerPortTemplateCreateForm',
+    'CoolingOutletCreateForm',
+    'CoolingOutletTemplateCreateForm',
+    'CoolingPortCreateForm',
+    'CoolingPortTemplateCreateForm',
     'DeviceBayCreateForm',
     'DeviceBayTemplateCreateForm',
     'FrontPortCreateForm',
@@ -104,6 +108,18 @@ class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutlet
         exclude = ('name', 'label')
 
 
+class CoolingPortTemplateCreateForm(ComponentCreateForm, model_forms.CoolingPortTemplateForm):
+
+    class Meta(model_forms.CoolingPortTemplateForm.Meta):
+        exclude = ('name', 'label')
+
+
+class CoolingOutletTemplateCreateForm(ComponentCreateForm, model_forms.CoolingOutletTemplateForm):
+
+    class Meta(model_forms.CoolingOutletTemplateForm.Meta):
+        exclude = ('name', 'label')
+
+
 class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm):
 
     class Meta(model_forms.InterfaceTemplateForm.Meta):
@@ -196,6 +212,18 @@ class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm):
         exclude = ('name', 'label')
 
 
+class CoolingPortCreateForm(ComponentCreateForm, model_forms.CoolingPortForm):
+
+    class Meta(model_forms.CoolingPortForm.Meta):
+        exclude = ('name', 'label')
+
+
+class CoolingOutletCreateForm(ComponentCreateForm, model_forms.CoolingOutletForm):
+
+    class Meta(model_forms.CoolingOutletForm.Meta):
+        exclude = ('name', 'label')
+
+
 class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
 
     class Meta(model_forms.InterfaceForm.Meta):

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

@@ -8,6 +8,8 @@ from wireless.choices import WirelessRoleChoices
 __all__ = (
     'ConsolePortTemplateImportForm',
     'ConsoleServerPortTemplateImportForm',
+    'CoolingOutletTemplateImportForm',
+    'CoolingPortTemplateImportForm',
     'DeviceBayTemplateImportForm',
     'FrontPortTemplateImportForm',
     'InterfaceTemplateImportForm',
@@ -80,6 +82,46 @@ class PowerOutletTemplateImportForm(forms.ModelForm):
         return module_type
 
 
+class CoolingPortTemplateImportForm(forms.ModelForm):
+
+    class Meta:
+        model = CoolingPortTemplate
+        fields = [
+            'device_type', 'module_type', 'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow',
+            'heat_capacity', 'description',
+        ]
+
+
+class CoolingOutletTemplateImportForm(forms.ModelForm):
+    cooling_port = forms.ModelChoiceField(
+        label=_('Cooling port'),
+        queryset=CoolingPortTemplate.objects.all(),
+        to_field_name='name',
+        required=False
+    )
+
+    class Meta:
+        model = CoolingOutletTemplate
+        fields = [
+            'device_type', 'module_type', 'name', 'label', 'type', 'connector_type', 'diameter', 'color',
+            'cooling_port', 'description',
+        ]
+
+    def clean_device_type(self):
+        if device_type := self.cleaned_data['device_type']:
+            cooling_port = self.fields['cooling_port']
+            cooling_port.queryset = cooling_port.queryset.filter(device_type=device_type)
+
+        return device_type
+
+    def clean_module_type(self):
+        if module_type := self.cleaned_data['module_type']:
+            cooling_port = self.fields['cooling_port']
+            cooling_port.queryset = cooling_port.queryset.filter(module_type=module_type)
+
+        return module_type
+
+
 class InterfaceTemplateImportForm(forms.ModelForm):
     type = forms.ChoiceField(
         label=_('Type'),

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

@@ -8,9 +8,17 @@ __all__ = (
     'CableTypeEnum',
     'ConsolePortSpeedEnum',
     'ConsolePortTypeEnum',
+    'CoolingConnectorTypeEnum',
+    'CoolingDiameterEnum',
+    'CoolingFeedStatusEnum',
+    'CoolingFeedTypeEnum',
+    'CoolingMethodEnum',
+    'CoolingSourceStatusEnum',
+    'CoolingSourceTypeEnum',
     'DeviceAirflowEnum',
     'DeviceFaceEnum',
     'DeviceStatusEnum',
+    'FluidTypeEnum',
     'InterfaceDuplexEnum',
     'InterfaceKindEnum',
     'InterfaceModeEnum',
@@ -47,9 +55,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'))
+CoolingDiameterEnum = strawberry.enum(CoolingDiameterChoices.as_enum(prefix='diameter'))
+CoolingFeedStatusEnum = strawberry.enum(CoolingFeedStatusChoices.as_enum(prefix='status'))
+CoolingFeedTypeEnum = strawberry.enum(CoolingFeedTypeChoices.as_enum(prefix='type'))
+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'))
+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'))

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

@@ -70,6 +70,12 @@ __all__ = (
     'ConsolePortTemplateFilter',
     'ConsoleServerPortFilter',
     'ConsoleServerPortTemplateFilter',
+    'CoolingFeedFilter',
+    'CoolingOutletFilter',
+    'CoolingOutletTemplateFilter',
+    'CoolingPortFilter',
+    'CoolingPortTemplateFilter',
+    'CoolingSourceFilter',
     'DeviceBayFilter',
     'DeviceBayTemplateFilter',
     'DeviceFilter',
@@ -931,6 +937,144 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
     )
 
 
+@strawberry_django.filter_type(models.CoolingFeed, lookups=True)
+class CoolingFeedFilter(CabledObjectModelFilterMixin, 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()
+    )
+    type: BaseFilterLookup[Annotated['CoolingFeedTypeEnum', 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()
+    )
+    flow_rate: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    pressure: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    supply_temperature: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    return_temperature: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+
+
+@strawberry_django.filter_type(models.CoolingOutlet, lookups=True)
+class CoolingOutletFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
+    type: BaseFilterLookup[Annotated['CoolingFeedTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    connector_type: BaseFilterLookup[
+        Annotated['CoolingConnectorTypeEnum', strawberry.lazy('dcim.graphql.enums')]
+    ] | None = strawberry_django.filter_field()
+    diameter: BaseFilterLookup[Annotated['CoolingDiameterEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    cooling_port: Annotated['CoolingPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    cooling_port_id: ID | None = strawberry_django.filter_field()
+    color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+
+
+@strawberry_django.filter_type(models.CoolingOutletTemplate, lookups=True)
+class CoolingOutletTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
+    type: BaseFilterLookup[Annotated['CoolingFeedTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    connector_type: BaseFilterLookup[
+        Annotated['CoolingConnectorTypeEnum', strawberry.lazy('dcim.graphql.enums')]
+    ] | None = strawberry_django.filter_field()
+    diameter: BaseFilterLookup[Annotated['CoolingDiameterEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    cooling_port: Annotated['CoolingPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    cooling_port_id: ID | None = strawberry_django.filter_field()
+
+
+@strawberry_django.filter_type(models.CoolingPort, lookups=True)
+class CoolingPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
+    type: BaseFilterLookup[Annotated['CoolingFeedTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    connector_type: BaseFilterLookup[
+        Annotated['CoolingConnectorTypeEnum', strawberry.lazy('dcim.graphql.enums')]
+    ] | None = strawberry_django.filter_field()
+    diameter: BaseFilterLookup[Annotated['CoolingDiameterEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    maximum_flow: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    heat_capacity: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+
+
+@strawberry_django.filter_type(models.CoolingPortTemplate, lookups=True)
+class CoolingPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
+    type: BaseFilterLookup[Annotated['CoolingFeedTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    connector_type: BaseFilterLookup[
+        Annotated['CoolingConnectorTypeEnum', strawberry.lazy('dcim.graphql.enums')]
+    ] | None = strawberry_django.filter_field()
+    diameter: BaseFilterLookup[Annotated['CoolingDiameterEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    maximum_flow: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    heat_capacity: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+
+
+@strawberry_django.filter_type(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()
+    )
+    cooling_capacity: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    supply_temperature: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    return_temperature: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+
+
 @strawberry_django.filter_type(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_outlet: CoolingOutletType = strawberry_django.field()
+    cooling_outlet_list: list[CoolingOutletType] = strawberry_django.field()
+
+    cooling_outlet_template: CoolingOutletTemplateType = strawberry_django.field()
+    cooling_outlet_template_list: list[CoolingOutletTemplateType] = strawberry_django.field()
+
+    cooling_port: CoolingPortType = strawberry_django.field()
+    cooling_port_list: list[CoolingPortType] = strawberry_django.field()
+
+    cooling_port_template: CoolingPortTemplateType = strawberry_django.field()
+    cooling_port_template_list: list[CoolingPortTemplateType] = 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()
 

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

@@ -51,6 +51,12 @@ __all__ = (
     'ConsolePortType',
     'ConsoleServerPortTemplateType',
     'ConsoleServerPortType',
+    'CoolingFeedType',
+    'CoolingOutletTemplateType',
+    'CoolingOutletType',
+    'CoolingPortTemplateType',
+    'CoolingPortType',
+    'CoolingSourceType',
     'DeviceBayTemplateType',
     'DeviceBayType',
     'DeviceRoleType',
@@ -256,6 +262,8 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, Prima
     console_server_port_count: BigInt
     power_port_count: BigInt
     power_outlet_count: BigInt
+    cooling_port_count: BigInt
+    cooling_outlet_count: BigInt
     interface_count: BigInt
     front_port_count: BigInt
     rear_port_count: BigInt
@@ -282,9 +290,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')]]
+    coolingports: list[Annotated["CoolingPortType", 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')]]
+    coolingoutlets: list[Annotated["CoolingOutletType", 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')]]
@@ -376,6 +386,8 @@ class DeviceTypeType(PrimaryObjectType):
     console_server_port_template_count: BigInt
     power_port_template_count: BigInt
     power_outlet_template_count: BigInt
+    cooling_port_template_count: BigInt
+    cooling_outlet_template_count: BigInt
     interface_template_count: BigInt
     front_port_template_count: BigInt
     rear_port_template_count: BigInt
@@ -393,6 +405,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')]]
+    coolingoutlettemplates: list[Annotated["CoolingOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
+    coolingporttemplates: list[Annotated["CoolingPortTemplateType", 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')]]
@@ -593,9 +607,11 @@ class ModuleType(PrimaryObjectType):
 
     interfaces: list[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
     powerports: list[Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')]]
+    coolingports: list[Annotated["CoolingPortType", 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')]]
+    coolingoutlets: list[Annotated["CoolingOutletType", strawberry.lazy('dcim.graphql.types')]]
     rearports: list[Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]]
     frontports: list[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]]
 
@@ -649,6 +665,8 @@ class ModuleTypeType(PrimaryObjectType):
     console_server_port_template_count: BigInt
     power_port_template_count: BigInt
     power_outlet_template_count: BigInt
+    cooling_port_template_count: BigInt
+    cooling_outlet_template_count: BigInt
     interface_template_count: BigInt
     front_port_template_count: BigInt
     rear_port_template_count: BigInt
@@ -661,6 +679,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')]]
+    coolingporttemplates: list[Annotated["CoolingPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
+    coolingoutlettemplates: list[Annotated["CoolingOutletTemplateType", 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')]]
@@ -772,6 +792,74 @@ class PowerPortTemplateType(ModularComponentTemplateType):
     poweroutlet_templates: list[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
+@strawberry_django.type(
+    models.CoolingFeed,
+    exclude=['_path'],
+    filters=CoolingFeedFilter,
+    pagination=True
+)
+class CoolingFeedType(CabledObjectMixin, PathEndpointMixin, 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
+
+
+@strawberry_django.type(
+    models.CoolingOutlet,
+    exclude=['_path'],
+    filters=CoolingOutletFilter,
+    pagination=True
+)
+class CoolingOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin):
+    cooling_port: Annotated["CoolingPortType", strawberry.lazy('dcim.graphql.types')] | None
+    color: str
+
+
+@strawberry_django.type(
+    models.CoolingOutletTemplate,
+    fields='__all__',
+    filters=CoolingOutletTemplateFilter,
+    pagination=True
+)
+class CoolingOutletTemplateType(ModularComponentTemplateType):
+    cooling_port: Annotated["CoolingPortTemplateType", strawberry.lazy('dcim.graphql.types')] | None
+    color: str
+
+
+@strawberry_django.type(
+    models.CoolingPort,
+    exclude=['_path'],
+    filters=CoolingPortFilter,
+    pagination=True
+)
+class CoolingPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin):
+
+    coolingoutlets: list[Annotated["CoolingOutletType", strawberry.lazy('dcim.graphql.types')]]
+
+
+@strawberry_django.type(
+    models.CoolingPortTemplate,
+    fields='__all__',
+    filters=CoolingPortTemplateFilter,
+    pagination=True
+)
+class CoolingPortTemplateType(ModularComponentTemplateType):
+    coolingoutlet_templates: list[Annotated["CoolingOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
+
+
+@strawberry_django.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')]]
+
+
 @strawberry_django.type(
     models.RackGroup,
     fields='__all__',

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

@@ -0,0 +1,896 @@
+# Generated by Django 6.0.6 on 2026-06-23 17:04
+
+import django.contrib.postgres.fields
+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
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("dcim", "0240_device__config_context_data"),
+        ("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_outlet_count",
+            field=utilities.fields.CounterCacheField(
+                default=0,
+                editable=False,
+                to_field="device",
+                to_model="dcim.CoolingOutlet",
+            ),
+        ),
+        migrations.AddField(
+            model_name="device",
+            name="cooling_port_count",
+            field=utilities.fields.CounterCacheField(
+                default=0,
+                editable=False,
+                to_field="device",
+                to_model="dcim.CoolingPort",
+            ),
+        ),
+        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_outlet_template_count",
+            field=utilities.fields.CounterCacheField(
+                default=0,
+                editable=False,
+                to_field="device_type",
+                to_model="dcim.CoolingOutletTemplate",
+            ),
+        ),
+        migrations.AddField(
+            model_name="devicetype",
+            name="cooling_port_template_count",
+            field=utilities.fields.CounterCacheField(
+                default=0,
+                editable=False,
+                to_field="device_type",
+                to_model="dcim.CoolingPortTemplate",
+            ),
+        ),
+        migrations.AddField(
+            model_name="moduletype",
+            name="cooling_outlet_template_count",
+            field=utilities.fields.CounterCacheField(
+                default=0,
+                editable=False,
+                to_field="module_type",
+                to_model="dcim.CoolingOutletTemplate",
+            ),
+        ),
+        migrations.AddField(
+            model_name="moduletype",
+            name="cooling_port_template_count",
+            field=utilities.fields.CounterCacheField(
+                default=0,
+                editable=False,
+                to_field="module_type",
+                to_model="dcim.CoolingPortTemplate",
+            ),
+        ),
+        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=8, null=True
+            ),
+        ),
+        migrations.AddField(
+            model_name="rack",
+            name="has_rdhx",
+            field=models.BooleanField(default=False),
+        ),
+        migrations.CreateModel(
+            name="CoolingPort",
+            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,
+                    ),
+                ),
+                ("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)),
+                ("cable_end", models.CharField(blank=True, max_length=1, null=True)),
+                (
+                    "cable_connector",
+                    models.PositiveSmallIntegerField(
+                        blank=True,
+                        null=True,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(256),
+                        ],
+                    ),
+                ),
+                (
+                    "cable_positions",
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.PositiveSmallIntegerField(
+                            validators=[
+                                django.core.validators.MinValueValidator(1),
+                                django.core.validators.MaxValueValidator(1024),
+                            ]
+                        ),
+                        blank=True,
+                        null=True,
+                    ),
+                ),
+                ("mark_connected", models.BooleanField(default=False)),
+                ("type", models.CharField(blank=True, max_length=50, null=True)),
+                (
+                    "connector_type",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                ("diameter", models.CharField(blank=True, max_length=50, null=True)),
+                (
+                    "maximum_flow",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(0)],
+                    ),
+                ),
+                (
+                    "heat_capacity",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(0)],
+                    ),
+                ),
+                (
+                    "_location",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to="dcim.location",
+                    ),
+                ),
+                (
+                    "_path",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to="dcim.cablepath",
+                    ),
+                ),
+                (
+                    "_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",
+                    ),
+                ),
+                (
+                    "cable",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to="dcim.cable",
+                    ),
+                ),
+                (
+                    "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 port",
+                "verbose_name_plural": "cooling ports",
+                "ordering": ("device", "name"),
+                "abstract": False,
+            },
+            bases=(
+                netbox.models.deletion.DeleteMixin,
+                models.Model,
+                utilities.tracking.TrackingModelMixin,
+            ),
+        ),
+        migrations.CreateModel(
+            name="CoolingOutlet",
+            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,
+                    ),
+                ),
+                ("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)),
+                ("cable_end", models.CharField(blank=True, max_length=1, null=True)),
+                (
+                    "cable_connector",
+                    models.PositiveSmallIntegerField(
+                        blank=True,
+                        null=True,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(256),
+                        ],
+                    ),
+                ),
+                (
+                    "cable_positions",
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.PositiveSmallIntegerField(
+                            validators=[
+                                django.core.validators.MinValueValidator(1),
+                                django.core.validators.MaxValueValidator(1024),
+                            ]
+                        ),
+                        blank=True,
+                        null=True,
+                    ),
+                ),
+                ("mark_connected", models.BooleanField(default=False)),
+                ("type", models.CharField(blank=True, max_length=50, null=True)),
+                (
+                    "connector_type",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                ("diameter", models.CharField(blank=True, max_length=50, null=True)),
+                ("color", utilities.fields.ColorField(blank=True, max_length=6)),
+                (
+                    "_location",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to="dcim.location",
+                    ),
+                ),
+                (
+                    "_path",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to="dcim.cablepath",
+                    ),
+                ),
+                (
+                    "_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",
+                    ),
+                ),
+                (
+                    "cable",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to="dcim.cable",
+                    ),
+                ),
+                (
+                    "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_port",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="coolingoutlets",
+                        to="dcim.coolingport",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "cooling outlet",
+                "verbose_name_plural": "cooling outlets",
+                "ordering": ("device", "name"),
+                "abstract": False,
+            },
+            bases=(
+                netbox.models.deletion.DeleteMixin,
+                models.Model,
+                utilities.tracking.TrackingModelMixin,
+            ),
+        ),
+        migrations.CreateModel(
+            name="CoolingPortTemplate",
+            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)),
+                ("name", models.CharField(db_collation="natural_sort", max_length=64)),
+                ("label", models.CharField(blank=True, max_length=64)),
+                ("description", models.CharField(blank=True, max_length=200)),
+                ("type", models.CharField(blank=True, max_length=50, null=True)),
+                (
+                    "connector_type",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                ("diameter", models.CharField(blank=True, max_length=50, null=True)),
+                (
+                    "maximum_flow",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(0)],
+                    ),
+                ),
+                (
+                    "heat_capacity",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(0)],
+                    ),
+                ),
+                (
+                    "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 port template",
+                "verbose_name_plural": "cooling port templates",
+                "ordering": ("device_type", "module_type", "name"),
+                "abstract": False,
+            },
+            bases=(
+                netbox.models.deletion.DeleteMixin,
+                models.Model,
+                utilities.tracking.TrackingModelMixin,
+            ),
+        ),
+        migrations.CreateModel(
+            name="CoolingOutletTemplate",
+            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)),
+                ("name", models.CharField(db_collation="natural_sort", max_length=64)),
+                ("label", models.CharField(blank=True, max_length=64)),
+                ("description", models.CharField(blank=True, max_length=200)),
+                ("type", models.CharField(blank=True, max_length=50, null=True)),
+                (
+                    "connector_type",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                ("diameter", models.CharField(blank=True, max_length=50, null=True)),
+                ("color", utilities.fields.ColorField(blank=True, max_length=6)),
+                (
+                    "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_port",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="coolingoutlet_templates",
+                        to="dcim.coolingporttemplate",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "cooling outlet template",
+                "verbose_name_plural": "cooling outlet 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(blank=True, max_length=50, null=True)),
+                ("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)],
+                    ),
+                ),
+                (
+                    "supply_temperature",
+                    models.DecimalField(
+                        blank=True, decimal_places=2, max_digits=5, null=True
+                    ),
+                ),
+                (
+                    "return_temperature",
+                    models.DecimalField(
+                        blank=True, decimal_places=2, max_digits=5, null=True
+                    ),
+                ),
+                (
+                    "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,
+                    ),
+                ),
+                ("description", models.CharField(blank=True, max_length=200)),
+                ("comments", models.TextField(blank=True)),
+                ("cable_end", models.CharField(blank=True, max_length=1, null=True)),
+                (
+                    "cable_connector",
+                    models.PositiveSmallIntegerField(
+                        blank=True,
+                        null=True,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(256),
+                        ],
+                    ),
+                ),
+                (
+                    "cable_positions",
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.PositiveSmallIntegerField(
+                            validators=[
+                                django.core.validators.MinValueValidator(1),
+                                django.core.validators.MaxValueValidator(1024),
+                            ]
+                        ),
+                        blank=True,
+                        null=True,
+                    ),
+                ),
+                ("mark_connected", models.BooleanField(default=False)),
+                ("name", models.CharField(db_collation="natural_sort", max_length=100)),
+                ("status", models.CharField(default="active", max_length=50)),
+                ("type", models.CharField(default="supply", max_length=50)),
+                ("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)],
+                    ),
+                ),
+                (
+                    "flow_rate",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(0)],
+                    ),
+                ),
+                (
+                    "pressure",
+                    models.DecimalField(
+                        blank=True,
+                        decimal_places=2,
+                        max_digits=8,
+                        null=True,
+                        validators=[django.core.validators.MinValueValidator(0)],
+                    ),
+                ),
+                (
+                    "supply_temperature",
+                    models.DecimalField(
+                        blank=True, decimal_places=2, max_digits=5, null=True
+                    ),
+                ),
+                (
+                    "return_temperature",
+                    models.DecimalField(
+                        blank=True, decimal_places=2, max_digits=5, null=True
+                    ),
+                ),
+                (
+                    "_path",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to="dcim.cablepath",
+                    ),
+                ),
+                (
+                    "cable",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to="dcim.cable",
+                    ),
+                ),
+                (
+                    "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.AddConstraint(
+            model_name="coolingport",
+            constraint=models.UniqueConstraint(
+                fields=("device", "name"), name="dcim_coolingport_unique_device_name"
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="coolingoutlet",
+            constraint=models.UniqueConstraint(
+                fields=("device", "name"), name="dcim_coolingoutlet_unique_device_name"
+            ),
+        ),
+        migrations.AddIndex(
+            model_name="coolingporttemplate",
+            index=models.Index(
+                fields=["device_type", "module_type", "name"],
+                name="dcim_coolin_device__0665e2_idx",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="coolingporttemplate",
+            constraint=models.UniqueConstraint(
+                fields=("device_type", "name"),
+                name="dcim_coolingporttemplate_unique_device_type_name",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="coolingporttemplate",
+            constraint=models.UniqueConstraint(
+                fields=("module_type", "name"),
+                name="dcim_coolingporttemplate_unique_module_type_name",
+            ),
+        ),
+        migrations.AddIndex(
+            model_name="coolingoutlettemplate",
+            index=models.Index(
+                fields=["device_type", "module_type", "name"],
+                name="dcim_coolin_device__a5bf09_idx",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="coolingoutlettemplate",
+            constraint=models.UniqueConstraint(
+                fields=("device_type", "name"),
+                name="dcim_coolingoutlettemplate_unique_device_type_name",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="coolingoutlettemplate",
+            constraint=models.UniqueConstraint(
+                fields=("module_type", "name"),
+                name="dcim_coolingoutlettemplate_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",
+            ),
+        ),
+    ]

+ 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 *

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

@@ -0,0 +1,251 @@
+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 netbox.models import PrimaryModel
+from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
+
+from .device_components import CabledObjectModel, PathEndpoint
+
+__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,
+        blank=True,
+        null=True
+    )
+    status = models.CharField(
+        verbose_name=_('status'),
+        max_length=50,
+        choices=CoolingSourceStatusChoices,
+        default=CoolingSourceStatusChoices.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=_('Total cooling capacity (kW)')
+    )
+    supply_temperature = models.DecimalField(
+        verbose_name=_('supply temperature'),
+        max_digits=5,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        help_text=_('Design supply temperature (°C)')
+    )
+    return_temperature = models.DecimalField(
+        verbose_name=_('return temperature'),
+        max_digits=5,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        help_text=_('Design return temperature (°C)')
+    )
+
+    clone_fields = (
+        'site', 'location', 'type', 'status', 'cooling_capacity', 'supply_temperature', 'return_temperature',
+    )
+    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(PrimaryModel, PathEndpoint, CabledObjectModel):
+    """
+    A coolant loop delivered from a CoolingSource to a rack or CDU. Supply and return loops are
+    represented as separate feeds so each can be traced independently.
+    """
+    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
+    )
+    type = models.CharField(
+        verbose_name=_('type'),
+        max_length=50,
+        choices=CoolingFeedTypeChoices,
+        default=CoolingFeedTypeChoices.TYPE_SUPPLY
+    )
+    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=_('Cooling capacity (kW)')
+    )
+    flow_rate = models.DecimalField(
+        verbose_name=_('flow rate'),
+        max_digits=8,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(0)],
+        help_text=_('Coolant flow rate (L/min)')
+    )
+    pressure = models.DecimalField(
+        verbose_name=_('pressure'),
+        max_digits=8,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(0)],
+        help_text=_('Operating pressure (kPa)')
+    )
+    supply_temperature = models.DecimalField(
+        verbose_name=_('supply temperature'),
+        max_digits=5,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        help_text=_('Supply temperature (°C)')
+    )
+    return_temperature = models.DecimalField(
+        verbose_name=_('return temperature'),
+        max_digits=5,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        help_text=_('Return temperature (°C)')
+    )
+    tenant = models.ForeignKey(
+        to='tenancy.Tenant',
+        on_delete=models.PROTECT,
+        related_name='cooling_feeds',
+        blank=True,
+        null=True
+    )
+
+    clone_fields = (
+        'cooling_source', 'rack', 'status', 'type', 'mark_connected', 'fluid_type', 'cooling_capacity', 'flow_rate',
+        'pressure', 'supply_temperature', 'return_temperature', '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_type_color(self):
+        return CoolingFeedTypeChoices.colors.get(self.type)
+
+    def get_status_color(self):
+        return CoolingFeedStatusChoices.colors.get(self.status)

+ 172 - 0
netbox/dcim/models/device_component_templates.py

@@ -20,6 +20,8 @@ from wireless.choices import WirelessRoleChoices
 from .device_components import (
     ConsolePort,
     ConsoleServerPort,
+    CoolingOutlet,
+    CoolingPort,
     DeviceBay,
     FrontPort,
     Interface,
@@ -33,6 +35,8 @@ from .device_components import (
 __all__ = (
     'ConsolePortTemplate',
     'ConsoleServerPortTemplate',
+    'CoolingOutletTemplate',
+    'CoolingPortTemplate',
     'DeviceBayTemplate',
     'FrontPortTemplate',
     'InterfaceTemplate',
@@ -432,6 +436,174 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
         }
 
 
+class CoolingPortTemplate(ModularComponentTemplateModel):
+    """
+    A template for a CoolingPort to be created for a new Device.
+    """
+    type = models.CharField(
+        verbose_name=_('type'),
+        max_length=50,
+        choices=CoolingFeedTypeChoices,
+        blank=True,
+        null=True
+    )
+    connector_type = models.CharField(
+        verbose_name=_('connector type'),
+        max_length=50,
+        choices=CoolingConnectorTypeChoices,
+        blank=True,
+        null=True
+    )
+    diameter = models.CharField(
+        verbose_name=_('diameter'),
+        max_length=50,
+        choices=CoolingDiameterChoices,
+        blank=True,
+        null=True
+    )
+    maximum_flow = models.DecimalField(
+        verbose_name=_('maximum flow'),
+        max_digits=8,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(0)],
+        help_text=_('Maximum coolant flow rate (L/min)')
+    )
+    heat_capacity = models.DecimalField(
+        verbose_name=_('heat capacity'),
+        max_digits=8,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(0)],
+        help_text=_('Heat removal capacity (kW)')
+    )
+
+    component_model = CoolingPort
+
+    class Meta(ModularComponentTemplateModel.Meta):
+        verbose_name = _('cooling port template')
+        verbose_name_plural = _('cooling port templates')
+
+    def instantiate(self, **kwargs):
+        return 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,
+            connector_type=self.connector_type,
+            diameter=self.diameter,
+            maximum_flow=self.maximum_flow,
+            heat_capacity=self.heat_capacity,
+            **kwargs
+        )
+    instantiate.do_not_call_in_templates = True
+
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'connector_type': self.connector_type,
+            'diameter': self.diameter,
+            'maximum_flow': float(self.maximum_flow) if self.maximum_flow is not None else None,
+            'heat_capacity': float(self.heat_capacity) if self.heat_capacity is not None else None,
+            'label': self.label,
+            'description': self.description,
+        }
+
+
+class CoolingOutletTemplate(ModularComponentTemplateModel):
+    """
+    A template for a CoolingOutlet to be created for a new Device.
+    """
+    type = models.CharField(
+        verbose_name=_('type'),
+        max_length=50,
+        choices=CoolingFeedTypeChoices,
+        blank=True,
+        null=True
+    )
+    connector_type = models.CharField(
+        verbose_name=_('connector type'),
+        max_length=50,
+        choices=CoolingConnectorTypeChoices,
+        blank=True,
+        null=True
+    )
+    diameter = models.CharField(
+        verbose_name=_('diameter'),
+        max_length=50,
+        choices=CoolingDiameterChoices,
+        blank=True,
+        null=True
+    )
+    color = ColorField(
+        verbose_name=_('color'),
+        blank=True
+    )
+    cooling_port = models.ForeignKey(
+        to='dcim.CoolingPortTemplate',
+        on_delete=models.SET_NULL,
+        blank=True,
+        null=True,
+        related_name='coolingoutlet_templates'
+    )
+
+    component_model = CoolingOutlet
+
+    class Meta(ModularComponentTemplateModel.Meta):
+        verbose_name = _('cooling outlet template')
+        verbose_name_plural = _('cooling outlet templates')
+
+    def clean(self):
+        super().clean()
+
+        # Validate cooling port assignment
+        if self.cooling_port:
+            if self.device_type and self.cooling_port.device_type != self.device_type:
+                raise ValidationError(
+                    _("Parent cooling port ({cooling_port}) must belong to the same device type").format(
+                        cooling_port=self.cooling_port
+                    )
+                )
+            if self.module_type and self.cooling_port.module_type != self.module_type:
+                raise ValidationError(
+                    _("Parent cooling port ({cooling_port}) must belong to the same module type").format(
+                        cooling_port=self.cooling_port
+                    )
+                )
+
+    def instantiate(self, **kwargs):
+        if self.cooling_port:
+            cooling_port_name = self.cooling_port.resolve_name(kwargs.get('module'), kwargs.get('device'))
+            cooling_port = CoolingPort.objects.get(name=cooling_port_name, **kwargs)
+        else:
+            cooling_port = None
+        return 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,
+            connector_type=self.connector_type,
+            diameter=self.diameter,
+            color=self.color,
+            cooling_port=cooling_port,
+            **kwargs
+        )
+    instantiate.do_not_call_in_templates = True
+
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'connector_type': self.connector_type,
+            'diameter': self.diameter,
+            'color': self.color,
+            'cooling_port': self.cooling_port.name if self.cooling_port else None,
+            'label': self.label,
+            'description': self.description,
+        }
+
+
 class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel):
     """
     A template for a physical data interface on a new Device.

+ 124 - 0
netbox/dcim/models/device_components.py

@@ -29,6 +29,8 @@ __all__ = (
     'CabledObjectModel',
     'ConsolePort',
     'ConsoleServerPort',
+    'CoolingOutlet',
+    'CoolingPort',
     'DeviceBay',
     'FrontPort',
     'Interface',
@@ -683,6 +685,128 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
         return PowerOutletStatusChoices.colors.get(self.status)
 
 
+#
+# Cooling components
+#
+
+class CoolingPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
+    """
+    A coolant intake/outlet port within a Device (e.g. a server cold-plate inlet or CDU intake).
+    CoolingPorts connect to CoolingOutlets.
+    """
+    type = models.CharField(
+        verbose_name=_('type'),
+        max_length=50,
+        choices=CoolingFeedTypeChoices,
+        blank=True,
+        null=True,
+        help_text=_('Supply (cold) or return (warm)')
+    )
+    connector_type = models.CharField(
+        verbose_name=_('connector type'),
+        max_length=50,
+        choices=CoolingConnectorTypeChoices,
+        blank=True,
+        null=True,
+        help_text=_('Physical connector type')
+    )
+    diameter = models.CharField(
+        verbose_name=_('diameter'),
+        max_length=50,
+        choices=CoolingDiameterChoices,
+        blank=True,
+        null=True,
+        help_text=_('Nominal connector diameter')
+    )
+    maximum_flow = models.DecimalField(
+        verbose_name=_('maximum flow'),
+        max_digits=8,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(0)],
+        help_text=_('Maximum coolant flow rate (L/min)')
+    )
+    heat_capacity = models.DecimalField(
+        verbose_name=_('heat capacity'),
+        max_digits=8,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(0)],
+        help_text=_('Heat removal capacity (kW)')
+    )
+
+    clone_fields = ('device', 'module', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity')
+
+    class Meta(ModularComponentModel.Meta):
+        verbose_name = _('cooling port')
+        verbose_name_plural = _('cooling ports')
+
+    def get_type_color(self):
+        return CoolingFeedTypeChoices.colors.get(self.type)
+
+
+class CoolingOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
+    """
+    A coolant outlet within a Device (e.g. a CDU or manifold outlet) which feeds a CoolingPort.
+    """
+    type = models.CharField(
+        verbose_name=_('type'),
+        max_length=50,
+        choices=CoolingFeedTypeChoices,
+        blank=True,
+        null=True,
+        help_text=_('Supply (cold) or return (warm)')
+    )
+    connector_type = models.CharField(
+        verbose_name=_('connector type'),
+        max_length=50,
+        choices=CoolingConnectorTypeChoices,
+        blank=True,
+        null=True,
+        help_text=_('Physical connector type')
+    )
+    diameter = models.CharField(
+        verbose_name=_('diameter'),
+        max_length=50,
+        choices=CoolingDiameterChoices,
+        blank=True,
+        null=True,
+        help_text=_('Nominal connector diameter')
+    )
+    cooling_port = models.ForeignKey(
+        to='dcim.CoolingPort',
+        on_delete=models.SET_NULL,
+        blank=True,
+        null=True,
+        related_name='coolingoutlets'
+    )
+    color = ColorField(
+        verbose_name=_('color'),
+        blank=True
+    )
+
+    clone_fields = ('device', 'module', 'type', 'connector_type', 'diameter', 'cooling_port')
+
+    class Meta(ModularComponentModel.Meta):
+        verbose_name = _('cooling outlet')
+        verbose_name_plural = _('cooling outlets')
+
+    def clean(self):
+        super().clean()
+
+        # Validate cooling port assignment
+        if self.cooling_port and self.cooling_port.device != self.device:
+            raise ValidationError(
+                _("Parent cooling port ({cooling_port}) must belong to the same device").format(
+                    cooling_port=self.cooling_port)
+            )
+
+    def get_type_color(self):
+        return CoolingFeedTypeChoices.colors.get(self.type)
+
+
 #
 # Interfaces
 #

+ 48 - 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
+    )
     front_image = models.ImageField(
         upload_to='devicetype-images',
         blank=True
@@ -162,6 +169,14 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         to_model='dcim.PowerOutletTemplate',
         to_field='device_type'
     )
+    cooling_port_template_count = CounterCacheField(
+        to_model='dcim.CoolingPortTemplate',
+        to_field='device_type'
+    )
+    cooling_outlet_template_count = CounterCacheField(
+        to_model='dcim.CoolingOutletTemplate',
+        to_field='device_type'
+    )
     interface_template_count = CounterCacheField(
         to_model='dcim.InterfaceTemplate',
         to_field='device_type'
@@ -192,8 +207,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',
@@ -243,6 +258,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,
             'comments': self.comments,
@@ -265,6 +281,14 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             data['power-outlets'] = [
                 c.to_yaml() for c in self.poweroutlettemplates.all()
             ]
+        if self.coolingporttemplates.exists():
+            data['cooling-ports'] = [
+                c.to_yaml() for c in self.coolingporttemplates.all()
+            ]
+        if self.coolingoutlettemplates.exists():
+            data['cooling-outlets'] = [
+                c.to_yaml() for c in self.coolingoutlettemplates.all()
+            ]
         if self.interfacetemplates.exists():
             data['interfaces'] = [
                 c.to_yaml() for c in self.interfacetemplates.all()
@@ -611,6 +635,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,
@@ -710,6 +741,14 @@ class Device(
         to_model='dcim.PowerOutlet',
         to_field='device'
     )
+    cooling_port_count = CounterCacheField(
+        to_model='dcim.CoolingPort',
+        to_field='device'
+    )
+    cooling_outlet_count = CounterCacheField(
+        to_model='dcim.CoolingOutlet',
+        to_field='device'
+    )
     interface_count = CounterCacheField(
         to_model='dcim.Interface',
         to_field='device'
@@ -739,7 +778,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',
@@ -1052,6 +1091,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
@@ -1068,6 +1111,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.coolingporttemplates.all())
+            self._instantiate_components(self.device_type.coolingoutlettemplates.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())

+ 18 - 0
netbox/dcim/models/modules.py

@@ -116,6 +116,14 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         to_model='dcim.PowerOutletTemplate',
         to_field='module_type'
     )
+    cooling_port_template_count = CounterCacheField(
+        to_model='dcim.CoolingPortTemplate',
+        to_field='module_type'
+    )
+    cooling_outlet_template_count = CounterCacheField(
+        to_model='dcim.CoolingOutletTemplate',
+        to_field='module_type'
+    )
     interface_template_count = CounterCacheField(
         to_model='dcim.InterfaceTemplate',
         to_field='module_type'
@@ -215,6 +223,14 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             data['power-outlets'] = [
                 c.to_yaml() for c in self.poweroutlettemplates.all()
             ]
+        if self.coolingporttemplates.exists():
+            data['cooling-ports'] = [
+                c.to_yaml() for c in self.coolingporttemplates.all()
+            ]
+        if self.coolingoutlettemplates.exists():
+            data['cooling-outlets'] = [
+                c.to_yaml() for c in self.coolingoutlettemplates.all()
+            ]
         if self.interfacetemplates.exists():
             data['interfaces'] = [
                 c.to_yaml() for c in self.interfacetemplates.all()
@@ -354,6 +370,8 @@ class Module(TrackingModelMixin, PrimaryModel):
             ("interfacetemplates", "interfaces", Interface),
             ("powerporttemplates", "powerports", PowerPort),
             ("poweroutlettemplates", "poweroutlets", PowerOutlet),
+            ("coolingporttemplates", "coolingports", CoolingPort),
+            ("coolingoutlettemplates", "coolingoutlets", CoolingOutlet),
             ("rearporttemplates", "rearports", RearPort),
             ("frontporttemplates", "frontports", FrontPort),
             ("modulebaytemplates", "modulebays", ModuleBay),

+ 23 - 3
netbox/dcim/models/racks.py

@@ -359,6 +359,26 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
         blank=True,
         null=True
     )
+    cooling_capability = models.CharField(
+        verbose_name=_('cooling capability'),
+        max_length=50,
+        choices=RackCoolingCapabilityChoices,
+        blank=True,
+        null=True
+    )
+    has_rdhx = models.BooleanField(
+        verbose_name=_('has RDHx'),
+        default=False,
+        help_text=_('Rack is equipped with a rear-door heat exchanger')
+    )
+    cooling_capacity = models.DecimalField(
+        verbose_name=_('cooling capacity'),
+        max_digits=8,
+        decimal_places=2,
+        blank=True,
+        null=True,
+        help_text=_('Cooling capacity (kW)')
+    )
 
     # Generic relations
     vlan_groups = GenericRelation(
@@ -369,9 +389,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',
+        'has_rdhx', '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',

+ 45 - 0
netbox/dcim/search.py

@@ -49,6 +49,51 @@ 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 CoolingOutletIndex(SearchIndex):
+    model = models.CoolingOutlet
+    fields = (
+        ('name', 100),
+        ('label', 200),
+        ('description', 500),
+    )
+    display_attrs = ('device', 'label', 'type', 'description')
+
+
+@register_search
+class CoolingPortIndex(SearchIndex):
+    model = models.CoolingPort
+    fields = (
+        ('name', 100),
+        ('label', 200),
+        ('description', 500),
+        ('maximum_flow', 2000),
+    )
+    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', '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 *

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

@@ -0,0 +1,324 @@
+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 CableTerminationTable, DeviceComponentTable, ModularDeviceComponentTable, PathEndpointTable
+from .devicetypes import ComponentTemplateTable
+from .template_code import COOLINGOUTLET_BUTTONS, COOLINGPORT_BUTTONS, MODULAR_COMPONENT_TEMPLATE_BUTTONS
+
+__all__ = (
+    'CoolingFeedTable',
+    'CoolingOutletTable',
+    'CoolingOutletTemplateTable',
+    'CoolingPortTable',
+    'CoolingPortTemplateTable',
+    'CoolingSourceTable',
+    'DeviceCoolingOutletTable',
+    'DeviceCoolingPortTable',
+)
+
+
+#
+# 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'),
+    )
+    cooling_capacity = tables.Column(
+        verbose_name=_('Cooling Capacity (kW)')
+    )
+    supply_temperature = tables.Column(
+        verbose_name=_('Supply Temperature (°C)')
+    )
+    return_temperature = tables.Column(
+        verbose_name=_('Return Temperature (°C)')
+    )
+    cooling_feed_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', 'cooling_capacity', 'supply_temperature',
+            'return_temperature', 'cooling_feed_count', 'contacts', 'description', 'comments', 'tags', 'created',
+            'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'site', 'location', 'type', 'status', 'cooling_capacity', 'cooling_feed_count',
+        )
+
+
+#
+# Cooling feeds
+#
+
+# We're not using PathEndpointTable for CoolingFeed because cooling connections
+# cannot traverse pass-through ports.
+class CoolingFeedTable(TenancyColumnsMixin, CableTerminationTable, 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'),
+    )
+    type = columns.ChoiceFieldColumn(
+        verbose_name=_('Type'),
+    )
+    fluid_type = columns.ChoiceFieldColumn(
+        verbose_name=_('Fluid Type'),
+    )
+    cooling_capacity = tables.Column(
+        verbose_name=_('Cooling Capacity (kW)')
+    )
+    flow_rate = tables.Column(
+        verbose_name=_('Flow Rate (L/min)')
+    )
+    pressure = tables.Column(
+        verbose_name=_('Pressure (kPa)')
+    )
+    supply_temperature = tables.Column(
+        verbose_name=_('Supply Temperature (°C)')
+    )
+    return_temperature = tables.Column(
+        verbose_name=_('Return Temperature (°C)')
+    )
+    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(CableTerminationTable.Meta, PrimaryModelTable.Meta):
+        model = CoolingFeed
+        fields = (
+            'pk', 'id', 'name', 'cooling_source', 'site', 'rack', 'status', 'type', 'fluid_type', 'cooling_capacity',
+            'flow_rate', 'pressure', 'supply_temperature', 'return_temperature', 'mark_connected', 'cable',
+            'cable_color', 'link_peer', 'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created',
+            'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'cooling_source', 'rack', 'status', 'type', 'fluid_type', 'cooling_capacity', 'flow_rate',
+            'cable', 'link_peer',
+        )
+
+
+#
+# Cooling ports
+#
+
+class CoolingPortTable(ModularDeviceComponentTable, PathEndpointTable):
+    device = tables.Column(
+        verbose_name=_('Device'),
+        linkify={
+            'viewname': 'dcim:device_coolingports',
+            'args': [Accessor('device_id')],
+        }
+    )
+    type = columns.ChoiceFieldColumn(
+        verbose_name=_('Type'),
+    )
+    connector_type = columns.ChoiceFieldColumn(
+        verbose_name=_('Connector Type'),
+    )
+    diameter = columns.ChoiceFieldColumn(
+        verbose_name=_('Diameter'),
+    )
+    maximum_flow = tables.Column(
+        verbose_name=_('Maximum flow (L/min)')
+    )
+    heat_capacity = tables.Column(
+        verbose_name=_('Heat capacity (kW)')
+    )
+    tags = columns.TagColumn(
+        url_name='dcim:coolingport_list'
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = models.CoolingPort
+        fields = (
+            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'connector_type', 'diameter',
+            'description', 'mark_connected', 'maximum_flow', 'heat_capacity', 'cable', 'cable_color', 'link_peer',
+            'connection', 'inventory_items', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'device', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity',
+            'description',
+        )
+
+
+#
+# Cooling outlets
+#
+
+class CoolingOutletTable(ModularDeviceComponentTable, PathEndpointTable):
+    device = tables.Column(
+        verbose_name=_('Device'),
+        linkify={
+            'viewname': 'dcim:device_coolingoutlets',
+            'args': [Accessor('device_id')],
+        }
+    )
+    type = columns.ChoiceFieldColumn(
+        verbose_name=_('Type'),
+    )
+    connector_type = columns.ChoiceFieldColumn(
+        verbose_name=_('Connector Type'),
+    )
+    diameter = columns.ChoiceFieldColumn(
+        verbose_name=_('Diameter'),
+    )
+    cooling_port = tables.Column(
+        verbose_name=_('Cooling Port'),
+        linkify=True
+    )
+    color = columns.ColorColumn()
+    tags = columns.TagColumn(
+        url_name='dcim:coolingoutlet_list'
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = models.CoolingOutlet
+        fields = (
+            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'connector_type', 'diameter',
+            'description', 'cooling_port', 'color', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection',
+            'inventory_items', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'device', 'label', 'type', 'connector_type', 'diameter', 'color', 'cooling_port',
+            'description',
+        )
+
+
+#
+# Cooling port templates
+#
+
+class CoolingPortTemplateTable(ComponentTemplateTable):
+    actions = columns.ActionsColumn(
+        actions=('edit', 'delete'),
+        extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
+    )
+
+    class Meta(ComponentTemplateTable.Meta):
+        model = models.CoolingPortTemplate
+        fields = (
+            'pk', 'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity',
+            'description', 'actions',
+        )
+        empty_text = "None"
+
+
+#
+# Cooling outlet templates
+#
+
+class CoolingOutletTemplateTable(ComponentTemplateTable):
+    color = columns.ColorColumn(
+        verbose_name=_('Color'),
+    )
+    actions = columns.ActionsColumn(
+        actions=('edit', 'delete'),
+        extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
+    )
+
+    class Meta(ComponentTemplateTable.Meta):
+        model = models.CoolingOutletTemplate
+        fields = (
+            'pk', 'name', 'label', 'type', 'connector_type', 'diameter', 'color', 'cooling_port', 'description',
+            'actions',
+        )
+        empty_text = "None"
+
+
+#
+# Device cooling components
+#
+
+class DeviceCoolingPortTable(CoolingPortTable):
+    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'}}
+    )
+    actions = columns.ActionsColumn(
+        extra_buttons=COOLINGPORT_BUTTONS
+    )
+
+    class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
+        model = models.CoolingPort
+        fields = (
+            'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow',
+            'heat_capacity', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
+            'actions',
+        )
+        default_columns = (
+            'pk', 'name', 'label', 'type', 'connector_type', 'diameter', 'maximum_flow', 'heat_capacity',
+            'description', 'cable', 'connection',
+        )
+
+
+class DeviceCoolingOutletTable(CoolingOutletTable):
+    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'}}
+    )
+    actions = columns.ActionsColumn(
+        extra_buttons=COOLINGOUTLET_BUTTONS
+    )
+
+    class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
+        model = models.CoolingOutlet
+        fields = (
+            'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'connector_type', 'diameter', 'color',
+            'cooling_port', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
+            'actions',
+        )
+        default_columns = (
+            'pk', 'name', 'label', 'type', 'connector_type', 'diameter', 'color', 'cooling_port', 'description',
+            'cable', 'connection',
+        )

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

@@ -268,13 +268,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',
         )

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

@@ -146,12 +146,15 @@ 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',
+            'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'cooling_method', 'weight',
             'description', 'comments', 'device_count', 'tags', 'created', 'last_updated',
         )
         default_columns = (

+ 12 - 1
netbox/dcim/tables/racks.py

@@ -196,13 +196,24 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
         template_code=WEIGHT,
         order_by=('_abs_max_weight', 'weight_unit')
     )
+    cooling_capability = columns.ChoiceFieldColumn(
+        verbose_name=_('Cooling Capability'),
+    )
+    has_rdhx = columns.BooleanColumn(
+        verbose_name=_('Has RDHx'),
+        false_mark=None
+    )
+    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', 'has_rdhx',
+            'cooling_capacity', 'weight', 'max_weight', 'comments',
             'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts',
             'tags', 'created', 'last_updated',
         )

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

@@ -343,6 +343,100 @@ POWEROUTLET_BUTTONS = """
 {% endif %}
 """
 
+COOLINGPORT_BUTTONS = """
+{% if perms.dcim.add_inventoryitem %}
+  <a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_coolingports' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
+    <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
+  </a>
+{% endif %}
+{% if record.cable %}
+    <a href="{% url 'dcim:coolingport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
+    {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
+    {% if perms.dcim.change_cable or perms.dcim.delete_cable %}
+        <span class="dropdown">
+            <button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
+            </button>
+            <ul class="dropdown-menu dropdown-menu-end">
+            {% if perms.dcim.change_cable %}
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_coolingports' pk=object.pk %}">
+                    <i class="mdi mdi-pencil-outline"></i>
+                    Edit cable
+                    </a>
+                </li>
+            {% endif %}
+            {% if perms.dcim.delete_cable %}
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_coolingports' pk=object.pk %}">
+                    <i class="mdi mdi-trash-can-outline"></i>
+                    Delete cable
+                    </a>
+                </li>
+            {% endif %}
+            </ul>
+        </span>
+    {% endif %}
+{% elif perms.dcim.add_cable %}
+    <a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
+    <a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
+    <span class="dropdown">
+        <button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+            <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
+        </button>
+        <ul class="dropdown-menu dropdown-menu-end">
+            <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.coolingport&a_terminations={{ record.pk }}&b_terminations_type=dcim.coolingoutlet&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_coolingports' pk=object.pk %}">Cooling Outlet</a></li>
+            <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.coolingport&a_terminations={{ record.pk }}&b_terminations_type=dcim.coolingfeed&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_coolingports' pk=object.pk %}">Cooling Feed</a></li>
+        </ul>
+    </span>
+{% else %}
+    <a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
+{% endif %}
+"""
+
+COOLINGOUTLET_BUTTONS = """
+{% if perms.dcim.add_inventoryitem %}
+  <a href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_coolingoutlets' pk=object.pk %}" class="btn btn-sm btn-primary" title="Add inventory item">
+    <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
+  </a>
+{% endif %}
+{% if record.cable %}
+    <a href="{% url 'dcim:coolingoutlet_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
+    {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
+    {% if perms.dcim.change_cable or perms.dcim.delete_cable %}
+        <span class="dropdown">
+            <button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
+            </button>
+            <ul class="dropdown-menu dropdown-menu-end">
+            {% if perms.dcim.change_cable %}
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_coolingoutlets' pk=object.pk %}">
+                    <i class="mdi mdi-pencil-outline"></i>
+                    Edit cable
+                    </a>
+                </li>
+            {% endif %}
+            {% if perms.dcim.delete_cable %}
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_coolingoutlets' pk=object.pk %}">
+                    <i class="mdi mdi-trash-can-outline"></i>
+                    Delete cable
+                    </a>
+                </li>
+            {% endif %}
+            </ul>
+        </span>
+    {% endif %}
+{% elif perms.dcim.add_cable %}
+    <a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
+    <a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
+    {% if not record.mark_connected %}
+        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.coolingoutlet&a_terminations={{ record.pk }}&b_terminations_type=dcim.coolingport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_coolingoutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
+            <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
+        </a>
+    {% else %}
+        <a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
+    {% endif %}
+{% endif %}
+"""
+
 INTERFACE_BUTTONS = """
 {% if perms.dcim.change_interface %}
   <span class="dropdown">

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

@@ -10,6 +10,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,
+  "coolingoutlet:api_list_objects": 14,
+  "coolingoutlet:list_objects_with_permission": 22,
+  "coolingoutlettemplate:api_list_objects": 11,
+  "coolingport:api_list_objects": 14,
+  "coolingport:list_objects_with_permission": 21,
+  "coolingporttemplate: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,

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

@@ -3810,6 +3810,303 @@ class PowerFeedTestCase(APIViewTestCases.APIViewTestCase):
         ]
 
 
+class CoolingPortTemplateTestCase(APIViewTestCases.APIViewTestCase):
+    model = CoolingPortTemplate
+    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_port_templates = (
+            CoolingPortTemplate(device_type=devicetype, name='Cooling Port Template 1'),
+            CoolingPortTemplate(device_type=devicetype, name='Cooling Port Template 2'),
+            CoolingPortTemplate(device_type=devicetype, name='Cooling Port Template 3'),
+        )
+        CoolingPortTemplate.objects.bulk_create(cooling_port_templates)
+
+        cls.create_data = [
+            {
+                'device_type': devicetype.pk,
+                'name': 'Cooling Port Template 4',
+            },
+            {
+                '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 CoolingOutletTemplateTestCase(APIViewTestCases.APIViewTestCase):
+    model = CoolingOutletTemplate
+    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_port_templates = (
+            CoolingPortTemplate(device_type=devicetype, name='Cooling Port Template 1'),
+            CoolingPortTemplate(device_type=devicetype, name='Cooling Port Template 2'),
+        )
+        CoolingPortTemplate.objects.bulk_create(cooling_port_templates)
+
+        cooling_outlet_templates = (
+            CoolingOutletTemplate(device_type=devicetype, name='Cooling Outlet Template 1'),
+            CoolingOutletTemplate(device_type=devicetype, name='Cooling Outlet Template 2'),
+            CoolingOutletTemplate(device_type=devicetype, name='Cooling Outlet Template 3'),
+        )
+        CoolingOutletTemplate.objects.bulk_create(cooling_outlet_templates)
+
+        cls.create_data = [
+            {
+                'device_type': devicetype.pk,
+                'name': 'Cooling Outlet Template 4',
+                'cooling_port': cooling_port_templates[0].pk,
+            },
+            {
+                'device_type': devicetype.pk,
+                'name': 'Cooling Outlet Template 5',
+                'cooling_port': cooling_port_templates[1].pk,
+            },
+            {
+                'device_type': devicetype.pk,
+                'name': 'Cooling Outlet Template 6',
+                'cooling_port': None,
+            },
+            {
+                'module_type': moduletype.pk,
+                'name': 'Cooling Outlet Template 7',
+            },
+            {
+                'module_type': moduletype.pk,
+                'name': 'Cooling Outlet Template 8',
+            },
+        ]
+
+
+class CoolingPortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+    model = CoolingPort
+    brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+    peer_termination_type = CoolingOutlet
+    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_ports = (
+            CoolingPort(device=device, name='Cooling Port 1'),
+            CoolingPort(device=device, name='Cooling Port 2'),
+            CoolingPort(device=device, name='Cooling Port 3'),
+        )
+        CoolingPort.objects.bulk_create(cooling_ports)
+
+        cls.create_data = [
+            {
+                'device': device.pk,
+                'name': 'Cooling Port 4',
+            },
+            {
+                'device': device.pk,
+                'name': 'Cooling Port 5',
+            },
+            {
+                'device': device.pk,
+                'name': 'Cooling Port 6',
+            },
+        ]
+
+
+class CoolingOutletTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+    model = CoolingOutlet
+    brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+    peer_termination_type = CoolingPort
+    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_ports = (
+            CoolingPort(device=device, name='Cooling Port 1'),
+            CoolingPort(device=device, name='Cooling Port 2'),
+        )
+        CoolingPort.objects.bulk_create(cooling_ports)
+
+        cooling_outlets = (
+            CoolingOutlet(device=device, name='Cooling Outlet 1'),
+            CoolingOutlet(device=device, name='Cooling Outlet 2'),
+            CoolingOutlet(device=device, name='Cooling Outlet 3'),
+        )
+        CoolingOutlet.objects.bulk_create(cooling_outlets)
+
+        cls.create_data = [
+            {
+                'device': device.pk,
+                'name': 'Cooling Outlet 4',
+                'cooling_port': cooling_ports[0].pk,
+            },
+            {
+                'device': device.pk,
+                'name': 'Cooling Outlet 5',
+                'cooling_port': cooling_ports[1].pk,
+            },
+            {
+                'device': device.pk,
+                'name': 'Cooling Outlet 6',
+                'cooling_port': None,
+            },
+        ]
+
+
+class CoolingSourceTestCase(APIViewTestCases.APIViewTestCase):
+    model = CoolingSource
+    brief_fields = ['cooling_feed_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'),
+            CoolingSource(site=sites[0], location=locations[1], name='Cooling Source 2'),
+            CoolingSource(site=sites[0], location=locations[2], name='Cooling Source 3'),
+        )
+        CoolingSource.objects.bulk_create(cooling_sources)
+
+        cls.create_data = [
+            {
+                'name': 'Cooling Source 4',
+                'site': sites[0].pk,
+                'location': locations[0].pk,
+            },
+            {
+                'name': 'Cooling Source 5',
+                'site': sites[0].pk,
+                'location': locations[1].pk,
+            },
+            {
+                'name': 'Cooling Source 6',
+                'site': sites[0].pk,
+                'location': locations[2].pk,
+            },
+        ]
+
+        cls.bulk_update_data = {
+            'site': sites[1].pk,
+            'location': locations[3].pk
+        }
+
+
+class CoolingFeedTestCase(APIViewTestCases.APIViewTestCase):
+    model = CoolingFeed
+    brief_fields = ['_occupied', 'cable', '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'),
+            CoolingSource(site=site, location=location, name='Cooling Source 2'),
+        )
+        CoolingSource.objects.bulk_create(cooling_sources)
+
+        SUPPLY = CoolingFeedTypeChoices.TYPE_SUPPLY
+        RETURN = CoolingFeedTypeChoices.TYPE_RETURN
+        cooling_feeds = (
+            CoolingFeed(cooling_source=cooling_sources[0], rack=racks[0], name='Cooling Feed 1A', type=SUPPLY),
+            CoolingFeed(cooling_source=cooling_sources[1], rack=racks[0], name='Cooling Feed 1B', type=RETURN),
+            CoolingFeed(cooling_source=cooling_sources[0], rack=racks[1], name='Cooling Feed 2A', type=SUPPLY),
+            CoolingFeed(cooling_source=cooling_sources[1], rack=racks[1], name='Cooling Feed 2B', type=RETURN),
+            CoolingFeed(cooling_source=cooling_sources[0], rack=racks[2], name='Cooling Feed 3A', type=SUPPLY),
+            CoolingFeed(cooling_source=cooling_sources[1], rack=racks[2], name='Cooling Feed 3B', type=RETURN),
+        )
+        CoolingFeed.objects.bulk_create(cooling_feeds)
+
+        cls.create_data = [
+            {
+                'name': 'Cooling Feed 4A',
+                'cooling_source': cooling_sources[0].pk,
+                'rack': racks[3].pk,
+                'type': SUPPLY,
+            },
+            {
+                'name': 'Cooling Feed 4B',
+                'cooling_source': cooling_sources[1].pk,
+                'rack': racks[3].pk,
+                'type': RETURN,
+            },
+        ]
+
+
 class VirtualDeviceContextTestCase(APIViewTestCases.APIViewTestCase):
     model = VirtualDeviceContext
     brief_fields = ['description', 'device', 'display', 'id', 'identifier', 'name', 'url']

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

@@ -2026,6 +2026,147 @@ class PowerOutletTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTest
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+class CoolingPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
+    queryset = CoolingPortTemplate.objects.all()
+    filterset = CoolingPortTemplateFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Model 1', slug='model-1'),
+            DeviceType(manufacturer=manufacturer, model='Model 2', slug='model-2'),
+            DeviceType(manufacturer=manufacturer, model='Model 3', slug='model-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
+        CoolingPortTemplate.objects.bulk_create((
+            CoolingPortTemplate(
+                device_type=device_types[0],
+                name='Cooling Port 1',
+                type=CoolingFeedTypeChoices.TYPE_SUPPLY,
+                connector_type=CoolingConnectorTypeChoices.TYPE_UQD,
+                diameter=CoolingDiameterChoices.DN25,
+                maximum_flow=100,
+                heat_capacity=50,
+                description='foobar1'
+            ),
+            CoolingPortTemplate(
+                device_type=device_types[1],
+                name='Cooling Port 2',
+                type=CoolingFeedTypeChoices.TYPE_RETURN,
+                connector_type=CoolingConnectorTypeChoices.TYPE_QDC,
+                diameter=CoolingDiameterChoices.DN32,
+                maximum_flow=200,
+                heat_capacity=100,
+                description='foobar2'
+            ),
+            CoolingPortTemplate(
+                device_type=device_types[2],
+                name='Cooling Port 3',
+                type=CoolingFeedTypeChoices.TYPE_SUPPLY,
+                connector_type=CoolingConnectorTypeChoices.TYPE_BLIND_MATE,
+                diameter=CoolingDiameterChoices.DN40,
+                maximum_flow=300,
+                heat_capacity=150,
+                description='foobar3'
+            ),
+        ))
+
+    def test_name(self):
+        params = {'name': ['Cooling Port 1', 'Cooling Port 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_type(self):
+        params = {'type': CoolingFeedTypeChoices.TYPE_SUPPLY}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_connector_type(self):
+        params = {'connector_type': CoolingConnectorTypeChoices.TYPE_UQD}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_diameter(self):
+        params = {'diameter': CoolingDiameterChoices.DN25}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_maximum_flow(self):
+        params = {'maximum_flow': [100, 200]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_heat_capacity(self):
+        params = {'heat_capacity': [50, 100]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class CoolingOutletTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
+    queryset = CoolingOutletTemplate.objects.all()
+    filterset = CoolingOutletTemplateFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Model 1', slug='model-1'),
+            DeviceType(manufacturer=manufacturer, model='Model 2', slug='model-2'),
+            DeviceType(manufacturer=manufacturer, model='Model 3', slug='model-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
+        CoolingOutletTemplate.objects.bulk_create((
+            CoolingOutletTemplate(
+                device_type=device_types[0],
+                name='Cooling Outlet 1',
+                type=CoolingFeedTypeChoices.TYPE_SUPPLY,
+                connector_type=CoolingConnectorTypeChoices.TYPE_UQD,
+                diameter=CoolingDiameterChoices.DN25,
+                color=ColorChoices.COLOR_RED,
+                description='foobar1'
+            ),
+            CoolingOutletTemplate(
+                device_type=device_types[1],
+                name='Cooling Outlet 2',
+                type=CoolingFeedTypeChoices.TYPE_RETURN,
+                connector_type=CoolingConnectorTypeChoices.TYPE_QDC,
+                diameter=CoolingDiameterChoices.DN32,
+                color=ColorChoices.COLOR_GREEN,
+                description='foobar2'
+            ),
+            CoolingOutletTemplate(
+                device_type=device_types[2],
+                name='Cooling Outlet 3',
+                type=CoolingFeedTypeChoices.TYPE_SUPPLY,
+                connector_type=CoolingConnectorTypeChoices.TYPE_BLIND_MATE,
+                diameter=CoolingDiameterChoices.DN40,
+                color=ColorChoices.COLOR_BLUE,
+                description='foobar3'
+            ),
+        ))
+
+    def test_name(self):
+        params = {'name': ['Cooling Outlet 1', 'Cooling Outlet 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_type(self):
+        params = {'type': CoolingFeedTypeChoices.TYPE_SUPPLY}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_connector_type(self):
+        params = {'connector_type': CoolingConnectorTypeChoices.TYPE_UQD}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_diameter(self):
+        params = {'diameter': CoolingDiameterChoices.DN25}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_color(self):
+        params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class InterfaceTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = InterfaceTemplate.objects.all()
     filterset = InterfaceTemplateFilterSet
@@ -4509,6 +4650,571 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
+class CoolingPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
+    queryset = CoolingPort.objects.all()
+    filterset = CoolingPortFilterSet
+    ignore_fields = ('cable_positions',)
+
+    @classmethod
+    def setUpTestData(cls):
+
+        regions = (
+            Region(name='Region 1', slug='region-1'),
+            Region(name='Region 2', slug='region-2'),
+            Region(name='Region 3', slug='region-3'),
+        )
+        for region in regions:
+            region.save()
+
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
+        sites = Site.objects.bulk_create((
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
+            Site(name='Site X', slug='site-x'),
+        ))
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
+        module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
+
+        roles = (
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+            DeviceRole(name='Device Role 3', slug='device-role-3'),
+        )
+        for role in roles:
+            role.save()
+
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
+        racks = (
+            Rack(name='Rack 1', site=sites[0]),
+            Rack(name='Rack 2', site=sites[1]),
+            Rack(name='Rack 3', site=sites[2]),
+        )
+        Rack.objects.bulk_create(racks)
+
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        devices = (
+            Device(
+                name='Device 1',
+                tenant=tenants[0],
+                device_type=device_types[0],
+                role=roles[0],
+                site=sites[0],
+                location=locations[0],
+                rack=racks[0],
+                status='active',
+            ),
+            Device(
+                name='Device 2',
+                tenant=tenants[1],
+                device_type=device_types[1],
+                role=roles[1],
+                site=sites[1],
+                location=locations[1],
+                rack=racks[1],
+                status='planned',
+            ),
+            Device(
+                name='Device 3',
+                tenant=tenants[2],
+                device_type=device_types[2],
+                role=roles[2],
+                site=sites[2],
+                location=locations[2],
+                rack=racks[2],
+                status='offline',
+            ),
+            # For cable connections
+            Device(
+                name=None,
+                device_type=device_types[2],
+                role=roles[2],
+                site=sites[3],
+                status='offline'
+            ),
+        )
+        Device.objects.bulk_create(devices)
+
+        module_bays = (
+            ModuleBay(device=devices[0], name='Module Bay 1'),
+            ModuleBay(device=devices[1], name='Module Bay 2'),
+            ModuleBay(device=devices[2], name='Module Bay 3'),
+        )
+        for module_bay in module_bays:
+            module_bay.save()
+
+        modules = (
+            Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
+            Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
+            Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
+        )
+        Module.objects.bulk_create(modules)
+
+        cooling_outlets = (
+            CoolingOutlet(device=devices[3], name='Cooling Outlet 1'),
+            CoolingOutlet(device=devices[3], name='Cooling Outlet 2'),
+        )
+        CoolingOutlet.objects.bulk_create(cooling_outlets)
+
+        cooling_ports = (
+            CoolingPort(
+                device=devices[0],
+                module=modules[0],
+                name='Cooling Port 1',
+                label='A',
+                type=CoolingFeedTypeChoices.TYPE_SUPPLY,
+                connector_type=CoolingConnectorTypeChoices.TYPE_UQD,
+                diameter=CoolingDiameterChoices.DN25,
+                maximum_flow=100,
+                heat_capacity=50,
+                description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
+            ),
+            CoolingPort(
+                device=devices[1],
+                module=modules[1],
+                name='Cooling Port 2',
+                label='B',
+                type=CoolingFeedTypeChoices.TYPE_RETURN,
+                connector_type=CoolingConnectorTypeChoices.TYPE_QDC,
+                diameter=CoolingDiameterChoices.DN32,
+                maximum_flow=200,
+                heat_capacity=100,
+                description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
+            ),
+            CoolingPort(
+                device=devices[2],
+                module=modules[2],
+                name='Cooling Port 3',
+                label='C',
+                type=CoolingFeedTypeChoices.TYPE_SUPPLY,
+                connector_type=CoolingConnectorTypeChoices.TYPE_BLIND_MATE,
+                diameter=CoolingDiameterChoices.DN40,
+                maximum_flow=300,
+                heat_capacity=150,
+                description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
+            ),
+        )
+        CoolingPort.objects.bulk_create(cooling_ports)
+
+        # Cables
+        Cable(a_terminations=[cooling_ports[0]], b_terminations=[cooling_outlets[0]]).save()
+        Cable(a_terminations=[cooling_ports[1]], b_terminations=[cooling_outlets[1]]).save()
+        # Third port is not connected
+
+    def test_name(self):
+        params = {'name': ['Cooling Port 1', 'Cooling Port 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_label(self):
+        params = {'label': ['A', 'B']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['First', 'Second']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_type(self):
+        params = {'type': [CoolingFeedTypeChoices.TYPE_SUPPLY]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_connector_type(self):
+        params = {'connector_type': [CoolingConnectorTypeChoices.TYPE_UQD, CoolingConnectorTypeChoices.TYPE_QDC]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_diameter(self):
+        params = {'diameter': [CoolingDiameterChoices.DN25, CoolingDiameterChoices.DN32]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_maximum_flow(self):
+        params = {'maximum_flow': [100, 200]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_heat_capacity(self):
+        params = {'heat_capacity': [50, 100]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_region(self):
+        regions = Region.objects.all()[:2]
+        params = {'region_id': [regions[0].pk, regions[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'region': [regions[0].slug, regions[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_site(self):
+        sites = Site.objects.all()[:2]
+        params = {'site_id': [sites[0].pk, sites[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site': [sites[0].slug, sites[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_rack(self):
+        racks = Rack.objects.all()[:2]
+        params = {'rack_id': [racks[0].pk, racks[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'rack': [racks[0].name, racks[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_device(self):
+        devices = Device.objects.all()[:2]
+        params = {'device_id': [devices[0].pk, devices[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'device': [devices[0].name, devices[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_module(self):
+        modules = Module.objects.all()[:2]
+        params = {'module_id': [modules[0].pk, modules[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_cabled(self):
+        params = {'cabled': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'cabled': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_occupied(self):
+        params = {'occupied': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'occupied': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_connected(self):
+        params = {'connected': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'connected': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+
+class CoolingOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
+    queryset = CoolingOutlet.objects.all()
+    filterset = CoolingOutletFilterSet
+    ignore_fields = ('cable_positions',)
+
+    @classmethod
+    def setUpTestData(cls):
+
+        regions = (
+            Region(name='Region 1', slug='region-1'),
+            Region(name='Region 2', slug='region-2'),
+            Region(name='Region 3', slug='region-3'),
+        )
+        for region in regions:
+            region.save()
+
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
+        sites = Site.objects.bulk_create((
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
+            Site(name='Site X', slug='site-x'),
+        ))
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
+        module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
+
+        roles = (
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+            DeviceRole(name='Device Role 3', slug='device-role-3'),
+        )
+        for role in roles:
+            role.save()
+
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
+        racks = (
+            Rack(name='Rack 1', site=sites[0]),
+            Rack(name='Rack 2', site=sites[1]),
+            Rack(name='Rack 3', site=sites[2]),
+        )
+        Rack.objects.bulk_create(racks)
+
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        devices = (
+            Device(
+                name='Device 1',
+                tenant=tenants[0],
+                device_type=device_types[0],
+                role=roles[0],
+                site=sites[0],
+                location=locations[0],
+                rack=racks[0],
+                status='active',
+            ),
+            Device(
+                name='Device 2',
+                tenant=tenants[1],
+                device_type=device_types[1],
+                role=roles[1],
+                site=sites[1],
+                location=locations[1],
+                rack=racks[1],
+                status='planned',
+            ),
+            Device(
+                name='Device 3',
+                tenant=tenants[2],
+                device_type=device_types[2],
+                role=roles[2],
+                site=sites[2],
+                location=locations[2],
+                rack=racks[2],
+                status='offline',
+            ),
+            # For cable connections
+            Device(
+                name=None,
+                device_type=device_types[2],
+                role=roles[2],
+                site=sites[3],
+                status='offline'
+            ),
+        )
+        Device.objects.bulk_create(devices)
+
+        module_bays = (
+            ModuleBay(device=devices[0], name='Module Bay 1'),
+            ModuleBay(device=devices[1], name='Module Bay 2'),
+            ModuleBay(device=devices[2], name='Module Bay 3'),
+        )
+        for module_bay in module_bays:
+            module_bay.save()
+
+        modules = (
+            Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
+            Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
+            Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
+        )
+        Module.objects.bulk_create(modules)
+
+        cooling_ports = (
+            CoolingPort(device=devices[3], name='Cooling Outlet 1'),
+            CoolingPort(device=devices[3], name='Cooling Outlet 2'),
+        )
+        CoolingPort.objects.bulk_create(cooling_ports)
+
+        cooling_outlets = (
+            CoolingOutlet(
+                device=devices[0],
+                module=modules[0],
+                name='Cooling Outlet 1',
+                label='A',
+                type=CoolingFeedTypeChoices.TYPE_SUPPLY,
+                connector_type=CoolingConnectorTypeChoices.TYPE_UQD,
+                diameter=CoolingDiameterChoices.DN25,
+                description='First',
+                color='ff0000',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
+            ),
+            CoolingOutlet(
+                device=devices[1],
+                module=modules[1],
+                name='Cooling Outlet 2',
+                label='B',
+                type=CoolingFeedTypeChoices.TYPE_RETURN,
+                connector_type=CoolingConnectorTypeChoices.TYPE_QDC,
+                diameter=CoolingDiameterChoices.DN32,
+                description='Second',
+                color='00ff00',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
+            ),
+            CoolingOutlet(
+                device=devices[2],
+                module=modules[2],
+                name='Cooling Outlet 3',
+                label='C',
+                type=CoolingFeedTypeChoices.TYPE_SUPPLY,
+                connector_type=CoolingConnectorTypeChoices.TYPE_BLIND_MATE,
+                diameter=CoolingDiameterChoices.DN40,
+                description='Third',
+                color='0000ff',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
+            ),
+        )
+        CoolingOutlet.objects.bulk_create(cooling_outlets)
+
+        # Cables
+        Cable(a_terminations=[cooling_outlets[0]], b_terminations=[cooling_ports[0]]).save()
+        Cable(a_terminations=[cooling_outlets[1]], b_terminations=[cooling_ports[1]]).save()
+        # Third port is not connected
+
+    def test_name(self):
+        params = {'name': ['Cooling Outlet 1', 'Cooling Outlet 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_label(self):
+        params = {'label': ['A', 'B']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['First', 'Second']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_color(self):
+        params = {'color': ['ff0000', '00ff00']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_type(self):
+        params = {'type': [CoolingFeedTypeChoices.TYPE_SUPPLY]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_connector_type(self):
+        params = {'connector_type': [CoolingConnectorTypeChoices.TYPE_UQD, CoolingConnectorTypeChoices.TYPE_QDC]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_diameter(self):
+        params = {'diameter': [CoolingDiameterChoices.DN25, CoolingDiameterChoices.DN32]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_region(self):
+        regions = Region.objects.all()[:2]
+        params = {'region_id': [regions[0].pk, regions[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'region': [regions[0].slug, regions[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_site(self):
+        sites = Site.objects.all()[:2]
+        params = {'site_id': [sites[0].pk, sites[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site': [sites[0].slug, sites[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_rack(self):
+        racks = Rack.objects.all()[:2]
+        params = {'rack_id': [racks[0].pk, racks[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'rack': [racks[0].name, racks[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_device(self):
+        devices = Device.objects.all()[:2]
+        params = {'device_id': [devices[0].pk, devices[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'device': [devices[0].name, devices[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_module(self):
+        modules = Module.objects.all()[:2]
+        params = {'module_id': [modules[0].pk, modules[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_cabled(self):
+        params = {'cabled': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'cabled': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_occupied(self):
+        params = {'occupied': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'occupied': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_connected(self):
+        params = {'connected': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'connected': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+
 class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = Interface.objects.all()
     filterset = InterfaceFilterSet
@@ -7463,6 +8169,363 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+class CoolingSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = CoolingSource.objects.all()
+    filterset = CoolingSourceFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        regions = (
+            Region(name='Region 1', slug='region-1'),
+            Region(name='Region 2', slug='region-2'),
+            Region(name='Region 3', slug='region-3'),
+        )
+        for region in regions:
+            region.save()
+
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
+        sites = (
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[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]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
+        cooling_sources = (
+            CoolingSource(
+                name='Cooling Source 1',
+                site=sites[0],
+                location=locations[0],
+                type=CoolingSourceTypeChoices.TYPE_CHILLER,
+                status=CoolingSourceStatusChoices.STATUS_ACTIVE,
+                cooling_capacity=100,
+                supply_temperature=18,
+                return_temperature=30,
+                description='foobar1'
+            ),
+            CoolingSource(
+                name='Cooling Source 2',
+                site=sites[1],
+                location=locations[1],
+                type=CoolingSourceTypeChoices.TYPE_COOLING_TOWER,
+                status=CoolingSourceStatusChoices.STATUS_PLANNED,
+                cooling_capacity=200,
+                supply_temperature=20,
+                return_temperature=32,
+                description='foobar2'
+            ),
+            CoolingSource(
+                name='Cooling Source 3',
+                site=sites[2],
+                location=locations[2],
+                type=CoolingSourceTypeChoices.TYPE_DRY_COOLER,
+                status=CoolingSourceStatusChoices.STATUS_OFFLINE,
+                cooling_capacity=300,
+                supply_temperature=22,
+                return_temperature=34,
+                description='foobar3'
+            ),
+        )
+        CoolingSource.objects.bulk_create(cooling_sources)
+
+    def test_q(self):
+        params = {'q': 'foobar1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_name(self):
+        params = {'name': ['Cooling Source 1', 'Cooling Source 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_type(self):
+        params = {'type': [CoolingSourceTypeChoices.TYPE_CHILLER, CoolingSourceTypeChoices.TYPE_COOLING_TOWER]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_status(self):
+        params = {'status': [CoolingSourceStatusChoices.STATUS_ACTIVE, CoolingSourceStatusChoices.STATUS_PLANNED]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_cooling_capacity(self):
+        params = {'cooling_capacity': [100, 200]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_supply_temperature(self):
+        params = {'supply_temperature': [18, 20]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_return_temperature(self):
+        params = {'return_temperature': [30, 32]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_region(self):
+        regions = Region.objects.all()[:2]
+        params = {'region_id': [regions[0].pk, regions[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'region': [regions[0].slug, regions[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_site(self):
+        sites = Site.objects.all()[:2]
+        params = {'site_id': [sites[0].pk, sites[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site': [sites[0].slug, sites[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class CoolingFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = CoolingFeed.objects.all()
+    filterset = CoolingFeedFilterSet
+    ignore_fields = ('cable_positions',)
+
+    @classmethod
+    def setUpTestData(cls):
+
+        regions = (
+            Region(name='Region 1', slug='region-1'),
+            Region(name='Region 2', slug='region-2'),
+            Region(name='Region 3', slug='region-3'),
+        )
+        for region in regions:
+            region.save()
+
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
+        sites = (
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
+        )
+        Site.objects.bulk_create(sites)
+
+        racks = (
+            Rack(name='Rack 1', site=sites[0]),
+            Rack(name='Rack 2', site=sites[1]),
+            Rack(name='Rack 3', site=sites[2]),
+        )
+        Rack.objects.bulk_create(racks)
+
+        tenant_groups = (
+            TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
+            TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
+            TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
+        )
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
+
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
+            Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
+            Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        cooling_sources = (
+            CoolingSource(name='Cooling Source 1', site=sites[0]),
+            CoolingSource(name='Cooling Source 2', site=sites[1]),
+            CoolingSource(name='Cooling Source 3', site=sites[2]),
+        )
+        CoolingSource.objects.bulk_create(cooling_sources)
+
+        cooling_feeds = (
+            CoolingFeed(
+                cooling_source=cooling_sources[0],
+                rack=racks[0],
+                name='Cooling Feed 1',
+                tenant=tenants[0],
+                status=CoolingFeedStatusChoices.STATUS_ACTIVE,
+                type=CoolingFeedTypeChoices.TYPE_SUPPLY,
+                fluid_type=FluidTypeChoices.FLUID_WATER,
+                cooling_capacity=100,
+                flow_rate=10,
+                pressure=100,
+                supply_temperature=18,
+                return_temperature=30,
+                description='foobar1'
+            ),
+            CoolingFeed(
+                cooling_source=cooling_sources[1],
+                rack=racks[1],
+                name='Cooling Feed 2',
+                tenant=tenants[1],
+                status=CoolingFeedStatusChoices.STATUS_FAILED,
+                type=CoolingFeedTypeChoices.TYPE_SUPPLY,
+                fluid_type=FluidTypeChoices.FLUID_WATER,
+                cooling_capacity=200,
+                flow_rate=20,
+                pressure=200,
+                supply_temperature=20,
+                return_temperature=32,
+                description='foobar2'
+            ),
+            CoolingFeed(
+                cooling_source=cooling_sources[2],
+                rack=racks[2],
+                name='Cooling Feed 3',
+                tenant=tenants[2],
+                status=CoolingFeedStatusChoices.STATUS_OFFLINE,
+                type=CoolingFeedTypeChoices.TYPE_RETURN,
+                fluid_type=FluidTypeChoices.FLUID_DIELECTRIC,
+                cooling_capacity=300,
+                flow_rate=30,
+                pressure=300,
+                supply_temperature=22,
+                return_temperature=34,
+                description='foobar3'
+            ),
+        )
+        CoolingFeed.objects.bulk_create(cooling_feeds)
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model', slug='model')
+        role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+        device = Device.objects.create(name='Device', device_type=device_type, role=role, site=sites[0])
+        cooling_ports = [
+            CoolingPort(device=device, name='Cooling Port 1'),
+            CoolingPort(device=device, name='Cooling Port 2'),
+        ]
+        CoolingPort.objects.bulk_create(cooling_ports)
+        Cable(a_terminations=[cooling_feeds[0]], b_terminations=[cooling_ports[0]]).save()
+        Cable(a_terminations=[cooling_feeds[1]], b_terminations=[cooling_ports[1]]).save()
+
+    def test_q(self):
+        params = {'q': 'foobar1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_name(self):
+        params = {'name': ['Cooling Feed 1', 'Cooling Feed 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_status(self):
+        params = {'status': [CoolingFeedStatusChoices.STATUS_ACTIVE, CoolingFeedStatusChoices.STATUS_FAILED]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_type(self):
+        params = {'type': [CoolingFeedTypeChoices.TYPE_SUPPLY]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_fluid_type(self):
+        params = {'fluid_type': [FluidTypeChoices.FLUID_WATER]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_cooling_capacity(self):
+        params = {'cooling_capacity': [100, 200]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_flow_rate(self):
+        params = {'flow_rate': [10, 20]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_pressure(self):
+        params = {'pressure': [100, 200]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_supply_temperature(self):
+        params = {'supply_temperature': [18, 20]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_return_temperature(self):
+        params = {'return_temperature': [30, 32]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_region(self):
+        regions = Region.objects.all()[:2]
+        params = {'region_id': [regions[0].pk, regions[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'region': [regions[0].slug, regions[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_site(self):
+        sites = Site.objects.all()[:2]
+        params = {'site_id': [sites[0].pk, sites[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site': [sites[0].slug, sites[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_cooling_source_id(self):
+        cooling_sources = CoolingSource.objects.all()[:2]
+        params = {'cooling_source_id': [cooling_sources[0].pk, cooling_sources[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_rack_id(self):
+        racks = Rack.objects.all()[:2]
+        params = {'rack_id': [racks[0].pk, racks[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_cabled(self):
+        params = {'cabled': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'cabled': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_connected(self):
+        params = {'connected': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'connected': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_tenant(self):
+        tenants = Tenant.objects.all()[:2]
+        params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'tenant': [tenants[0].slug, tenants[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_tenant_group(self):
+        tenant_groups = TenantGroup.objects.all()[:2]
+        params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VirtualDeviceContext.objects.all()
     filterset = VirtualDeviceContextFilterSet

+ 123 - 0
netbox/dcim/tests/test_models.py

@@ -2789,3 +2789,126 @@ 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 CoolingPort and CoolingOutlet components.
+        """
+        device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer,
+            model='Device Type 3',
+            slug='device-type-3'
+        )
+
+        cooling_port_template = CoolingPortTemplate.objects.create(
+            device_type=device_type,
+            name='Cooling Port 1',
+            type=CoolingFeedTypeChoices.TYPE_SUPPLY,
+            connector_type=CoolingConnectorTypeChoices.TYPE_UQD,
+            diameter=CoolingDiameterChoices.DN25,
+            maximum_flow=100,
+            heat_capacity=50
+        )
+        CoolingOutletTemplate.objects.create(
+            device_type=device_type,
+            name='Cooling Outlet 1',
+            type=CoolingFeedTypeChoices.TYPE_SUPPLY,
+            connector_type=CoolingConnectorTypeChoices.TYPE_UQD,
+            diameter=CoolingDiameterChoices.DN25
+        )
+
+        device = Device.objects.create(
+            site=self.site,
+            device_type=device_type,
+            role=self.role,
+            name='Device 3'
+        )
+
+        cooling_port = CoolingPort.objects.get(
+            device=device,
+            name='Cooling Port 1',
+            type=CoolingFeedTypeChoices.TYPE_SUPPLY,
+            connector_type=CoolingConnectorTypeChoices.TYPE_UQD,
+            diameter=CoolingDiameterChoices.DN25,
+            maximum_flow=100,
+            heat_capacity=50
+        )
+        self.assertEqual(cooling_port_template.maximum_flow, cooling_port.maximum_flow)
+
+        CoolingOutlet.objects.get(
+            device=device,
+            name='Cooling Outlet 1',
+            type=CoolingFeedTypeChoices.TYPE_SUPPLY,
+            connector_type=CoolingConnectorTypeChoices.TYPE_UQD,
+            diameter=CoolingDiameterChoices.DN25
+        )
+
+    def test_cooling_outlet_clean_different_device(self):
+        """
+        CoolingOutlet.clean() should raise a ValidationError when its cooling_port 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_port = CoolingPort.objects.create(device=device1, name='Cooling Port 1')
+        cooling_outlet = CoolingOutlet(device=device2, name='Cooling Outlet 1', cooling_port=cooling_port)
+
+        with self.assertRaises(ValidationError):
+            cooling_outlet.full_clean()

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

@@ -4216,6 +4216,415 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertHttpStatus(response, 200)
 
 
+class CoolingPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+    model = CoolingPortTemplate
+    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')
+
+        CoolingPortTemplate.objects.bulk_create((
+            CoolingPortTemplate(device_type=devicetype, name='Cooling Port Template 1'),
+            CoolingPortTemplate(device_type=devicetype, name='Cooling Port Template 2'),
+            CoolingPortTemplate(device_type=devicetype, name='Cooling Port Template 3'),
+        ))
+
+        cls.form_data = {
+            'device_type': devicetype.pk,
+            'name': 'Cooling Port Template X',
+            'type': CoolingFeedTypeChoices.TYPE_SUPPLY,
+            'connector_type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': CoolingDiameterChoices.DN25,
+            'maximum_flow': 100,
+            'heat_capacity': 50,
+        }
+
+        cls.bulk_create_data = {
+            'device_type': devicetype.pk,
+            'name': 'Cooling Port Template [4-6]',
+            'type': CoolingFeedTypeChoices.TYPE_SUPPLY,
+            'connector_type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': CoolingDiameterChoices.DN25,
+            'maximum_flow': 100,
+            'heat_capacity': 50,
+        }
+
+        cls.bulk_edit_data = {
+            'type': CoolingFeedTypeChoices.TYPE_SUPPLY,
+            'connector_type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': CoolingDiameterChoices.DN25,
+            'maximum_flow': 100,
+            'heat_capacity': 50,
+        }
+
+
+class CoolingOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+    model = CoolingOutletTemplate
+    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')
+
+        CoolingOutletTemplate.objects.bulk_create((
+            CoolingOutletTemplate(device_type=devicetype, name='Cooling Outlet Template 1'),
+            CoolingOutletTemplate(device_type=devicetype, name='Cooling Outlet Template 2'),
+            CoolingOutletTemplate(device_type=devicetype, name='Cooling Outlet Template 3'),
+        ))
+
+        coolingports = (
+            CoolingPortTemplate(device_type=devicetype, name='Cooling Port Template 1'),
+        )
+        CoolingPortTemplate.objects.bulk_create(coolingports)
+
+        cls.form_data = {
+            'device_type': devicetype.pk,
+            'name': 'Cooling Outlet Template X',
+            'type': CoolingFeedTypeChoices.TYPE_SUPPLY,
+            'connector_type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': CoolingDiameterChoices.DN25,
+            'cooling_port': coolingports[0].pk,
+        }
+
+        cls.bulk_create_data = {
+            'device_type': devicetype.pk,
+            'name': 'Cooling Outlet Template [4-6]',
+            'type': CoolingFeedTypeChoices.TYPE_SUPPLY,
+            'connector_type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': CoolingDiameterChoices.DN25,
+            'cooling_port': coolingports[0].pk,
+        }
+
+        cls.bulk_edit_data = {
+            'type': CoolingFeedTypeChoices.TYPE_SUPPLY,
+            'connector_type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': CoolingDiameterChoices.DN25,
+        }
+
+
+class CoolingPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
+    model = CoolingPort
+    validation_excluded_fields = ('name', 'label')
+
+    @classmethod
+    def setUpTestData(cls):
+        device = create_test_device('Device 1')
+
+        cooling_ports = (
+            CoolingPort(device=device, name='Cooling Port 1'),
+            CoolingPort(device=device, name='Cooling Port 2'),
+            CoolingPort(device=device, name='Cooling Port 3'),
+        )
+        CoolingPort.objects.bulk_create(cooling_ports)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'device': device.pk,
+            'name': 'Cooling Port X',
+            'type': CoolingFeedTypeChoices.TYPE_SUPPLY,
+            'connector_type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': CoolingDiameterChoices.DN25,
+            'maximum_flow': 100,
+            'heat_capacity': 50,
+            'description': 'A cooling port',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.bulk_create_data = {
+            'device': device.pk,
+            'name': 'Cooling Port [4-6]]',
+            'type': CoolingFeedTypeChoices.TYPE_SUPPLY,
+            'connector_type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': CoolingDiameterChoices.DN25,
+            'maximum_flow': 100,
+            'heat_capacity': 50,
+            'description': 'A cooling port',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.bulk_edit_data = {
+            'type': CoolingFeedTypeChoices.TYPE_SUPPLY,
+            'connector_type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': CoolingDiameterChoices.DN25,
+            'maximum_flow': 100,
+            'heat_capacity': 50,
+            '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_ports[0].pk},Cooling Port 7,New description7",
+            f"{cooling_ports[1].pk},Cooling Port 8,New description8",
+            f"{cooling_ports[2].pk},Cooling Port 9,New description9",
+        )
+
+    def test_trace(self):
+        self.add_permissions(
+            'dcim.view_coolingport',
+            'dcim.view_coolingoutlet',
+            'dcim.view_cable',
+            'dcim.view_device',
+        )
+        coolingport = CoolingPort.objects.first()
+        coolingoutlet = CoolingOutlet.objects.create(
+            device=coolingport.device,
+            name='Cooling Outlet 1'
+        )
+        Cable(a_terminations=[coolingport], b_terminations=[coolingoutlet]).save()
+
+        response = self.client.get(reverse('dcim:coolingport_trace', kwargs={'pk': coolingport.pk}))
+        self.assertHttpStatus(response, 200)
+
+
+class CoolingOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
+    model = CoolingOutlet
+    validation_excluded_fields = ('name', 'label')
+
+    @classmethod
+    def setUpTestData(cls):
+        device = create_test_device('Device 1')
+
+        coolingports = (
+            CoolingPort(device=device, name='Cooling Port 1'),
+            CoolingPort(device=device, name='Cooling Port 2'),
+        )
+        CoolingPort.objects.bulk_create(coolingports)
+
+        cooling_outlets = (
+            CoolingOutlet(device=device, name='Cooling Outlet 1', cooling_port=coolingports[0]),
+            CoolingOutlet(device=device, name='Cooling Outlet 2', cooling_port=coolingports[0]),
+            CoolingOutlet(device=device, name='Cooling Outlet 3', cooling_port=coolingports[0]),
+        )
+        CoolingOutlet.objects.bulk_create(cooling_outlets)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'device': device.pk,
+            'name': 'Cooling Outlet X',
+            'type': CoolingFeedTypeChoices.TYPE_SUPPLY,
+            'connector_type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': CoolingDiameterChoices.DN25,
+            'cooling_port': coolingports[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': CoolingFeedTypeChoices.TYPE_SUPPLY,
+            'connector_type': CoolingConnectorTypeChoices.TYPE_UQD,
+            'diameter': CoolingDiameterChoices.DN25,
+            'cooling_port': coolingports[1].pk,
+            'description': 'A cooling outlet',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.bulk_edit_data = {
+            'type': CoolingFeedTypeChoices.TYPE_RETURN,
+            'cooling_port': coolingports[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_outlets[0].pk},Cooling Outlet 7,New description7",
+            f"{cooling_outlets[1].pk},Cooling Outlet 8,New description8",
+            f"{cooling_outlets[2].pk},Cooling Outlet 9,New description9",
+        )
+
+    def test_trace(self):
+        self.add_permissions(
+            'dcim.view_coolingoutlet',
+            'dcim.view_coolingport',
+            'dcim.view_cable',
+            'dcim.view_device',
+        )
+        coolingoutlet = CoolingOutlet.objects.first()
+        coolingport = CoolingPort.objects.first()
+        Cable(a_terminations=[coolingoutlet], b_terminations=[coolingport]).save()
+
+        response = self.client.get(reverse('dcim:coolingoutlet_trace', kwargs={'pk': coolingoutlet.pk}))
+        self.assertHttpStatus(response, 200)
+
+
+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'),
+            CoolingSource(site=sites[0], location=locations[0], name='Cooling Source 2'),
+            CoolingSource(site=sites[0], location=locations[0], name='Cooling Source 3'),
+        )
+        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',
+            'status': CoolingSourceStatusChoices.STATUS_ACTIVE,
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "site,location,name,status",
+            "Site 1,Location 1,Cooling Source 4,active",
+            "Site 1,Location 1,Cooling Source 5,active",
+            "Site 1,Location 1,Cooling Source 6,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'),
+            CoolingSource(site=site, name='Cooling Source 2'),
+        )
+        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,
+            'type': CoolingFeedTypeChoices.TYPE_RETURN,
+            'fluid_type': FluidTypeChoices.FLUID_WATER,
+            'cooling_capacity': 100,
+            'flow_rate': 50,
+            'pressure': 200,
+            'supply_temperature': 18,
+            'return_temperature': 30,
+            'comments': 'New comments',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "site,cooling_source,name,status,type",
+            "Site 1,Cooling Source 1,Cooling Feed 4,active,supply",
+            "Site 1,Cooling Source 1,Cooling Feed 5,active,supply",
+            "Site 1,Cooling Source 1,Cooling Feed 6,active,supply",
+        )
+
+        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,
+            'type': CoolingFeedTypeChoices.TYPE_RETURN,
+            'fluid_type': FluidTypeChoices.FLUID_WATER,
+            'cooling_capacity': 100,
+            'flow_rate': 50,
+            'pressure': 200,
+            'supply_temperature': 18,
+            'return_temperature': 30,
+            'comments': 'New comments',
+        }
+
+    def test_trace(self):
+        self.add_permissions(
+            'dcim.view_coolingfeed',
+            'dcim.view_coolingport',
+            'dcim.view_cable',
+            'dcim.view_device',
+        )
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+        )
+        role = DeviceRole.objects.create(
+            name='Device Role', slug='device-role-1'
+        )
+        device = Device.objects.create(
+            site=Site.objects.first(), device_type=device_type, role=role
+        )
+
+        coolingfeed = CoolingFeed.objects.first()
+        coolingport = CoolingPort.objects.create(
+            device=device,
+            name='Cooling Port 1'
+        )
+        Cable(a_terminations=[coolingfeed], b_terminations=[coolingport]).save()
+
+        response = self.client.get(reverse('dcim:coolingfeed_trace', kwargs={'pk': coolingfeed.pk}))
+        self.assertHttpStatus(response, 200)
+
+
 class VirtualDeviceContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = VirtualDeviceContext
 

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

@@ -55,6 +55,9 @@ 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')
+    has_rdhx = attrs.BooleanAttr('has_rdhx', label=_('Has RDHx'))
+    cooling_capacity = attrs.TextAttr('cooling_capacity', format_string=_('{} kW'))
     space_utilization = attrs.UtilizationAttr('get_utilization')
     power_utilization = attrs.UtilizationAttr('get_power_utilization')
 
@@ -95,6 +98,7 @@ class DevicePanel(panels.ObjectAttributesPanel):
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     description = attrs.TextAttr('description')
     airflow = attrs.ChoiceAttr('airflow')
+    cooling_method = attrs.ChoiceAttr('cooling_method')
     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)
     config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
@@ -158,6 +162,7 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
     weight = attrs.WeightAttr('weight')
     subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child'))
     airflow = attrs.ChoiceAttr('airflow')
+    cooling_method = attrs.ChoiceAttr('cooling_method')
     front_image = attrs.ImageAttr('front_image')
     rear_image = attrs.ImageAttr('rear_image')
 
@@ -236,6 +241,32 @@ class PowerOutletPanel(panels.ObjectAttributesPanel):
     feed_leg = attrs.ChoiceAttr('feed_leg')
 
 
+class CoolingPortPanel(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')
+    connector_type = attrs.ChoiceAttr('connector_type')
+    diameter = attrs.ChoiceAttr('diameter')
+    description = attrs.TextAttr('description')
+    maximum_flow = attrs.TextAttr('maximum_flow', format_string=_('{} L/min'))
+    heat_capacity = attrs.TextAttr('heat_capacity', format_string=_('{} kW'))
+
+
+class CoolingOutletPanel(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')
+    connector_type = attrs.ChoiceAttr('connector_type')
+    diameter = attrs.ChoiceAttr('diameter')
+    description = attrs.TextAttr('description')
+    color = attrs.ColorAttr('color')
+    cooling_port = attrs.RelatedObjectAttr('cooling_port', linkify=True)
+
+
 class FrontPortPanel(panels.ObjectAttributesPanel):
     device = attrs.RelatedObjectAttr('device', linkify=True)
     module = attrs.RelatedObjectAttr('module', linkify=True)
@@ -361,6 +392,42 @@ 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')
+    cooling_capacity = attrs.TextAttr('cooling_capacity', format_string=_('{} kW'))
+    supply_temperature = attrs.TextAttr('supply_temperature', format_string=_('{} °C'))
+    return_temperature = attrs.TextAttr('return_temperature', format_string=_('{} °C'))
+    description = attrs.TextAttr('description')
+
+
+class CoolingFeedPanel(panels.ObjectAttributesPanel):
+    cooling_source = attrs.RelatedObjectAttr('cooling_source', linkify=True)
+    rack = attrs.RelatedObjectAttr('rack', linkify=True)
+    type = attrs.ChoiceAttr('type')
+    status = attrs.ChoiceAttr('status')
+    fluid_type = attrs.ChoiceAttr('fluid_type')
+    description = attrs.TextAttr('description')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    connected_device = attrs.TemplatedAttr(
+        'connected_endpoints',
+        label=_('Connected device'),
+        template_name='dcim/powerfeed/attrs/connected_device.html',
+    )
+
+
+class CoolingFeedElectricalPanel(panels.ObjectAttributesPanel):
+    title = _('Cooling Characteristics')
+
+    cooling_capacity = attrs.TextAttr('cooling_capacity', format_string=_('{} kW'))
+    flow_rate = attrs.TextAttr('flow_rate', format_string=_('{} L/min'))
+    pressure = attrs.TextAttr('pressure', format_string=_('{} kPa'))
+    supply_temperature = attrs.TextAttr('supply_temperature', format_string=_('{} °C'))
+    return_temperature = attrs.TextAttr('return_temperature', format_string=_('{} °C'))
+
+
 class VirtualDeviceContextPanel(panels.ObjectAttributesPanel):
     name = attrs.TextAttr('name')
     device = attrs.RelatedObjectAttr('device', linkify=True)

+ 28 - 0
netbox/dcim/urls.py

@@ -59,6 +59,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-port-templates/', include(get_model_urls('dcim', 'coolingporttemplate', detail=False))),
+    path('cooling-port-templates/<int:pk>/', include(get_model_urls('dcim', 'coolingporttemplate'))),
+
+    path('cooling-outlet-templates/', include(get_model_urls('dcim', 'coolingoutlettemplate', detail=False))),
+    path('cooling-outlet-templates/<int:pk>/', include(get_model_urls('dcim', 'coolingoutlettemplate'))),
+
     path('interface-templates/', include(get_model_urls('dcim', 'interfacetemplate', detail=False))),
     path('interface-templates/<int:pk>/', include(get_model_urls('dcim', 'interfacetemplate'))),
 
@@ -120,6 +126,22 @@ urlpatterns = [
         name='device_bulk_add_poweroutlet'
     ),
 
+    path('cooling-ports/', include(get_model_urls('dcim', 'coolingport', detail=False))),
+    path('cooling-ports/<int:pk>/', include(get_model_urls('dcim', 'coolingport'))),
+    path(
+        'devices/cooling-ports/add/',
+        views.DeviceBulkAddCoolingPortView.as_view(),
+        name='device_bulk_add_coolingport'
+    ),
+
+    path('cooling-outlets/', include(get_model_urls('dcim', 'coolingoutlet', detail=False))),
+    path('cooling-outlets/<int:pk>/', include(get_model_urls('dcim', 'coolingoutlet'))),
+    path(
+        'devices/cooling-outlets/add/',
+        views.DeviceBulkAddCoolingOutletView.as_view(),
+        name='device_bulk_add_coolingoutlet'
+    ),
+
     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'),
@@ -175,6 +197,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'))),
 

+ 558 - 6
netbox/dcim/views.py

@@ -62,10 +62,13 @@ CABLE_TERMINATION_TYPES = {
     'dcim.consoleserverport': ConsoleServerPort,
     'dcim.powerport': PowerPort,
     'dcim.poweroutlet': PowerOutlet,
+    'dcim.coolingport': CoolingPort,
+    'dcim.coolingoutlet': CoolingOutlet,
     'dcim.interface': Interface,
     'dcim.frontport': FrontPort,
     'dcim.rearport': RearPort,
     'dcim.powerfeed': PowerFeed,
+    'dcim.coolingfeed': CoolingFeed,
     'circuits.circuittermination': CircuitTermination,
 }
 
@@ -1429,9 +1432,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, CoolingOutletTemplate, CoolingPortTemplate,
+                DeviceBayTemplate, FrontPortTemplate, InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate,
+                PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
             ]),
         }
 
@@ -1508,6 +1511,36 @@ class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
     )
 
 
+@register_model_view(DeviceType, 'coolingports', path='cooling-ports')
+class DeviceTypeCoolingPortsView(DeviceTypeComponentsView):
+    child_model = CoolingPortTemplate
+    table = tables.CoolingPortTemplateTable
+    filterset = filtersets.CoolingPortTemplateFilterSet
+    viewname = 'dcim:devicetype_coolingports'
+    tab = ViewTab(
+        label=_('Cooling Ports'),
+        badge=lambda obj: obj.cooling_port_template_count,
+        permission='dcim.view_coolingporttemplate',
+        weight=570,
+        hide_if_empty=True
+    )
+
+
+@register_model_view(DeviceType, 'coolingoutlets', path='cooling-outlets')
+class DeviceTypeCoolingOutletsView(DeviceTypeComponentsView):
+    child_model = CoolingOutletTemplate
+    table = tables.CoolingOutletTemplateTable
+    filterset = filtersets.CoolingOutletTemplateFilterSet
+    viewname = 'dcim:devicetype_coolingoutlets'
+    tab = ViewTab(
+        label=_('Cooling Outlets'),
+        badge=lambda obj: obj.cooling_outlet_template_count,
+        permission='dcim.view_coolingoutlettemplate',
+        weight=580,
+        hide_if_empty=True
+    )
+
+
 @register_model_view(DeviceType, 'interfaces')
 class DeviceTypeInterfacesView(DeviceTypeComponentsView):
     child_model = InterfaceTemplate
@@ -1606,6 +1639,8 @@ class DeviceTypeImportView(generic.BulkImportView):
         'dcim.add_consoleserverporttemplate',
         'dcim.add_powerporttemplate',
         'dcim.add_poweroutlettemplate',
+        'dcim.add_coolingporttemplate',
+        'dcim.add_coolingoutlettemplate',
         'dcim.add_interfacetemplate',
         'dcim.add_frontporttemplate',
         'dcim.add_rearporttemplate',
@@ -1620,6 +1655,8 @@ class DeviceTypeImportView(generic.BulkImportView):
         'console-server-ports': forms.ConsoleServerPortTemplateImportForm,
         'power-ports': forms.PowerPortTemplateImportForm,
         'power-outlets': forms.PowerOutletTemplateImportForm,
+        'cooling-ports': forms.CoolingPortTemplateImportForm,
+        'cooling-outlets': forms.CoolingOutletTemplateImportForm,
         'interfaces': forms.InterfaceTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
@@ -1783,9 +1820,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, CoolingOutletTemplate, CoolingPortTemplate,
+                DeviceBayTemplate, FrontPortTemplate, InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate,
+                PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
             ]),
         }
 
@@ -1862,6 +1899,36 @@ class ModuleTypePowerOutletsView(ModuleTypeComponentsView):
     )
 
 
+@register_model_view(ModuleType, 'coolingports', path='cooling-ports')
+class ModuleTypeCoolingPortsView(ModuleTypeComponentsView):
+    child_model = CoolingPortTemplate
+    table = tables.CoolingPortTemplateTable
+    filterset = filtersets.CoolingPortTemplateFilterSet
+    viewname = 'dcim:moduletype_coolingports'
+    tab = ViewTab(
+        label=_('Cooling Ports'),
+        badge=lambda obj: obj.cooling_port_template_count,
+        permission='dcim.view_coolingporttemplate',
+        weight=550,
+        hide_if_empty=True
+    )
+
+
+@register_model_view(ModuleType, 'coolingoutlets', path='cooling-outlets')
+class ModuleTypeCoolingOutletsView(ModuleTypeComponentsView):
+    child_model = CoolingOutletTemplate
+    table = tables.CoolingOutletTemplateTable
+    filterset = filtersets.CoolingOutletTemplateFilterSet
+    viewname = 'dcim:moduletype_coolingoutlets'
+    tab = ViewTab(
+        label=_('Cooling Outlets'),
+        badge=lambda obj: obj.cooling_outlet_template_count,
+        permission='dcim.view_coolingoutlettemplate',
+        weight=560,
+        hide_if_empty=True
+    )
+
+
 @register_model_view(ModuleType, 'interfaces')
 class ModuleTypeInterfacesView(ModuleTypeComponentsView):
     child_model = InterfaceTemplate
@@ -1930,6 +1997,8 @@ class ModuleTypeImportView(generic.BulkImportView):
         'dcim.add_consoleserverporttemplate',
         'dcim.add_powerporttemplate',
         'dcim.add_poweroutlettemplate',
+        'dcim.add_coolingporttemplate',
+        'dcim.add_coolingoutlettemplate',
         'dcim.add_interfacetemplate',
         'dcim.add_frontporttemplate',
         'dcim.add_rearporttemplate',
@@ -1942,6 +2011,8 @@ class ModuleTypeImportView(generic.BulkImportView):
         'console-server-ports': forms.ConsoleServerPortTemplateImportForm,
         'power-ports': forms.PowerPortTemplateImportForm,
         'power-outlets': forms.PowerOutletTemplateImportForm,
+        'cooling-ports': forms.CoolingPortTemplateImportForm,
+        'cooling-outlets': forms.CoolingOutletTemplateImportForm,
         'interfaces': forms.InterfaceTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
@@ -2144,6 +2215,88 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView):
     table = tables.PowerOutletTemplateTable
 
 
+#
+# Cooling port templates
+#
+
+@register_model_view(CoolingPortTemplate, 'add', detail=False)
+class CoolingPortTemplateCreateView(generic.ComponentCreateView):
+    queryset = CoolingPortTemplate.objects.all()
+    form = forms.CoolingPortTemplateCreateForm
+    model_form = forms.CoolingPortTemplateForm
+
+
+@register_model_view(CoolingPortTemplate, 'edit')
+class CoolingPortTemplateEditView(generic.ObjectEditView):
+    queryset = CoolingPortTemplate.objects.all()
+    form = forms.CoolingPortTemplateForm
+
+
+@register_model_view(CoolingPortTemplate, 'delete')
+class CoolingPortTemplateDeleteView(generic.ObjectDeleteView):
+    queryset = CoolingPortTemplate.objects.all()
+
+
+@register_model_view(CoolingPortTemplate, 'bulk_edit', path='edit', detail=False)
+class CoolingPortTemplateBulkEditView(generic.BulkEditView):
+    queryset = CoolingPortTemplate.objects.all()
+    table = tables.CoolingPortTemplateTable
+    form = forms.CoolingPortTemplateBulkEditForm
+
+
+@register_model_view(CoolingPortTemplate, 'bulk_rename', path='rename', detail=False)
+class CoolingPortTemplateBulkRenameView(generic.BulkRenameView):
+    queryset = CoolingPortTemplate.objects.all()
+    rename_fields = ('name', 'label')
+
+
+@register_model_view(CoolingPortTemplate, 'bulk_delete', path='delete', detail=False)
+class CoolingPortTemplateBulkDeleteView(generic.BulkDeleteView):
+    queryset = CoolingPortTemplate.objects.all()
+    table = tables.CoolingPortTemplateTable
+
+
+#
+# Cooling outlet templates
+#
+
+@register_model_view(CoolingOutletTemplate, 'add', detail=False)
+class CoolingOutletTemplateCreateView(generic.ComponentCreateView):
+    queryset = CoolingOutletTemplate.objects.all()
+    form = forms.CoolingOutletTemplateCreateForm
+    model_form = forms.CoolingOutletTemplateForm
+
+
+@register_model_view(CoolingOutletTemplate, 'edit')
+class CoolingOutletTemplateEditView(generic.ObjectEditView):
+    queryset = CoolingOutletTemplate.objects.all()
+    form = forms.CoolingOutletTemplateForm
+
+
+@register_model_view(CoolingOutletTemplate, 'delete')
+class CoolingOutletTemplateDeleteView(generic.ObjectDeleteView):
+    queryset = CoolingOutletTemplate.objects.all()
+
+
+@register_model_view(CoolingOutletTemplate, 'bulk_edit', path='edit', detail=False)
+class CoolingOutletTemplateBulkEditView(generic.BulkEditView):
+    queryset = CoolingOutletTemplate.objects.all()
+    table = tables.CoolingOutletTemplateTable
+    form = forms.CoolingOutletTemplateBulkEditForm
+
+
+@register_model_view(CoolingOutletTemplate, 'bulk_rename', path='rename', detail=False)
+class CoolingOutletTemplateBulkRenameView(generic.BulkRenameView):
+    queryset = CoolingOutletTemplate.objects.all()
+    rename_fields = ('name', 'label')
+
+
+@register_model_view(CoolingOutletTemplate, 'bulk_delete', path='delete', detail=False)
+class CoolingOutletTemplateBulkDeleteView(generic.BulkDeleteView):
+    queryset = CoolingOutletTemplate.objects.all()
+    table = tables.CoolingOutletTemplateTable
+
+
 #
 # Interface templates
 #
@@ -2747,6 +2900,38 @@ class DevicePowerOutletsView(DeviceComponentsView):
     )
 
 
+@register_model_view(Device, 'coolingports', path='cooling-ports')
+class DeviceCoolingPortsView(DeviceComponentsView):
+    child_model = CoolingPort
+    table = tables.DeviceCoolingPortTable
+    filterset = filtersets.CoolingPortFilterSet
+    filterset_form = forms.CoolingPortFilterForm
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
+    tab = ViewTab(
+        label=_('Cooling Ports'),
+        badge=lambda obj: obj.cooling_port_count,
+        permission='dcim.view_coolingport',
+        weight=570,
+        hide_if_empty=True
+    )
+
+
+@register_model_view(Device, 'coolingoutlets', path='cooling-outlets')
+class DeviceCoolingOutletsView(DeviceComponentsView):
+    child_model = CoolingOutlet
+    table = tables.DeviceCoolingOutletTable
+    filterset = filtersets.CoolingOutletFilterSet
+    filterset_form = forms.CoolingOutletFilterForm
+    actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
+    tab = ViewTab(
+        label=_('Cooling Outlets'),
+        badge=lambda obj: obj.cooling_outlet_count,
+        permission='dcim.view_coolingoutlet',
+        weight=580,
+        hide_if_empty=True
+    )
+
+
 @register_model_view(Device, 'interfaces')
 class DeviceInterfacesView(DeviceComponentsView):
     child_model = Interface
@@ -3371,6 +3556,183 @@ class PowerOutletBulkDeleteView(generic.BulkDeleteView):
 register_model_view(PowerOutlet, 'trace', kwargs={'model': PowerOutlet})(PathTraceView)
 
 
+#
+# Cooling ports
+#
+
+@register_model_view(CoolingPort, 'list', path='', detail=False)
+class CoolingPortListView(generic.ObjectListView):
+    queryset = CoolingPort.objects.all()
+    filterset = filtersets.CoolingPortFilterSet
+    filterset_form = forms.CoolingPortFilterForm
+    table = tables.CoolingPortTable
+
+
+@register_model_view(CoolingPort)
+class CoolingPortView(generic.ObjectView):
+    queryset = CoolingPort.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.CoolingPortPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            panels.ConnectionPanel(
+                trace_url_name='dcim:coolingport_trace',
+                connect_options=[
+                    {'a_type': 'dcim.coolingport', 'b_type': 'dcim.coolingoutlet', 'label': _('Cooling Outlet')},
+                    {'a_type': 'dcim.coolingport', 'b_type': 'dcim.coolingfeed', 'label': _('Cooling Feed')},
+                ],
+            ),
+            panels.InventoryItemsPanel(),
+        ],
+    )
+
+
+@register_model_view(CoolingPort, 'add', detail=False)
+class CoolingPortCreateView(generic.ComponentCreateView):
+    queryset = CoolingPort.objects.all()
+    form = forms.CoolingPortCreateForm
+    model_form = forms.CoolingPortForm
+
+
+@register_model_view(CoolingPort, 'edit')
+class CoolingPortEditView(generic.ObjectEditView):
+    queryset = CoolingPort.objects.all()
+    form = forms.CoolingPortForm
+
+
+@register_model_view(CoolingPort, 'delete')
+class CoolingPortDeleteView(generic.ObjectDeleteView):
+    queryset = CoolingPort.objects.all()
+
+
+@register_model_view(CoolingPort, 'bulk_import', path='import', detail=False)
+class CoolingPortBulkImportView(generic.BulkImportView):
+    queryset = CoolingPort.objects.all()
+    model_form = forms.CoolingPortImportForm
+
+
+@register_model_view(CoolingPort, 'bulk_edit', path='edit', detail=False)
+class CoolingPortBulkEditView(generic.BulkEditView):
+    queryset = CoolingPort.objects.all()
+    filterset = filtersets.CoolingPortFilterSet
+    table = tables.CoolingPortTable
+    form = forms.CoolingPortBulkEditForm
+
+
+@register_model_view(CoolingPort, 'bulk_rename', path='rename', detail=False)
+class CoolingPortBulkRenameView(generic.BulkRenameView):
+    queryset = CoolingPort.objects.all()
+    filterset = filtersets.CoolingPortFilterSet
+    rename_fields = ('name', 'label')
+
+
+@register_model_view(CoolingPort, 'bulk_disconnect', path='disconnect', detail=False)
+class CoolingPortBulkDisconnectView(BulkDisconnectView):
+    queryset = CoolingPort.objects.all()
+
+
+@register_model_view(CoolingPort, 'bulk_delete', path='delete', detail=False)
+class CoolingPortBulkDeleteView(generic.BulkDeleteView):
+    queryset = CoolingPort.objects.all()
+    filterset = filtersets.CoolingPortFilterSet
+    table = tables.CoolingPortTable
+
+
+# Trace view
+register_model_view(CoolingPort, 'trace', kwargs={'model': CoolingPort})(PathTraceView)
+
+
+#
+# Cooling outlets
+#
+
+@register_model_view(CoolingOutlet, 'list', path='', detail=False)
+class CoolingOutletListView(generic.ObjectListView):
+    queryset = CoolingOutlet.objects.all()
+    filterset = filtersets.CoolingOutletFilterSet
+    filterset_form = forms.CoolingOutletFilterForm
+    table = tables.CoolingOutletTable
+
+
+@register_model_view(CoolingOutlet)
+class CoolingOutletView(generic.ObjectView):
+    queryset = CoolingOutlet.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.CoolingOutletPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            panels.ConnectionPanel(
+                trace_url_name='dcim:coolingoutlet_trace',
+                connect_options=[
+                    {'a_type': 'dcim.coolingoutlet', 'b_type': 'dcim.coolingport', 'label': _('Cooling Port')},
+                ],
+            ),
+            panels.InventoryItemsPanel(),
+        ],
+    )
+
+
+@register_model_view(CoolingOutlet, 'add', detail=False)
+class CoolingOutletCreateView(generic.ComponentCreateView):
+    queryset = CoolingOutlet.objects.all()
+    form = forms.CoolingOutletCreateForm
+    model_form = forms.CoolingOutletForm
+
+
+@register_model_view(CoolingOutlet, 'edit')
+class CoolingOutletEditView(generic.ObjectEditView):
+    queryset = CoolingOutlet.objects.all()
+    form = forms.CoolingOutletForm
+
+
+@register_model_view(CoolingOutlet, 'delete')
+class CoolingOutletDeleteView(generic.ObjectDeleteView):
+    queryset = CoolingOutlet.objects.all()
+
+
+@register_model_view(CoolingOutlet, 'bulk_import', path='import', detail=False)
+class CoolingOutletBulkImportView(generic.BulkImportView):
+    queryset = CoolingOutlet.objects.all()
+    model_form = forms.CoolingOutletImportForm
+
+
+@register_model_view(CoolingOutlet, 'bulk_edit', path='edit', detail=False)
+class CoolingOutletBulkEditView(generic.BulkEditView):
+    queryset = CoolingOutlet.objects.all()
+    filterset = filtersets.CoolingOutletFilterSet
+    table = tables.CoolingOutletTable
+    form = forms.CoolingOutletBulkEditForm
+
+
+@register_model_view(CoolingOutlet, 'bulk_rename', path='rename', detail=False)
+class CoolingOutletBulkRenameView(generic.BulkRenameView):
+    queryset = CoolingOutlet.objects.all()
+    filterset = filtersets.CoolingOutletFilterSet
+    rename_fields = ('name', 'label')
+
+
+@register_model_view(CoolingOutlet, 'bulk_disconnect', path='disconnect', detail=False)
+class CoolingOutletBulkDisconnectView(BulkDisconnectView):
+    queryset = CoolingOutlet.objects.all()
+
+
+@register_model_view(CoolingOutlet, 'bulk_delete', path='delete', detail=False)
+class CoolingOutletBulkDeleteView(generic.BulkDeleteView):
+    queryset = CoolingOutlet.objects.all()
+    filterset = filtersets.CoolingOutletFilterSet
+    table = tables.CoolingOutletTable
+
+
+# Trace view
+register_model_view(CoolingOutlet, 'trace', kwargs={'model': CoolingOutlet})(PathTraceView)
+
+
 #
 # Interfaces
 #
@@ -4198,6 +4560,28 @@ class DeviceBulkAddPowerOutletView(generic.BulkComponentCreateView):
     default_return_url = 'dcim:device_list'
 
 
+class DeviceBulkAddCoolingPortView(generic.BulkComponentCreateView):
+    parent_model = Device
+    parent_field = 'device'
+    form = forms.CoolingPortBulkCreateForm
+    queryset = CoolingPort.objects.all()
+    model_form = forms.CoolingPortForm
+    filterset = filtersets.DeviceFilterSet
+    table = tables.DeviceTable
+    default_return_url = 'dcim:device_list'
+
+
+class DeviceBulkAddCoolingOutletView(generic.BulkComponentCreateView):
+    parent_model = Device
+    parent_field = 'device'
+    form = forms.CoolingOutletBulkCreateForm
+    queryset = CoolingOutlet.objects.all()
+    model_form = forms.CoolingOutletForm
+    filterset = filtersets.DeviceFilterSet
+    table = tables.DeviceTable
+    default_return_url = 'dcim:device_list'
+
+
 class DeviceBulkAddInterfaceView(generic.BulkComponentCreateView):
     parent_model = Device
     parent_field = 'device'
@@ -4896,6 +5280,174 @@ 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()
+    layout = layout.SimpleLayout(
+        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()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.CoolingFeedPanel(),
+            panels.CoolingFeedElectricalPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            panels.ConnectionPanel(
+                trace_url_name='dcim:coolingfeed_trace',
+                connect_options=[
+                    {'a_type': 'dcim.coolingfeed', 'b_type': 'dcim.coolingport', 'label': _('Cooling Port')},
+                ],
+            ),
+            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_disconnect', path='disconnect', detail=False)
+class CoolingFeedBulkDisconnectView(BulkDisconnectView):
+    queryset = CoolingFeed.objects.all()
+
+
+@register_model_view(CoolingFeed, 'bulk_delete', path='delete', detail=False)
+class CoolingFeedBulkDeleteView(generic.BulkDeleteView):
+    queryset = CoolingFeed.objects.all()
+    filterset = filtersets.CoolingFeedFilterSet
+    table = tables.CoolingFeedTable
+
+
+# Trace view
+register_model_view(CoolingFeed, 'trace', kwargs={'model': CoolingFeed})(PathTraceView)
+
+
 #
 # Virtual device contexts
 #

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

@@ -108,6 +108,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', 'coolingport', _('Cooling Ports')),
+                get_model_item('dcim', 'coolingoutlet', _('Cooling Outlets')),
                 get_model_item('dcim', 'modulebay', _('Module Bays')),
                 get_model_item('dcim', 'devicebay', _('Device Bays')),
                 get_model_item('dcim', 'inventoryitem', _('Inventory Items')),
@@ -334,6 +336,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',
@@ -481,6 +497,7 @@ def get_menus():
         VIRTUALIZATION_MENU,
         CIRCUITS_MENU,
         POWER_MENU,
+        COOLING_MENU,
         PROVISIONING_MENU,
         CUSTOMIZATION_MENU,
         OPERATIONS_MENU,

+ 10 - 0
netbox/templates/dcim/coolingfeed.html

@@ -0,0 +1,10 @@
+{% extends 'generic/object.html' %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item"><a href="{% url 'dcim:coolingfeed_list' %}?site_id={{ object.cooling_source.site.pk }}">{{ object.cooling_source.site }}</a></li>
+  <li class="breadcrumb-item"><a href="{% url 'dcim:coolingfeed_list' %}?cooling_source_id={{ object.cooling_source.pk }}">{{ object.cooling_source }}</a></li>
+  {% if object.rack %}
+    <li class="breadcrumb-item"><a href="{% url 'dcim:coolingfeed_list' %}?rack_id={{ object.rack.pk }}">{{ object.rack }}</a></li>
+  {% endif %}
+{% endblock %}

+ 9 - 0
netbox/templates/dcim/coolingoutlet.html

@@ -0,0 +1,9 @@
+{% extends 'generic/object.html' %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'dcim:device_coolingoutlets' pk=object.device.pk %}">{{ object.device }}</a>
+  </li>
+{% endblock %}

+ 9 - 0
netbox/templates/dcim/coolingport.html

@@ -0,0 +1,9 @@
+{% extends 'generic/object.html' %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'dcim:device_coolingports' pk=object.device.pk %}">{{ object.device }}</a>
+  </li>
+{% endblock %}

+ 9 - 0
netbox/templates/dcim/coolingsource.html

@@ -0,0 +1,9 @@
+{% extends 'generic/object.html' %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item"><a href="{% url 'dcim:coolingsource_list' %}?site_id={{ object.site.pk }}">{{ object.site }}</a></li>
+  {% if object.location %}
+    <li class="breadcrumb-item">{{ object.location|linkify }}</li>
+  {% endif %}
+{% endblock %}

+ 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_coolingport %}
+                    <li><a class="dropdown-item" href="{% url 'dcim:coolingport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_coolingports' pk=object.pk %}">{% trans "Cooling Ports" %}</a></li>
+                {% endif %}
+                {% if perms.dcim.add_coolingoutlet %}
+                    <li><a class="dropdown-item" href="{% url 'dcim:coolingoutlet_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_coolingoutlets' pk=object.pk %}">{% trans "Cooling Outlets" %}</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 %}

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

@@ -30,6 +30,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_coolingporttemplate %}
+          <li><a class="dropdown-item" href="{% url 'dcim:coolingporttemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_coolingports' pk=object.pk %}">{% trans "Cooling Ports" %}</a></li>
+        {% endif %}
+        {% if perms.dcim.add_coolingoutlettemplate %}
+          <li><a class="dropdown-item" href="{% url 'dcim:coolingoutlettemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_coolingoutlets' pk=object.pk %}">{% trans "Cooling Outlets" %}</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 %}