Kaynağa Gözat

22447 - Add Cooling infrastructure modeling

Arthur 1 hafta önce
ebeveyn
işleme
c09b3ee614
62 değiştirilmiş dosya ile 7018 ekleme ve 48 silme
  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.
 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
 ### Serial Number
 
 
 The unique physical serial number assigned to this device by its manufacturer.
 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).
 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
 ### Weight
 
 
 The numeric weight of the device, including a unit designation (e.g. 10 kilograms or 20 pounds).
 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.
 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
 !!! 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).
     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'
             - ConsolePortTemplate: 'models/dcim/consoleporttemplate.md'
             - ConsoleServerPort: 'models/dcim/consoleserverport.md'
             - ConsoleServerPort: 'models/dcim/consoleserverport.md'
             - ConsoleServerPortTemplate: 'models/dcim/consoleserverporttemplate.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'
             - Device: 'models/dcim/device.md'
             - DeviceBay: 'models/dcim/devicebay.md'
             - DeviceBay: 'models/dcim/devicebay.md'
             - DeviceBayTemplate: 'models/dcim/devicebaytemplate.md'
             - DeviceBayTemplate: 'models/dcim/devicebaytemplate.md'

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

@@ -1,4 +1,5 @@
 from .serializers_.cables import *
 from .serializers_.cables import *
+from .serializers_.cooling import *
 from .serializers_.device_components import *
 from .serializers_.device_components import *
 from .serializers_.devices import *
 from .serializers_.devices import *
 from .serializers_.devicetype_components 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 (
 from dcim.models import (
     ConsolePort,
     ConsolePort,
     ConsoleServerPort,
     ConsoleServerPort,
+    CoolingOutlet,
+    CoolingPort,
     DeviceBay,
     DeviceBay,
     FrontPort,
     FrontPort,
     Interface,
     Interface,
@@ -41,6 +43,8 @@ from .roles import InventoryItemRoleSerializer
 __all__ = (
 __all__ = (
     'ConsolePortSerializer',
     'ConsolePortSerializer',
     'ConsoleServerPortSerializer',
     'ConsoleServerPortSerializer',
+    'CoolingOutletSerializer',
+    'CoolingPortSerializer',
     'DeviceBaySerializer',
     'DeviceBaySerializer',
     'FrontPortSerializer',
     'FrontPortSerializer',
     'InterfaceSerializer',
     'InterfaceSerializer',
@@ -196,6 +200,97 @@ class PowerOutletSerializer(
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
         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(
 class InterfaceSerializer(
     OwnerMixin,
     OwnerMixin,
     NetBoxModelSerializer,
     NetBoxModelSerializer,

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

@@ -57,6 +57,7 @@ class DeviceSerializer(PrimaryModelSerializer):
     )
     )
     status = ChoiceField(choices=DeviceStatusChoices, required=False)
     status = ChoiceField(choices=DeviceStatusChoices, required=False)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, 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(
     primary_ip = IPAddressSerializer(
         nested=True,
         nested=True,
         read_only=True,
         read_only=True,
@@ -105,11 +106,13 @@ class DeviceSerializer(PrimaryModelSerializer):
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
             'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
             'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
             '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',
             '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')
         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 (
 from dcim.models import (
     ConsolePortTemplate,
     ConsolePortTemplate,
     ConsoleServerPortTemplate,
     ConsoleServerPortTemplate,
+    CoolingOutletTemplate,
+    CoolingPortTemplate,
     DeviceBayTemplate,
     DeviceBayTemplate,
     FrontPortTemplate,
     FrontPortTemplate,
     InterfaceTemplate,
     InterfaceTemplate,
@@ -30,6 +32,8 @@ from .roles import InventoryItemRoleSerializer
 __all__ = (
 __all__ = (
     'ConsolePortTemplateSerializer',
     'ConsolePortTemplateSerializer',
     'ConsoleServerPortTemplateSerializer',
     'ConsoleServerPortTemplateSerializer',
+    'CoolingOutletTemplateSerializer',
+    'CoolingPortTemplateSerializer',
     'DeviceBayTemplateSerializer',
     'DeviceBayTemplateSerializer',
     'FrontPortTemplateSerializer',
     'FrontPortTemplateSerializer',
     'InterfaceTemplateSerializer',
     'InterfaceTemplateSerializer',
@@ -170,6 +174,93 @@ class PowerOutletTemplateSerializer(ComponentTemplateSerializer):
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         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):
 class InterfaceTemplateSerializer(ComponentTemplateSerializer):
     device_type = DeviceTypeSerializer(
     device_type = DeviceTypeSerializer(
         nested=True,
         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)
     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)
     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)
     weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
     front_image = serializers.ImageField(required=False, allow_null=True)
     front_image = serializers.ImageField(required=False, allow_null=True)
     rear_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)
     console_server_port_template_count = serializers.IntegerField(read_only=True)
     power_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)
     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)
     interface_template_count = serializers.IntegerField(read_only=True)
     front_port_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)
     rear_port_template_count = serializers.IntegerField(read_only=True)
@@ -52,12 +55,13 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number',
             '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',
             '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')
         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('console-server-port-templates', views.ConsoleServerPortTemplateViewSet)
 router.register('power-port-templates', views.PowerPortTemplateViewSet)
 router.register('power-port-templates', views.PowerPortTemplateViewSet)
 router.register('power-outlet-templates', views.PowerOutletTemplateViewSet)
 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('interface-templates', views.InterfaceTemplateViewSet)
 router.register('front-port-templates', views.FrontPortTemplateViewSet)
 router.register('front-port-templates', views.FrontPortTemplateViewSet)
 router.register('rear-port-templates', views.RearPortTemplateViewSet)
 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('console-server-ports', views.ConsoleServerPortViewSet)
 router.register('power-ports', views.PowerPortViewSet)
 router.register('power-ports', views.PowerPortViewSet)
 router.register('power-outlets', views.PowerOutletViewSet)
 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('interfaces', views.InterfaceViewSet)
 router.register('front-ports', views.FrontPortViewSet)
 router.register('front-ports', views.FrontPortViewSet)
 router.register('rear-ports', views.RearPortViewSet)
 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-panels', views.PowerPanelViewSet)
 router.register('power-feeds', views.PowerFeedViewSet)
 router.register('power-feeds', views.PowerFeedViewSet)
 
 
+# Cooling
+router.register('cooling-sources', views.CoolingSourceViewSet)
+router.register('cooling-feeds', views.CoolingFeedViewSet)
+
 # Miscellaneous
 # Miscellaneous
 router.register('connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
 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
     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):
 class InterfaceTemplateViewSet(NetBoxModelViewSet):
     queryset = InterfaceTemplate.objects.all()
     queryset = InterfaceTemplate.objects.all()
     serializer_class = serializers.InterfaceTemplateSerializer
     serializer_class = serializers.InterfaceTemplateSerializer
@@ -468,6 +480,22 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
     filterset_class = filtersets.PowerOutletFilterSet
     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):
 class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = Interface.objects.prefetch_related(
     queryset = Interface.objects.prefetch_related(
         GenericPrefetch(
         GenericPrefetch(
@@ -609,6 +637,28 @@ class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
     filterset_class = filtersets.PowerFeedFilterSet
     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
 # Miscellaneous
 #
 #

+ 159 - 0
netbox/dcim/choices.py

@@ -1883,6 +1883,9 @@ class CableTypeChoices(ChoiceSet):
     # USB
     # USB
     TYPE_USB = 'usb'
     TYPE_USB = 'usb'
 
 
+    # Cooling
+    TYPE_COOLING_HOSE = 'cooling-hose'
+
     CHOICES = (
     CHOICES = (
         (
         (
             _('Copper - Twisted Pair (UTP/STP)'),
             _('Copper - Twisted Pair (UTP/STP)'),
@@ -1955,6 +1958,12 @@ class CableTypeChoices(ChoiceSet):
                 (TYPE_USB, 'USB'),
                 (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
 # VDC
 #
 #

+ 10 - 0
netbox/dcim/constants.py

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

+ 257 - 5
netbox/dcim/filtersets.py

@@ -57,6 +57,12 @@ __all__ = (
     'ConsolePortTemplateFilterSet',
     'ConsolePortTemplateFilterSet',
     'ConsoleServerPortFilterSet',
     'ConsoleServerPortFilterSet',
     'ConsoleServerPortTemplateFilterSet',
     'ConsoleServerPortTemplateFilterSet',
+    'CoolingFeedFilterSet',
+    'CoolingOutletFilterSet',
+    'CoolingOutletTemplateFilterSet',
+    'CoolingPortFilterSet',
+    'CoolingPortTemplateFilterSet',
+    'CoolingSourceFilterSet',
     'DeviceBayFilterSet',
     'DeviceBayFilterSet',
     'DeviceBayTemplateFilterSet',
     'DeviceBayTemplateFilterSet',
     'DeviceFilterSet',
     'DeviceFilterSet',
@@ -494,12 +500,18 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS
         lookup_expr='iexact'
         lookup_expr='iexact'
     )
     )
 
 
+    cooling_capability = django_filters.MultipleChoiceFilter(
+        choices=RackCoolingCapabilityChoices,
+        distinct=False,
+    )
+    has_rdhx = django_filters.BooleanFilter()
+
     class Meta:
     class Meta:
         model = Rack
         model = Rack
         fields = (
         fields = (
             'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
             '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):
     def search(self, queryset, name, value):
@@ -727,7 +739,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
         model = DeviceType
         model = DeviceType
         fields = (
         fields = (
             'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
             '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
             # Counters
             'console_port_template_count',
             'console_port_template_count',
@@ -992,6 +1004,30 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
         fields = ('id', 'name', 'label', 'type', 'color', 'feed_leg', 'description')
         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
 @register_filterset
 class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
@@ -1465,8 +1501,8 @@ class DeviceFilterSet(
     class Meta:
     class Meta:
         model = Device
         model = Device
         fields = (
         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
             # Counters
             'console_port_count',
             '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
 @register_filterset
 class MACAddressFilterSet(PrimaryModelFilterSet):
 class MACAddressFilterSet(PrimaryModelFilterSet):
     mac_address = MultiValueMACAddressFilter()
     mac_address = MultiValueMACAddressFilter()
@@ -3031,6 +3123,166 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CabledObjectFilterSet, PathEndpo
         return queryset.filter(qs_filter)
         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
 # Connection filter sets
 #
 #

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

@@ -13,6 +13,8 @@ from .object_create import ComponentCreateForm
 __all__ = (
 __all__ = (
     'ConsolePortBulkCreateForm',
     'ConsolePortBulkCreateForm',
     'ConsoleServerPortBulkCreateForm',
     'ConsoleServerPortBulkCreateForm',
+    'CoolingOutletBulkCreateForm',
+    'CoolingPortBulkCreateForm',
     'DeviceBayBulkCreateForm',
     'DeviceBayBulkCreateForm',
     # 'FrontPortBulkCreateForm',
     # 'FrontPortBulkCreateForm',
     'InterfaceBulkCreateForm',
     '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(
 class InterfaceBulkCreateForm(
     form_from_model(Interface, [
     form_from_model(Interface, [
         'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type', 'rf_role'
         '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',
     'ConsolePortTemplateBulkEditForm',
     'ConsoleServerPortBulkEditForm',
     'ConsoleServerPortBulkEditForm',
     'ConsoleServerPortTemplateBulkEditForm',
     'ConsoleServerPortTemplateBulkEditForm',
+    'CoolingFeedBulkEditForm',
+    'CoolingOutletBulkEditForm',
+    'CoolingOutletTemplateBulkEditForm',
+    'CoolingPortBulkEditForm',
+    'CoolingPortTemplateBulkEditForm',
+    'CoolingSourceBulkEditForm',
     'DeviceBayBulkEditForm',
     'DeviceBayBulkEditForm',
     'DeviceBayTemplateBulkEditForm',
     'DeviceBayTemplateBulkEditForm',
     'DeviceBulkEditForm',
     'DeviceBulkEditForm',
@@ -437,6 +443,21 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         choices=add_blank_choice(RackAirflowChoices),
         choices=add_blank_choice(RackAirflowChoices),
         required=False
         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(
     weight = forms.DecimalField(
         label=_('Weight'),
         label=_('Weight'),
         min_value=0,
         min_value=0,
@@ -462,11 +483,13 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
         FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
         FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
         FieldSet('outer_width', 'outer_height', 'outer_depth', 'outer_unit', name=_('Outer Dimensions')),
         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('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')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
     )
     )
     nullable_fields = (
     nullable_fields = (
         'location', 'group', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_height', 'outer_depth',
         '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),
         choices=add_blank_choice(DeviceAirflowChoices),
         required=False
         required=False
     )
     )
+    cooling_method = forms.ChoiceField(
+        label=_('Cooling method'),
+        choices=add_blank_choice(CoolingMethodChoices),
+        required=False
+    )
     weight = forms.DecimalField(
     weight = forms.DecimalField(
         label=_('Weight'),
         label=_('Weight'),
         min_value=0,
         min_value=0,
@@ -554,11 +582,11 @@ class DeviceTypeBulkEditForm(PrimaryModelBulkEditForm):
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             'manufacturer', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
             'manufacturer', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
-            'airflow', 'description', name=_('Device Type')
+            'airflow', 'cooling_method', 'description', name=_('Device Type')
         ),
         ),
         FieldSet('weight', 'weight_unit', name=_('Weight')),
         FieldSet('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):
 class ModuleTypeProfileBulkEditForm(PrimaryModelBulkEditForm):
@@ -725,6 +753,11 @@ class DeviceBulkEditForm(PrimaryModelBulkEditForm):
         choices=add_blank_choice(DeviceAirflowChoices),
         choices=add_blank_choice(DeviceAirflowChoices),
         required=False
         required=False
     )
     )
+    cooling_method = forms.ChoiceField(
+        label=_('Cooling method'),
+        choices=add_blank_choice(CoolingMethodChoices),
+        required=False
+    )
     serial = forms.CharField(
     serial = forms.CharField(
         max_length=50,
         max_length=50,
         required=False,
         required=False,
@@ -748,12 +781,12 @@ class DeviceBulkEditForm(PrimaryModelBulkEditForm):
     fieldsets = (
     fieldsets = (
         FieldSet('role', 'status', 'tenant', 'platform', 'description', name=_('Device')),
         FieldSet('role', 'status', 'tenant', 'platform', 'description', name=_('Device')),
         FieldSet('site', 'location', name=_('Location')),
         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('config_template', name=_('Configuration')),
         FieldSet('cluster', name=_('Virtualization')),
         FieldSet('cluster', name=_('Virtualization')),
     )
     )
     nullable_fields = (
     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')
     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
 # Device component templates
 #
 #
@@ -1132,6 +1315,113 @@ class PowerOutletTemplateBulkEditForm(ComponentTemplateBulkEditForm):
             self.fields['power_port'].widget.attrs['disabled'] = True
             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):
 class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=InterfaceTemplate.objects.all(),
         queryset=InterfaceTemplate.objects.all(),
@@ -1430,6 +1720,65 @@ class PowerOutletBulkEditForm(
             self.fields['power_port'].widget.attrs['disabled'] = True
             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(
 class InterfaceBulkEditForm(
     ComponentBulkEditForm,
     ComponentBulkEditForm,
     form_from_model(Interface, [
     form_from_model(Interface, [

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

@@ -38,6 +38,10 @@ __all__ = (
     'CableImportForm',
     'CableImportForm',
     'ConsolePortImportForm',
     'ConsolePortImportForm',
     'ConsoleServerPortImportForm',
     'ConsoleServerPortImportForm',
+    'CoolingFeedImportForm',
+    'CoolingOutletImportForm',
+    'CoolingPortImportForm',
+    'CoolingSourceImportForm',
     'DeviceBayImportForm',
     'DeviceBayImportForm',
     'DeviceImportForm',
     'DeviceImportForm',
     'DeviceRoleImportForm',
     'DeviceRoleImportForm',
@@ -324,6 +328,12 @@ class RackImportForm(PrimaryModelImportForm):
         required=False,
         required=False,
         help_text=_('Airflow direction')
         help_text=_('Airflow direction')
     )
     )
+    cooling_capability = CSVChoiceField(
+        label=_('Cooling capability'),
+        choices=RackCoolingCapabilityChoices,
+        required=False,
+        help_text=_('Cooling capability')
+    )
     weight_unit = CSVChoiceField(
     weight_unit = CSVChoiceField(
         label=_('Weight unit'),
         label=_('Weight unit'),
         choices=WeightUnitChoices,
         choices=WeightUnitChoices,
@@ -336,8 +346,8 @@ class RackImportForm(PrimaryModelImportForm):
         fields = (
         fields = (
             'site', 'location', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor',
             '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',
             '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):
     def __init__(self, data=None, *args, **kwargs):
@@ -457,8 +467,8 @@ class DeviceTypeImportForm(PrimaryModelImportForm):
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
             'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization',
             '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,
         required=False,
         help_text=_('Airflow direction')
         help_text=_('Airflow direction')
     )
     )
+    cooling_method = CSVChoiceField(
+        label=_('Cooling method'),
+        choices=CoolingMethodChoices,
+        required=False,
+        help_text=_('Cooling method')
+    )
     config_template = CSVModelChoiceField(
     config_template = CSVModelChoiceField(
         label=_('Config template'),
         label=_('Config template'),
         queryset=ConfigTemplate.objects.all(),
         queryset=ConfigTemplate.objects.all(),
@@ -710,8 +726,8 @@ class DeviceImportForm(BaseDeviceImportForm):
         fields = [
         fields = [
             'name', 'role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
             'name', 'role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
             'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent', 'device_bay', 'airflow',
             '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):
     def __init__(self, data=None, *args, **kwargs):
@@ -948,6 +964,101 @@ class PowerOutletImportForm(OwnerCSVMixin, NetBoxModelImportForm):
             self.fields['power_port'].queryset = PowerPort.objects.none()
             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):
 class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         label=_('Device'),
         label=_('Device'),
@@ -1806,6 +1917,127 @@ class PowerFeedImportForm(PrimaryModelImportForm):
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
             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):
 class VirtualDeviceContextImportForm(PrimaryModelImportForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         label=_('Device'),
         label=_('Device'),

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

@@ -34,6 +34,12 @@ __all__ = (
     'ConsolePortTemplateFilterForm',
     'ConsolePortTemplateFilterForm',
     'ConsoleServerPortFilterForm',
     'ConsoleServerPortFilterForm',
     'ConsoleServerPortTemplateFilterForm',
     'ConsoleServerPortTemplateFilterForm',
+    'CoolingFeedFilterForm',
+    'CoolingOutletFilterForm',
+    'CoolingOutletTemplateFilterForm',
+    'CoolingPortFilterForm',
+    'CoolingPortTemplateFilterForm',
+    'CoolingSourceFilterForm',
     'DeviceBayFilterForm',
     'DeviceBayFilterForm',
     'DeviceBayTemplateFilterForm',
     'DeviceBayTemplateFilterForm',
     'DeviceFilterForm',
     '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('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('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
         FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
         FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
+        FieldSet('cooling_capability', 'has_rdhx', name=_('Cooling')),
         FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
         FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
@@ -438,6 +445,18 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
         choices=add_blank_choice(RackAirflowChoices),
         choices=add_blank_choice(RackAirflowChoices),
         required=False
         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(
     serial = forms.CharField(
         label=_('Serial'),
         label=_('Serial'),
         required=False
         required=False
@@ -561,7 +580,7 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet(
         FieldSet(
             'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
             '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('has_front_image', 'has_rear_image', name=_('Images')),
         FieldSet(
         FieldSet(
@@ -601,6 +620,11 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
         choices=add_blank_choice(DeviceAirflowChoices),
         choices=add_blank_choice(DeviceAirflowChoices),
         required=False
         required=False
     )
     )
+    cooling_method = forms.MultipleChoiceField(
+        label=_('Cooling method'),
+        choices=add_blank_choice(CoolingMethodChoices),
+        required=False
+    )
     has_front_image = forms.NullBooleanField(
     has_front_image = forms.NullBooleanField(
         required=False,
         required=False,
         label=_('Has a front image'),
         label=_('Has a front image'),
@@ -859,7 +883,7 @@ class DeviceFilterForm(
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_('Operation')),
         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('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
@@ -947,6 +971,11 @@ class DeviceFilterForm(
         choices=add_blank_choice(DeviceAirflowChoices),
         choices=add_blank_choice(DeviceAirflowChoices),
         required=False
         required=False
     )
     )
+    cooling_method = forms.MultipleChoiceField(
+        label=_('Cooling method'),
+        choices=add_blank_choice(CoolingMethodChoices),
+        required=False
+    )
     serial = forms.CharField(
     serial = forms.CharField(
         label=_('Serial'),
         label=_('Serial'),
         required=False
         required=False
@@ -1427,6 +1456,120 @@ class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     tag = TagFilterField(model)
     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
 # 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):
 class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = Interface
     model = Interface
     fieldsets = (
     fieldsets = (

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

@@ -45,6 +45,12 @@ __all__ = (
     'ConsolePortTemplateForm',
     'ConsolePortTemplateForm',
     'ConsoleServerPortForm',
     'ConsoleServerPortForm',
     'ConsoleServerPortTemplateForm',
     'ConsoleServerPortTemplateForm',
+    'CoolingFeedForm',
+    'CoolingOutletForm',
+    'CoolingOutletTemplateForm',
+    'CoolingPortForm',
+    'CoolingPortTemplateForm',
+    'CoolingSourceForm',
     'DeviceBayForm',
     'DeviceBayForm',
     'DeviceBayTemplateForm',
     'DeviceBayTemplateForm',
     'DeviceForm',
     'DeviceForm',
@@ -326,6 +332,7 @@ class RackForm(TenancyForm, PrimaryModelForm):
             'site', 'location', 'group', 'name', 'status', 'role', 'rack_type', 'description', 'airflow', 'tags',
             'site', 'location', 'group', 'name', 'status', 'role', 'rack_type', 'description', 'airflow', 'tags',
             name=_('Rack')
             name=_('Rack')
         ),
         ),
+        FieldSet('cooling_capability', 'has_rdhx', 'cooling_capacity', name=_('Cooling')),
         FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')),
         FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
     )
@@ -335,8 +342,8 @@ class RackForm(TenancyForm, PrimaryModelForm):
         fields = [
         fields = [
             'site', 'location', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
             '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',
             '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):
     def __init__(self, *args, **kwargs):
@@ -430,7 +437,7 @@ class DeviceTypeForm(PrimaryModelForm):
         FieldSet('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags', name=_('Device Type')),
         FieldSet('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags', name=_('Device Type')),
         FieldSet(
         FieldSet(
             'u_height', 'exclude_from_utilization', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow',
             'u_height', 'exclude_from_utilization', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow',
-            'weight', 'weight_unit', name=_('Chassis')
+            'cooling_method', 'weight', 'weight_unit', name=_('Chassis')
         ),
         ),
         FieldSet('front_image', 'rear_image', name=_('Images')),
         FieldSet('front_image', 'rear_image', name=_('Images')),
     )
     )
@@ -439,8 +446,8 @@ class DeviceTypeForm(PrimaryModelForm):
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
             'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization',
             '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 = {
         widgets = {
             'front_image': ClearableFileInput(attrs={
             'front_image': ClearableFileInput(attrs={
@@ -722,7 +729,8 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
         model = Device
         model = Device
         fields = [
         fields = [
             'name', 'role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
             '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',
             'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
             'owner', 'comments', 'tags', 'local_context_data',
             '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
 # 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):
 class InterfaceTemplateForm(ModularComponentTemplateForm):
     bridge = DynamicModelChoiceField(
     bridge = DynamicModelChoiceField(
         label=_('Bridge'),
         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):
 class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
     vdcs = DynamicModelMultipleChoiceField(
     vdcs = DynamicModelMultipleChoiceField(
         queryset=VirtualDeviceContext.objects.all(),
         queryset=VirtualDeviceContext.objects.all(),

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

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

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

@@ -8,6 +8,8 @@ from wireless.choices import WirelessRoleChoices
 __all__ = (
 __all__ = (
     'ConsolePortTemplateImportForm',
     'ConsolePortTemplateImportForm',
     'ConsoleServerPortTemplateImportForm',
     'ConsoleServerPortTemplateImportForm',
+    'CoolingOutletTemplateImportForm',
+    'CoolingPortTemplateImportForm',
     'DeviceBayTemplateImportForm',
     'DeviceBayTemplateImportForm',
     'FrontPortTemplateImportForm',
     'FrontPortTemplateImportForm',
     'InterfaceTemplateImportForm',
     'InterfaceTemplateImportForm',
@@ -80,6 +82,46 @@ class PowerOutletTemplateImportForm(forms.ModelForm):
         return module_type
         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):
 class InterfaceTemplateImportForm(forms.ModelForm):
     type = forms.ChoiceField(
     type = forms.ChoiceField(
         label=_('Type'),
         label=_('Type'),

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

@@ -8,9 +8,17 @@ __all__ = (
     'CableTypeEnum',
     'CableTypeEnum',
     'ConsolePortSpeedEnum',
     'ConsolePortSpeedEnum',
     'ConsolePortTypeEnum',
     'ConsolePortTypeEnum',
+    'CoolingConnectorTypeEnum',
+    'CoolingDiameterEnum',
+    'CoolingFeedStatusEnum',
+    'CoolingFeedTypeEnum',
+    'CoolingMethodEnum',
+    'CoolingSourceStatusEnum',
+    'CoolingSourceTypeEnum',
     'DeviceAirflowEnum',
     'DeviceAirflowEnum',
     'DeviceFaceEnum',
     'DeviceFaceEnum',
     'DeviceStatusEnum',
     'DeviceStatusEnum',
+    'FluidTypeEnum',
     'InterfaceDuplexEnum',
     'InterfaceDuplexEnum',
     'InterfaceKindEnum',
     'InterfaceKindEnum',
     'InterfaceModeEnum',
     'InterfaceModeEnum',
@@ -47,9 +55,17 @@ CableLengthUnitEnum = strawberry.enum(CableLengthUnitChoices.as_enum(prefix='uni
 CableTypeEnum = strawberry.enum(CableTypeChoices.as_enum(prefix='type'))
 CableTypeEnum = strawberry.enum(CableTypeChoices.as_enum(prefix='type'))
 ConsolePortSpeedEnum = strawberry.enum(ConsolePortSpeedChoices.as_enum(prefix='speed'))
 ConsolePortSpeedEnum = strawberry.enum(ConsolePortSpeedChoices.as_enum(prefix='speed'))
 ConsolePortTypeEnum = strawberry.enum(ConsolePortTypeChoices.as_enum(prefix='type'))
 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'))
 DeviceAirflowEnum = strawberry.enum(DeviceAirflowChoices.as_enum(prefix='airflow'))
 DeviceFaceEnum = strawberry.enum(DeviceFaceChoices.as_enum(prefix='face'))
 DeviceFaceEnum = strawberry.enum(DeviceFaceChoices.as_enum(prefix='face'))
 DeviceStatusEnum = strawberry.enum(DeviceStatusChoices.as_enum(prefix='status'))
 DeviceStatusEnum = strawberry.enum(DeviceStatusChoices.as_enum(prefix='status'))
+FluidTypeEnum = strawberry.enum(FluidTypeChoices.as_enum(prefix='fluid'))
 InterfaceDuplexEnum = strawberry.enum(InterfaceDuplexChoices.as_enum(prefix='duplex'))
 InterfaceDuplexEnum = strawberry.enum(InterfaceDuplexChoices.as_enum(prefix='duplex'))
 InterfaceKindEnum = strawberry.enum(InterfaceKindChoices.as_enum(prefix='kind'))
 InterfaceKindEnum = strawberry.enum(InterfaceKindChoices.as_enum(prefix='kind'))
 InterfaceModeEnum = strawberry.enum(InterfaceModeChoices.as_enum(prefix='mode'))
 InterfaceModeEnum = strawberry.enum(InterfaceModeChoices.as_enum(prefix='mode'))

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

@@ -70,6 +70,12 @@ __all__ = (
     'ConsolePortTemplateFilter',
     'ConsolePortTemplateFilter',
     'ConsoleServerPortFilter',
     'ConsoleServerPortFilter',
     'ConsoleServerPortTemplateFilter',
     'ConsoleServerPortTemplateFilter',
+    'CoolingFeedFilter',
+    'CoolingOutletFilter',
+    'CoolingOutletTemplateFilter',
+    'CoolingPortFilter',
+    'CoolingPortTemplateFilter',
+    'CoolingSourceFilter',
     'DeviceBayFilter',
     'DeviceBayFilter',
     'DeviceBayTemplateFilter',
     'DeviceBayTemplateFilter',
     'DeviceFilter',
     '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)
 @strawberry_django.filter_type(models.RackType, lookups=True)
 class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
 class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
     form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     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: ConsoleServerPortTemplateType = strawberry_django.field()
     console_server_port_template_list: list[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: DeviceType = strawberry_django.field()
     device_list: list[DeviceType] = strawberry_django.field()
     device_list: list[DeviceType] = strawberry_django.field()
 
 

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

@@ -51,6 +51,12 @@ __all__ = (
     'ConsolePortType',
     'ConsolePortType',
     'ConsoleServerPortTemplateType',
     'ConsoleServerPortTemplateType',
     'ConsoleServerPortType',
     'ConsoleServerPortType',
+    'CoolingFeedType',
+    'CoolingOutletTemplateType',
+    'CoolingOutletType',
+    'CoolingPortTemplateType',
+    'CoolingPortType',
+    'CoolingSourceType',
     'DeviceBayTemplateType',
     'DeviceBayTemplateType',
     'DeviceBayType',
     'DeviceBayType',
     'DeviceRoleType',
     'DeviceRoleType',
@@ -256,6 +262,8 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, Prima
     console_server_port_count: BigInt
     console_server_port_count: BigInt
     power_port_count: BigInt
     power_port_count: BigInt
     power_outlet_count: BigInt
     power_outlet_count: BigInt
+    cooling_port_count: BigInt
+    cooling_outlet_count: BigInt
     interface_count: BigInt
     interface_count: BigInt
     front_port_count: BigInt
     front_port_count: BigInt
     rear_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')]]
     rearports: list[Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]]
     consoleports: list[Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')]]
     consoleports: list[Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')]]
     powerports: list[Annotated["PowerPortType", 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')]]
     cabletermination_set: list[Annotated["CableTerminationType", strawberry.lazy('dcim.graphql.types')]]
     consoleserverports: list[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]]
     consoleserverports: list[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]]
     poweroutlets: list[Annotated["PowerOutletType", 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')]]
     frontports: list[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]]
     devicebays: list[Annotated["DeviceBayType", strawberry.lazy('dcim.graphql.types')]]
     devicebays: list[Annotated["DeviceBayType", strawberry.lazy('dcim.graphql.types')]]
     modulebays: list[Annotated["ModuleBayType", 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
     console_server_port_template_count: BigInt
     power_port_template_count: BigInt
     power_port_template_count: BigInt
     power_outlet_template_count: BigInt
     power_outlet_template_count: BigInt
+    cooling_port_template_count: BigInt
+    cooling_outlet_template_count: BigInt
     interface_template_count: BigInt
     interface_template_count: BigInt
     front_port_template_count: BigInt
     front_port_template_count: BigInt
     rear_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')]]
     instances: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     poweroutlettemplates: list[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
     poweroutlettemplates: list[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
     powerporttemplates: list[Annotated["PowerPortTemplateType", 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')]]
     inventoryitemtemplates: list[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]]
     rearporttemplates: list[Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
     rearporttemplates: list[Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
     consoleserverporttemplates: list[Annotated["ConsoleServerPortTemplateType", 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')]]
     interfaces: list[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
     powerports: list[Annotated["PowerPortType", 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')]]
     consoleserverports: list[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]]
     consoleports: list[Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')]]
     consoleports: list[Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')]]
     poweroutlets: list[Annotated["PowerOutletType", 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')]]
     rearports: list[Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]]
     frontports: list[Annotated["FrontPortType", 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
     console_server_port_template_count: BigInt
     power_port_template_count: BigInt
     power_port_template_count: BigInt
     power_outlet_template_count: BigInt
     power_outlet_template_count: BigInt
+    cooling_port_template_count: BigInt
+    cooling_outlet_template_count: BigInt
     interface_template_count: BigInt
     interface_template_count: BigInt
     front_port_template_count: BigInt
     front_port_template_count: BigInt
     rear_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')]]
     interfacetemplates: list[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]]
     powerporttemplates: list[Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
     powerporttemplates: list[Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
     poweroutlettemplates: list[Annotated["PowerOutletTemplateType", 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')]]
     rearporttemplates: list[Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
     instances: list[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]]
     instances: list[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]]
     consoleporttemplates: list[Annotated["ConsolePortTemplateType", 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')]]
     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(
 @strawberry_django.type(
     models.RackGroup,
     models.RackGroup,
     fields='__all__',
     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 .cables import *
+from .cooling import *
 from .device_component_templates import *
 from .device_component_templates import *
 from .device_components import *
 from .device_components import *
 from .devices 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 (
 from .device_components import (
     ConsolePort,
     ConsolePort,
     ConsoleServerPort,
     ConsoleServerPort,
+    CoolingOutlet,
+    CoolingPort,
     DeviceBay,
     DeviceBay,
     FrontPort,
     FrontPort,
     Interface,
     Interface,
@@ -33,6 +35,8 @@ from .device_components import (
 __all__ = (
 __all__ = (
     'ConsolePortTemplate',
     'ConsolePortTemplate',
     'ConsoleServerPortTemplate',
     'ConsoleServerPortTemplate',
+    'CoolingOutletTemplate',
+    'CoolingPortTemplate',
     'DeviceBayTemplate',
     'DeviceBayTemplate',
     'FrontPortTemplate',
     'FrontPortTemplate',
     'InterfaceTemplate',
     '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):
 class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel):
     """
     """
     A template for a physical data interface on a new Device.
     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',
     'CabledObjectModel',
     'ConsolePort',
     'ConsolePort',
     'ConsoleServerPort',
     'ConsoleServerPort',
+    'CoolingOutlet',
+    'CoolingPort',
     'DeviceBay',
     'DeviceBay',
     'FrontPort',
     'FrontPort',
     'Interface',
     'Interface',
@@ -683,6 +685,128 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
         return PowerOutletStatusChoices.colors.get(self.status)
         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
 # Interfaces
 #
 #

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

@@ -136,6 +136,13 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    cooling_method = models.CharField(
+        verbose_name=_('cooling method'),
+        max_length=50,
+        choices=CoolingMethodChoices,
+        blank=True,
+        null=True
+    )
     front_image = models.ImageField(
     front_image = models.ImageField(
         upload_to='devicetype-images',
         upload_to='devicetype-images',
         blank=True
         blank=True
@@ -162,6 +169,14 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         to_model='dcim.PowerOutletTemplate',
         to_model='dcim.PowerOutletTemplate',
         to_field='device_type'
         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(
     interface_template_count = CounterCacheField(
         to_model='dcim.InterfaceTemplate',
         to_model='dcim.InterfaceTemplate',
         to_field='device_type'
         to_field='device_type'
@@ -192,8 +207,8 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
     )
     )
 
 
     clone_fields = (
     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 = (
     prerequisite_models = (
         'dcim.Manufacturer',
         'dcim.Manufacturer',
@@ -243,6 +258,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             'is_full_depth': self.is_full_depth,
             'is_full_depth': self.is_full_depth,
             'subdevice_role': self.subdevice_role,
             'subdevice_role': self.subdevice_role,
             'airflow': self.airflow,
             'airflow': self.airflow,
+            'cooling_method': self.cooling_method,
             'weight': float(self.weight) if self.weight is not None else None,
             'weight': float(self.weight) if self.weight is not None else None,
             'weight_unit': self.weight_unit,
             'weight_unit': self.weight_unit,
             'comments': self.comments,
             'comments': self.comments,
@@ -265,6 +281,14 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             data['power-outlets'] = [
             data['power-outlets'] = [
                 c.to_yaml() for c in self.poweroutlettemplates.all()
                 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():
         if self.interfacetemplates.exists():
             data['interfaces'] = [
             data['interfaces'] = [
                 c.to_yaml() for c in self.interfacetemplates.all()
                 c.to_yaml() for c in self.interfacetemplates.all()
@@ -611,6 +635,13 @@ class Device(
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    cooling_method = models.CharField(
+        verbose_name=_('cooling method'),
+        max_length=50,
+        choices=CoolingMethodChoices,
+        blank=True,
+        null=True
+    )
     primary_ip4 = models.OneToOneField(
     primary_ip4 = models.OneToOneField(
         to='ipam.IPAddress',
         to='ipam.IPAddress',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
@@ -710,6 +741,14 @@ class Device(
         to_model='dcim.PowerOutlet',
         to_model='dcim.PowerOutlet',
         to_field='device'
         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(
     interface_count = CounterCacheField(
         to_model='dcim.Interface',
         to_model='dcim.Interface',
         to_field='device'
         to_field='device'
@@ -739,7 +778,7 @@ class Device(
 
 
     clone_fields = (
     clone_fields = (
         'device_type', 'role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow',
         'device_type', 'role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow',
-        'cluster', 'virtual_chassis',
+        'cooling_method', 'cluster', 'virtual_chassis',
     )
     )
     prerequisite_models = (
     prerequisite_models = (
         'dcim.Site',
         'dcim.Site',
@@ -1052,6 +1091,10 @@ class Device(
         if is_new and not self.airflow:
         if is_new and not self.airflow:
             self.airflow = self.device_type.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
         # Inherit default_platform from DeviceType if not set
         if is_new and not self.platform:
         if is_new and not self.platform:
             self.platform = self.device_type.default_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.consoleserverporttemplates.all())
             self._instantiate_components(self.device_type.powerporttemplates.all())
             self._instantiate_components(self.device_type.powerporttemplates.all())
             self._instantiate_components(self.device_type.poweroutlettemplates.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.interfacetemplates.all())
             self._instantiate_components(self.device_type.rearporttemplates.all())
             self._instantiate_components(self.device_type.rearporttemplates.all())
             self._instantiate_components(self.device_type.frontporttemplates.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_model='dcim.PowerOutletTemplate',
         to_field='module_type'
         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(
     interface_template_count = CounterCacheField(
         to_model='dcim.InterfaceTemplate',
         to_model='dcim.InterfaceTemplate',
         to_field='module_type'
         to_field='module_type'
@@ -215,6 +223,14 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             data['power-outlets'] = [
             data['power-outlets'] = [
                 c.to_yaml() for c in self.poweroutlettemplates.all()
                 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():
         if self.interfacetemplates.exists():
             data['interfaces'] = [
             data['interfaces'] = [
                 c.to_yaml() for c in self.interfacetemplates.all()
                 c.to_yaml() for c in self.interfacetemplates.all()
@@ -354,6 +370,8 @@ class Module(TrackingModelMixin, PrimaryModel):
             ("interfacetemplates", "interfaces", Interface),
             ("interfacetemplates", "interfaces", Interface),
             ("powerporttemplates", "powerports", PowerPort),
             ("powerporttemplates", "powerports", PowerPort),
             ("poweroutlettemplates", "poweroutlets", PowerOutlet),
             ("poweroutlettemplates", "poweroutlets", PowerOutlet),
+            ("coolingporttemplates", "coolingports", CoolingPort),
+            ("coolingoutlettemplates", "coolingoutlets", CoolingOutlet),
             ("rearporttemplates", "rearports", RearPort),
             ("rearporttemplates", "rearports", RearPort),
             ("frontporttemplates", "frontports", FrontPort),
             ("frontporttemplates", "frontports", FrontPort),
             ("modulebaytemplates", "modulebays", ModuleBay),
             ("modulebaytemplates", "modulebays", ModuleBay),

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

@@ -359,6 +359,26 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
         blank=True,
         blank=True,
         null=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
     # Generic relations
     vlan_groups = GenericRelation(
     vlan_groups = GenericRelation(
@@ -369,9 +389,9 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
     )
     )
 
 
     clone_fields = (
     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 = (
     prerequisite_models = (
         'dcim.Site',
         'dcim.Site',

+ 45 - 0
netbox/dcim/search.py

@@ -49,6 +49,51 @@ class ConsoleServerPortIndex(SearchIndex):
     display_attrs = ('device', 'label', 'type', 'description')
     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
 @register_search
 class DeviceIndex(SearchIndex):
 class DeviceIndex(SearchIndex):
     model = models.Device
     model = models.Device

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

@@ -1,5 +1,6 @@
 from .cables import *
 from .cables import *
 from .connections import *
 from .connections import *
+from .cooling import *
 from .devices import *
 from .devices import *
 from .devicetypes import *
 from .devicetypes import *
 from .modules 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(
     inventory_item_count = tables.Column(
         verbose_name=_('Inventory items')
         verbose_name=_('Inventory items')
     )
     )
+    cooling_method = columns.ChoiceFieldColumn(
+        verbose_name=_('Cooling Method'),
+    )
 
 
     class Meta(PrimaryModelTable.Meta):
     class Meta(PrimaryModelTable.Meta):
         model = models.Device
         model = models.Device
         fields = (
         fields = (
             'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'role', 'manufacturer', 'device_type',
             'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'role', 'manufacturer', 'device_type',
             'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
             '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',
             'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
             'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
             '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(
     inventory_item_template_count = tables.Column(
         verbose_name=_('Inventory Items')
         verbose_name=_('Inventory Items')
     )
     )
+    cooling_method = columns.ChoiceFieldColumn(
+        verbose_name=_('Cooling Method'),
+    )
 
 
     class Meta(PrimaryModelTable.Meta):
     class Meta(PrimaryModelTable.Meta):
         model = models.DeviceType
         model = models.DeviceType
         fields = (
         fields = (
             'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
             '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',
             'description', 'comments', 'device_count', 'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (

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

@@ -196,13 +196,24 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
         template_code=WEIGHT,
         template_code=WEIGHT,
         order_by=('_abs_max_weight', 'weight_unit')
         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):
     class Meta(PrimaryModelTable.Meta):
         model = Rack
         model = Rack
         fields = (
         fields = (
             'pk', 'id', 'name', 'site', 'location', 'group', 'status', 'facility_id', 'tenant', 'tenant_group', 'role',
             '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',
             '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',
             'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts',
             'tags', 'created', 'last_updated',
             'tags', 'created', 'last_updated',
         )
         )

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

@@ -343,6 +343,100 @@ POWEROUTLET_BUTTONS = """
 {% endif %}
 {% 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 = """
 INTERFACE_BUTTONS = """
 {% if perms.dcim.change_interface %}
 {% if perms.dcim.change_interface %}
   <span class="dropdown">
   <span class="dropdown">

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

@@ -10,6 +10,16 @@
   "consoleserverport:api_list_objects": 14,
   "consoleserverport:api_list_objects": 14,
   "consoleserverport:list_objects_with_permission": 21,
   "consoleserverport:list_objects_with_permission": 21,
   "consoleserverporttemplate:api_list_objects": 11,
   "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:api_list_objects": 20,
   "device:list_objects_with_permission": 25,
   "device:list_objects_with_permission": 25,
   "devicebay:api_list_objects": 14,
   "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):
 class VirtualDeviceContextTestCase(APIViewTestCases.APIViewTestCase):
     model = VirtualDeviceContext
     model = VirtualDeviceContext
     brief_fields = ['description', 'device', 'display', 'id', 'identifier', 'name', 'url']
     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)
         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):
 class InterfaceTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = InterfaceTemplate.objects.all()
     queryset = InterfaceTemplate.objects.all()
     filterset = InterfaceTemplateFilterSet
     filterset = InterfaceTemplateFilterSet
@@ -4509,6 +4650,571 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         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):
 class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
     filterset = InterfaceFilterSet
     filterset = InterfaceFilterSet
@@ -7463,6 +8169,363 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
 class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VirtualDeviceContext.objects.all()
     queryset = VirtualDeviceContext.objects.all()
     filterset = VirtualDeviceContextFilterSet
     filterset = VirtualDeviceContextFilterSet

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

@@ -2789,3 +2789,126 @@ class InventoryItemTemplateCycleTestCase(TestCase):
         a.parent = a
         a.parent = a
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             a.full_clean()
             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)
         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):
 class VirtualDeviceContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = VirtualDeviceContext
     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)
     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)
     asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
     airflow = attrs.ChoiceAttr('airflow')
     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')
     space_utilization = attrs.UtilizationAttr('get_utilization')
     power_utilization = attrs.UtilizationAttr('get_power_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')
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     description = attrs.TextAttr('description')
     description = attrs.TextAttr('description')
     airflow = attrs.ChoiceAttr('airflow')
     airflow = attrs.ChoiceAttr('airflow')
+    cooling_method = attrs.ChoiceAttr('cooling_method')
     serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
     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)
     asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
     config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
     config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
@@ -158,6 +162,7 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
     weight = attrs.WeightAttr('weight')
     weight = attrs.WeightAttr('weight')
     subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child'))
     subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child'))
     airflow = attrs.ChoiceAttr('airflow')
     airflow = attrs.ChoiceAttr('airflow')
+    cooling_method = attrs.ChoiceAttr('cooling_method')
     front_image = attrs.ImageAttr('front_image')
     front_image = attrs.ImageAttr('front_image')
     rear_image = attrs.ImageAttr('rear_image')
     rear_image = attrs.ImageAttr('rear_image')
 
 
@@ -236,6 +241,32 @@ class PowerOutletPanel(panels.ObjectAttributesPanel):
     feed_leg = attrs.ChoiceAttr('feed_leg')
     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):
 class FrontPortPanel(panels.ObjectAttributesPanel):
     device = attrs.RelatedObjectAttr('device', linkify=True)
     device = attrs.RelatedObjectAttr('device', linkify=True)
     module = attrs.RelatedObjectAttr('module', linkify=True)
     module = attrs.RelatedObjectAttr('module', linkify=True)
@@ -361,6 +392,42 @@ class PowerFeedElectricalPanel(panels.ObjectAttributesPanel):
     max_utilization = attrs.TextAttr('max_utilization', format_string='{}%')
     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):
 class VirtualDeviceContextPanel(panels.ObjectAttributesPanel):
     name = attrs.TextAttr('name')
     name = attrs.TextAttr('name')
     device = attrs.RelatedObjectAttr('device', linkify=True)
     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/', include(get_model_urls('dcim', 'poweroutlettemplate', detail=False))),
     path('power-outlet-templates/<int:pk>/', include(get_model_urls('dcim', 'poweroutlettemplate'))),
     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/', include(get_model_urls('dcim', 'interfacetemplate', detail=False))),
     path('interface-templates/<int:pk>/', include(get_model_urls('dcim', 'interfacetemplate'))),
     path('interface-templates/<int:pk>/', include(get_model_urls('dcim', 'interfacetemplate'))),
 
 
@@ -120,6 +126,22 @@ urlpatterns = [
         name='device_bulk_add_poweroutlet'
         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/', include(get_model_urls('dcim', 'interface', detail=False))),
     path('interfaces/<int:pk>/', include(get_model_urls('dcim', 'interface'))),
     path('interfaces/<int:pk>/', include(get_model_urls('dcim', 'interface'))),
     path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_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/', include(get_model_urls('dcim', 'powerfeed', detail=False))),
     path('power-feeds/<int:pk>/', include(get_model_urls('dcim', 'powerfeed'))),
     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/', include(get_model_urls('dcim', 'macaddress', detail=False))),
     path('mac-addresses/<int:pk>/', include(get_model_urls('dcim', 'macaddress'))),
     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.consoleserverport': ConsoleServerPort,
     'dcim.powerport': PowerPort,
     'dcim.powerport': PowerPort,
     'dcim.poweroutlet': PowerOutlet,
     'dcim.poweroutlet': PowerOutlet,
+    'dcim.coolingport': CoolingPort,
+    'dcim.coolingoutlet': CoolingOutlet,
     'dcim.interface': Interface,
     'dcim.interface': Interface,
     'dcim.frontport': FrontPort,
     'dcim.frontport': FrontPort,
     'dcim.rearport': RearPort,
     'dcim.rearport': RearPort,
     'dcim.powerfeed': PowerFeed,
     'dcim.powerfeed': PowerFeed,
+    'dcim.coolingfeed': CoolingFeed,
     'circuits.circuittermination': CircuitTermination,
     'circuits.circuittermination': CircuitTermination,
 }
 }
 
 
@@ -1429,9 +1432,9 @@ class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView):
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
             'related_models': self.get_related_models(request, instance, omit=[
             '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')
 @register_model_view(DeviceType, 'interfaces')
 class DeviceTypeInterfacesView(DeviceTypeComponentsView):
 class DeviceTypeInterfacesView(DeviceTypeComponentsView):
     child_model = InterfaceTemplate
     child_model = InterfaceTemplate
@@ -1606,6 +1639,8 @@ class DeviceTypeImportView(generic.BulkImportView):
         'dcim.add_consoleserverporttemplate',
         'dcim.add_consoleserverporttemplate',
         'dcim.add_powerporttemplate',
         'dcim.add_powerporttemplate',
         'dcim.add_poweroutlettemplate',
         'dcim.add_poweroutlettemplate',
+        'dcim.add_coolingporttemplate',
+        'dcim.add_coolingoutlettemplate',
         'dcim.add_interfacetemplate',
         'dcim.add_interfacetemplate',
         'dcim.add_frontporttemplate',
         'dcim.add_frontporttemplate',
         'dcim.add_rearporttemplate',
         'dcim.add_rearporttemplate',
@@ -1620,6 +1655,8 @@ class DeviceTypeImportView(generic.BulkImportView):
         'console-server-ports': forms.ConsoleServerPortTemplateImportForm,
         'console-server-ports': forms.ConsoleServerPortTemplateImportForm,
         'power-ports': forms.PowerPortTemplateImportForm,
         'power-ports': forms.PowerPortTemplateImportForm,
         'power-outlets': forms.PowerOutletTemplateImportForm,
         'power-outlets': forms.PowerOutletTemplateImportForm,
+        'cooling-ports': forms.CoolingPortTemplateImportForm,
+        'cooling-outlets': forms.CoolingOutletTemplateImportForm,
         'interfaces': forms.InterfaceTemplateImportForm,
         'interfaces': forms.InterfaceTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
@@ -1783,9 +1820,9 @@ class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
             'related_models': self.get_related_models(request, instance, omit=[
             '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')
 @register_model_view(ModuleType, 'interfaces')
 class ModuleTypeInterfacesView(ModuleTypeComponentsView):
 class ModuleTypeInterfacesView(ModuleTypeComponentsView):
     child_model = InterfaceTemplate
     child_model = InterfaceTemplate
@@ -1930,6 +1997,8 @@ class ModuleTypeImportView(generic.BulkImportView):
         'dcim.add_consoleserverporttemplate',
         'dcim.add_consoleserverporttemplate',
         'dcim.add_powerporttemplate',
         'dcim.add_powerporttemplate',
         'dcim.add_poweroutlettemplate',
         'dcim.add_poweroutlettemplate',
+        'dcim.add_coolingporttemplate',
+        'dcim.add_coolingoutlettemplate',
         'dcim.add_interfacetemplate',
         'dcim.add_interfacetemplate',
         'dcim.add_frontporttemplate',
         'dcim.add_frontporttemplate',
         'dcim.add_rearporttemplate',
         'dcim.add_rearporttemplate',
@@ -1942,6 +2011,8 @@ class ModuleTypeImportView(generic.BulkImportView):
         'console-server-ports': forms.ConsoleServerPortTemplateImportForm,
         'console-server-ports': forms.ConsoleServerPortTemplateImportForm,
         'power-ports': forms.PowerPortTemplateImportForm,
         'power-ports': forms.PowerPortTemplateImportForm,
         'power-outlets': forms.PowerOutletTemplateImportForm,
         'power-outlets': forms.PowerOutletTemplateImportForm,
+        'cooling-ports': forms.CoolingPortTemplateImportForm,
+        'cooling-outlets': forms.CoolingOutletTemplateImportForm,
         'interfaces': forms.InterfaceTemplateImportForm,
         'interfaces': forms.InterfaceTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
@@ -2144,6 +2215,88 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView):
     table = tables.PowerOutletTemplateTable
     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
 # 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')
 @register_model_view(Device, 'interfaces')
 class DeviceInterfacesView(DeviceComponentsView):
 class DeviceInterfacesView(DeviceComponentsView):
     child_model = Interface
     child_model = Interface
@@ -3371,6 +3556,183 @@ class PowerOutletBulkDeleteView(generic.BulkDeleteView):
 register_model_view(PowerOutlet, 'trace', kwargs={'model': PowerOutlet})(PathTraceView)
 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
 # Interfaces
 #
 #
@@ -4198,6 +4560,28 @@ class DeviceBulkAddPowerOutletView(generic.BulkComponentCreateView):
     default_return_url = 'dcim:device_list'
     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):
 class DeviceBulkAddInterfaceView(generic.BulkComponentCreateView):
     parent_model = Device
     parent_model = Device
     parent_field = 'device'
     parent_field = 'device'
@@ -4896,6 +5280,174 @@ class PowerFeedBulkDeleteView(generic.BulkDeleteView):
 register_model_view(PowerFeed, 'trace', kwargs={'model': PowerFeed})(PathTraceView)
 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
 # 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', 'consoleserverport', _('Console Server Ports')),
                 get_model_item('dcim', 'powerport', _('Power Ports')),
                 get_model_item('dcim', 'powerport', _('Power Ports')),
                 get_model_item('dcim', 'poweroutlet', _('Power Outlets')),
                 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', 'modulebay', _('Module Bays')),
                 get_model_item('dcim', 'devicebay', _('Device Bays')),
                 get_model_item('dcim', 'devicebay', _('Device Bays')),
                 get_model_item('dcim', 'inventoryitem', _('Inventory Items')),
                 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(
 PROVISIONING_MENU = Menu(
     label=_('Provisioning'),
     label=_('Provisioning'),
     icon_class='mdi mdi-file-document-multiple-outline',
     icon_class='mdi mdi-file-document-multiple-outline',
@@ -481,6 +497,7 @@ def get_menus():
         VIRTUALIZATION_MENU,
         VIRTUALIZATION_MENU,
         CIRCUITS_MENU,
         CIRCUITS_MENU,
         POWER_MENU,
         POWER_MENU,
+        COOLING_MENU,
         PROVISIONING_MENU,
         PROVISIONING_MENU,
         CUSTOMIZATION_MENU,
         CUSTOMIZATION_MENU,
         OPERATIONS_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 %}
                 {% 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>
                     <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 %}
                 {% 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 %}
                 {% 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>
                     <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 %}
                 {% endif %}

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

@@ -30,6 +30,12 @@
         {% if perms.dcim.add_poweroutlettemplate %}
         {% 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>
           <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 %}
         {% 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 %}
         {% 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>
           <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 %}
         {% endif %}