Prechádzať zdrojové kódy

configurable temperature units

Arthur 1 týždeň pred
rodič
commit
59c4e3d313

+ 6 - 2
docs/models/dcim/coolingsource.md

@@ -33,8 +33,12 @@ The total heat-removal capacity of the source, expressed in kilowatts (kW).
 
 
 ### Supply Temperature
 ### Supply Temperature
 
 
-The design supply (cold) coolant temperature, in degrees Celsius (°C).
+The design supply (cold) coolant temperature, expressed in the selected temperature unit.
 
 
 ### Return Temperature
 ### Return Temperature
 
 
-The design return (warm) coolant temperature, in degrees Celsius (°C).
+The design return (warm) coolant temperature, expressed in the selected temperature unit.
+
+### Temperature Unit
+
+The unit (Celsius or Fahrenheit) in which the supply and return temperatures are expressed.

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

@@ -2,6 +2,7 @@ from dcim.choices import *
 from dcim.models import CoolingFeed, CoolingSource
 from dcim.models import CoolingFeed, CoolingSource
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
 from netbox.api.serializers import PrimaryModelSerializer
 from netbox.api.serializers import PrimaryModelSerializer
+from netbox.choices import TemperatureUnitChoices
 from tenancy.api.serializers_.tenants import TenantSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
 
 
 from .base import ConnectedEndpointsSerializer
 from .base import ConnectedEndpointsSerializer
@@ -33,6 +34,12 @@ class CoolingSourceSerializer(PrimaryModelSerializer):
         choices=CoolingSourceStatusChoices,
         choices=CoolingSourceStatusChoices,
         default=lambda: CoolingSourceStatusChoices.STATUS_ACTIVE,
         default=lambda: CoolingSourceStatusChoices.STATUS_ACTIVE,
     )
     )
+    temperature_unit = ChoiceField(
+        choices=TemperatureUnitChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
 
 
     # Related object counts
     # Related object counts
     cooling_feed_count = RelatedObjectCountField('cooling_feeds')
     cooling_feed_count = RelatedObjectCountField('cooling_feeds')
@@ -41,8 +48,8 @@ class CoolingSourceSerializer(PrimaryModelSerializer):
         model = CoolingSource
         model = CoolingSource
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'site', 'location', 'name', 'type', 'status', 'cooling_capacity',
             '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',
+            'supply_temperature', 'return_temperature', 'temperature_unit', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'cooling_feed_count', 'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description', 'cooling_feed_count')
         brief_fields = ('id', 'url', 'display', 'name', 'description', 'cooling_feed_count')
 
 

+ 8 - 2
netbox/dcim/filtersets.py

@@ -12,7 +12,7 @@ from extras.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
 from ipam.filtersets import PrimaryIPFilterSet
 from ipam.filtersets import PrimaryIPFilterSet
 from ipam.models import ASN, VRF, IPAddress, VLANTranslationPolicy
 from ipam.models import ASN, VRF, IPAddress, VLANTranslationPolicy
-from netbox.choices import ColorChoices
+from netbox.choices import ColorChoices, TemperatureUnitChoices
 from netbox.filtersets import (
 from netbox.filtersets import (
     AttributeFiltersMixin,
     AttributeFiltersMixin,
     BaseFilterSet,
     BaseFilterSet,
@@ -3217,11 +3217,17 @@ class CoolingSourceFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
         distinct=False,
         distinct=False,
         null_value=None
         null_value=None
     )
     )
+    temperature_unit = django_filters.MultipleChoiceFilter(
+        choices=TemperatureUnitChoices,
+        distinct=False,
+        null_value=None
+    )
 
 
     class Meta:
     class Meta:
         model = CoolingSource
         model = CoolingSource
         fields = (
         fields = (
-            'id', 'name', 'cooling_capacity', 'supply_temperature', 'return_temperature', 'description',
+            'id', 'name', 'cooling_capacity', 'supply_temperature', 'return_temperature', 'temperature_unit',
+            'description',
         )
         )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):

+ 10 - 1
netbox/dcim/forms/bulk_edit.py

@@ -1099,11 +1099,20 @@ class CoolingSourceBulkEditForm(PrimaryModelBulkEditForm):
         label=_('Return temperature'),
         label=_('Return temperature'),
         required=False
         required=False
     )
     )
+    temperature_unit = forms.ChoiceField(
+        label=_('Temperature unit'),
+        choices=add_blank_choice(TemperatureUnitChoices),
+        required=False,
+        initial=''
+    )
 
 
     model = CoolingSource
     model = CoolingSource
     fieldsets = (
     fieldsets = (
         FieldSet('region', 'site_group', 'site', 'location', 'type', 'status', 'description'),
         FieldSet('region', 'site_group', 'site', 'location', 'type', 'status', 'description'),
-        FieldSet('cooling_capacity', 'supply_temperature', 'return_temperature', name=_('Characteristics')),
+        FieldSet(
+            'cooling_capacity', 'supply_temperature', 'return_temperature', 'temperature_unit',
+            name=_('Characteristics')
+        ),
     )
     )
     nullable_fields = (
     nullable_fields = (
         'location', 'type', 'cooling_capacity', 'supply_temperature', 'return_temperature', 'description', 'comments',
         'location', 'type', 'cooling_capacity', 'supply_temperature', 'return_temperature', 'description', 'comments',

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

@@ -1947,12 +1947,18 @@ class CoolingSourceImportForm(PrimaryModelImportForm):
         choices=CoolingSourceStatusChoices,
         choices=CoolingSourceStatusChoices,
         help_text=_('Operational status')
         help_text=_('Operational status')
     )
     )
+    temperature_unit = CSVChoiceField(
+        label=_('Temperature unit'),
+        choices=TemperatureUnitChoices,
+        required=False,
+        help_text=_('Unit for supply/return temperatures')
+    )
 
 
     class Meta:
     class Meta:
         model = CoolingSource
         model = CoolingSource
         fields = (
         fields = (
             'site', 'location', 'name', 'type', 'status', 'cooling_capacity', 'supply_temperature',
             'site', 'location', 'name', 'type', 'status', 'cooling_capacity', 'supply_temperature',
-            'return_temperature', 'description', 'owner', 'comments', 'tags',
+            'return_temperature', 'temperature_unit', 'description', 'owner', 'comments', 'tags',
         )
         )
 
 
     def __init__(self, data=None, *args, **kwargs):
     def __init__(self, data=None, *args, **kwargs):

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

@@ -1465,6 +1465,8 @@ class CoolingSourceFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm)
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         FieldSet('type', 'status', name=_('Attributes')),
         FieldSet('type', 'status', name=_('Attributes')),
+        FieldSet('cooling_capacity', 'supply_temperature', 'return_temperature', 'temperature_unit',
+                 name=_('Characteristics')),
         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')),
     )
     )
@@ -1507,6 +1509,23 @@ class CoolingSourceFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm)
         choices=CoolingSourceStatusChoices,
         choices=CoolingSourceStatusChoices,
         required=False
         required=False
     )
     )
+    cooling_capacity = forms.DecimalField(
+        label=_('Cooling capacity'),
+        required=False
+    )
+    supply_temperature = forms.DecimalField(
+        label=_('Supply temperature'),
+        required=False
+    )
+    return_temperature = forms.DecimalField(
+        label=_('Return temperature'),
+        required=False
+    )
+    temperature_unit = forms.MultipleChoiceField(
+        label=_('Temperature unit'),
+        choices=TemperatureUnitChoices,
+        required=False
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 

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

@@ -985,7 +985,9 @@ class CoolingSourceForm(PrimaryModelForm):
     fieldsets = (
     fieldsets = (
         FieldSet('site', 'location', 'name', 'type', 'status', 'description', 'tags', name=_('Cooling Source')),
         FieldSet('site', 'location', 'name', 'type', 'status', 'description', 'tags', name=_('Cooling Source')),
         FieldSet(
         FieldSet(
-            'cooling_capacity', 'supply_temperature', 'return_temperature', name=_('Characteristics')
+            'cooling_capacity',
+            InlineFields('supply_temperature', 'return_temperature', 'temperature_unit', label=_('Temperatures')),
+            name=_('Characteristics')
         ),
         ),
     )
     )
 
 
@@ -993,7 +995,7 @@ class CoolingSourceForm(PrimaryModelForm):
         model = CoolingSource
         model = CoolingSource
         fields = [
         fields = [
             'site', 'location', 'name', 'type', 'status', 'cooling_capacity', 'supply_temperature',
             'site', 'location', 'name', 'type', 'status', 'cooling_capacity', 'supply_temperature',
-            'return_temperature', 'description', 'owner', 'comments', 'tags',
+            'return_temperature', 'temperature_unit', 'description', 'owner', 'comments', 'tags',
         ]
         ]
 
 
 
 

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

@@ -1,7 +1,7 @@
 import strawberry
 import strawberry
 
 
 from dcim.choices import *
 from dcim.choices import *
-from netbox.choices import DiameterUnitChoices
+from netbox.choices import DiameterUnitChoices, TemperatureUnitChoices
 
 
 __all__ = (
 __all__ = (
     'CableEndEnum',
     'CableEndEnum',
@@ -48,6 +48,7 @@ __all__ = (
     'RackWidthEnum',
     'RackWidthEnum',
     'SiteStatusEnum',
     'SiteStatusEnum',
     'SubdeviceRoleEnum',
     'SubdeviceRoleEnum',
+    'TemperatureUnitEnum',
     'VirtualDeviceContextStatusEnum',
     'VirtualDeviceContextStatusEnum',
 )
 )
 
 
@@ -95,4 +96,5 @@ RackStatusEnum = strawberry.enum(RackStatusChoices.as_enum(prefix='status'))
 RackWidthEnum = strawberry.enum(RackWidthChoices.as_enum(prefix='width'))
 RackWidthEnum = strawberry.enum(RackWidthChoices.as_enum(prefix='width'))
 SiteStatusEnum = strawberry.enum(SiteStatusChoices.as_enum(prefix='status'))
 SiteStatusEnum = strawberry.enum(SiteStatusChoices.as_enum(prefix='status'))
 SubdeviceRoleEnum = strawberry.enum(SubdeviceRoleChoices.as_enum(prefix='role'))
 SubdeviceRoleEnum = strawberry.enum(SubdeviceRoleChoices.as_enum(prefix='role'))
+TemperatureUnitEnum = strawberry.enum(TemperatureUnitChoices.as_enum(prefix='unit'))
 VirtualDeviceContextStatusEnum = strawberry.enum(VirtualDeviceContextStatusChoices.as_enum(prefix='status'))
 VirtualDeviceContextStatusEnum = strawberry.enum(VirtualDeviceContextStatusChoices.as_enum(prefix='status'))

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

@@ -1079,6 +1079,9 @@ class CoolingSourceFilter(ContactFilterMixin, ImageAttachmentFilterMixin, Primar
     return_temperature: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     return_temperature: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
+    temperature_unit: (
+        BaseFilterLookup[Annotated['TemperatureUnitEnum', strawberry.lazy('dcim.graphql.enums')]] | None
+    ) = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.RackType, lookups=True)
 @strawberry_django.filter_type(models.RackType, lookups=True)

+ 19 - 3
netbox/dcim/migrations/0241_device_cooling_method_device_cooling_outlet_count_and_more.py

@@ -1,4 +1,4 @@
-# Generated by Django 6.0.6 on 2026-06-23 22:31
+# Generated by Django 6.0.6 on 2026-06-24 18:32
 
 
 import django.contrib.postgres.fields
 import django.contrib.postgres.fields
 import django.core.validators
 import django.core.validators
@@ -686,13 +686,29 @@ class Migration(migrations.Migration):
                 (
                 (
                     "supply_temperature",
                     "supply_temperature",
                     models.DecimalField(
                     models.DecimalField(
-                        blank=True, decimal_places=2, max_digits=5, null=True
+                        blank=True, decimal_places=2, max_digits=6, null=True
                     ),
                     ),
                 ),
                 ),
                 (
                 (
                     "return_temperature",
                     "return_temperature",
                     models.DecimalField(
                     models.DecimalField(
-                        blank=True, decimal_places=2, max_digits=5, null=True
+                        blank=True, decimal_places=2, max_digits=6, null=True
+                    ),
+                ),
+                (
+                    "temperature_unit",
+                    models.CharField(blank=True, max_length=50, null=True),
+                ),
+                (
+                    "_abs_supply_temperature",
+                    models.DecimalField(
+                        blank=True, decimal_places=4, max_digits=8, null=True
+                    ),
+                ),
+                (
+                    "_abs_return_temperature",
+                    models.DecimalField(
+                        blank=True, decimal_places=4, max_digits=8, null=True
                     ),
                     ),
                 ),
                 ),
                 (
                 (

+ 62 - 4
netbox/dcim/models/cooling.py

@@ -4,8 +4,10 @@ from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from dcim.choices import *
 from dcim.choices import *
+from netbox.choices import TemperatureUnitChoices
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
+from utilities.conversion import to_celsius
 
 
 from .device_components import CabledObjectModel, PathEndpoint
 from .device_components import CabledObjectModel, PathEndpoint
 
 
@@ -62,23 +64,44 @@ class CoolingSource(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
     )
     )
     supply_temperature = models.DecimalField(
     supply_temperature = models.DecimalField(
         verbose_name=_('supply temperature'),
         verbose_name=_('supply temperature'),
-        max_digits=5,
+        max_digits=6,
         decimal_places=2,
         decimal_places=2,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        help_text=_('Design supply temperature (°C)')
+        help_text=_('Design supply temperature')
     )
     )
     return_temperature = models.DecimalField(
     return_temperature = models.DecimalField(
         verbose_name=_('return temperature'),
         verbose_name=_('return temperature'),
-        max_digits=5,
+        max_digits=6,
         decimal_places=2,
         decimal_places=2,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        help_text=_('Design return temperature (°C)')
+        help_text=_('Design return temperature')
+    )
+    temperature_unit = models.CharField(
+        verbose_name=_('temperature unit'),
+        max_length=50,
+        choices=TemperatureUnitChoices,
+        blank=True,
+        null=True,
+    )
+    # Stores the normalized temperatures (in degrees Celsius) for database ordering
+    _abs_supply_temperature = models.DecimalField(
+        max_digits=8,
+        decimal_places=4,
+        blank=True,
+        null=True
+    )
+    _abs_return_temperature = models.DecimalField(
+        max_digits=8,
+        decimal_places=4,
+        blank=True,
+        null=True
     )
     )
 
 
     clone_fields = (
     clone_fields = (
         'site', 'location', 'type', 'status', 'cooling_capacity', 'supply_temperature', 'return_temperature',
         'site', 'location', 'type', 'status', 'cooling_capacity', 'supply_temperature', 'return_temperature',
+        'temperature_unit',
     )
     )
     prerequisite_models = (
     prerequisite_models = (
         'dcim.Site',
         'dcim.Site',
@@ -98,9 +121,40 @@ class CoolingSource(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    @property
+    def abs_supply_temperature(self):
+        # Public alias for _abs_supply_temperature; Django templates cannot access underscore-prefixed attributes.
+        return self._abs_supply_temperature
+
+    @property
+    def abs_return_temperature(self):
+        # Public alias for _abs_return_temperature; Django templates cannot access underscore-prefixed attributes.
+        return self._abs_return_temperature
+
     def get_status_color(self):
     def get_status_color(self):
         return CoolingSourceStatusChoices.colors.get(self.status)
         return CoolingSourceStatusChoices.colors.get(self.status)
 
 
+    def save(self, *args, **kwargs):
+        # Store the given temperatures (if any) in degrees Celsius for use in database ordering
+        if self.temperature_unit:
+            self._abs_supply_temperature = (
+                to_celsius(self.supply_temperature, self.temperature_unit)
+                if self.supply_temperature is not None else None
+            )
+            self._abs_return_temperature = (
+                to_celsius(self.return_temperature, self.temperature_unit)
+                if self.return_temperature is not None else None
+            )
+        else:
+            self._abs_supply_temperature = None
+            self._abs_return_temperature = None
+
+        # Clear temperature_unit if no temperatures are defined
+        if self.supply_temperature is None and self.return_temperature is None:
+            self.temperature_unit = None
+
+        super().save(*args, **kwargs)
+
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
@@ -111,6 +165,10 @@ class CoolingSource(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
                     location=self.location, location_site=self.location.site, site=self.site)
                     location=self.location, location_site=self.location.site, site=self.site)
             )
             )
 
 
+        # A temperature unit is required when a temperature is set
+        if (self.supply_temperature is not None or self.return_temperature is not None) and not self.temperature_unit:
+            raise ValidationError(_("Must specify a unit when setting a temperature"))
+
 
 
 class CoolingFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
 class CoolingFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
     """
     """

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

@@ -55,10 +55,15 @@ class CoolingSourceTable(ContactsColumnMixin, PrimaryModelTable):
         verbose_name=_('Cooling Capacity (kW)')
         verbose_name=_('Cooling Capacity (kW)')
     )
     )
     supply_temperature = tables.Column(
     supply_temperature = tables.Column(
-        verbose_name=_('Supply Temperature (°C)')
+        verbose_name=_('Supply Temperature'),
+        order_by=('_abs_supply_temperature',)
     )
     )
     return_temperature = tables.Column(
     return_temperature = tables.Column(
-        verbose_name=_('Return Temperature (°C)')
+        verbose_name=_('Return Temperature'),
+        order_by=('_abs_return_temperature',)
+    )
+    temperature_unit = columns.ChoiceFieldColumn(
+        verbose_name=_('Temperature Unit'),
     )
     )
     cooling_feed_count = columns.LinkedCountColumn(
     cooling_feed_count = columns.LinkedCountColumn(
         viewname='dcim:coolingfeed_list',
         viewname='dcim:coolingfeed_list',
@@ -73,8 +78,8 @@ class CoolingSourceTable(ContactsColumnMixin, PrimaryModelTable):
         model = CoolingSource
         model = CoolingSource
         fields = (
         fields = (
             'pk', 'id', 'name', 'site', 'location', 'type', 'status', 'cooling_capacity', 'supply_temperature',
             'pk', 'id', 'name', 'site', 'location', 'type', 'status', 'cooling_capacity', 'supply_temperature',
-            'return_temperature', 'cooling_feed_count', 'contacts', 'description', 'comments', 'tags', 'created',
-            'last_updated',
+            'return_temperature', 'temperature_unit', 'cooling_feed_count', 'contacts', 'description', 'comments',
+            'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'site', 'location', 'type', 'status', 'cooling_capacity', 'cooling_feed_count',
             'pk', 'name', 'site', 'location', 'type', 'status', 'cooling_capacity', 'cooling_feed_count',

+ 8 - 1
netbox/dcim/tests/test_filtersets.py

@@ -10,7 +10,7 @@ from dcim.filtersets import *
 from dcim.models import *
 from dcim.models import *
 from ipam.choices import VLANQinQRoleChoices
 from ipam.choices import VLANQinQRoleChoices
 from ipam.models import ASN, RIR, VLAN, VRF, IPAddress, VLANTranslationPolicy
 from ipam.models import ASN, RIR, VLAN, VRF, IPAddress, VLANTranslationPolicy
-from netbox.choices import ColorChoices, DiameterUnitChoices, WeightUnitChoices
+from netbox.choices import ColorChoices, DiameterUnitChoices, TemperatureUnitChoices, WeightUnitChoices
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from users.models import User
 from users.models import User
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
@@ -8234,6 +8234,7 @@ class CoolingSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
                 cooling_capacity=100,
                 cooling_capacity=100,
                 supply_temperature=18,
                 supply_temperature=18,
                 return_temperature=30,
                 return_temperature=30,
+                temperature_unit=TemperatureUnitChoices.UNIT_CELSIUS,
                 description='foobar1'
                 description='foobar1'
             ),
             ),
             CoolingSource(
             CoolingSource(
@@ -8245,6 +8246,7 @@ class CoolingSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
                 cooling_capacity=200,
                 cooling_capacity=200,
                 supply_temperature=20,
                 supply_temperature=20,
                 return_temperature=32,
                 return_temperature=32,
+                temperature_unit=TemperatureUnitChoices.UNIT_CELSIUS,
                 description='foobar2'
                 description='foobar2'
             ),
             ),
             CoolingSource(
             CoolingSource(
@@ -8256,6 +8258,7 @@ class CoolingSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
                 cooling_capacity=300,
                 cooling_capacity=300,
                 supply_temperature=22,
                 supply_temperature=22,
                 return_temperature=34,
                 return_temperature=34,
+                temperature_unit=TemperatureUnitChoices.UNIT_FAHRENHEIT,
                 description='foobar3'
                 description='foobar3'
             ),
             ),
         )
         )
@@ -8293,6 +8296,10 @@ class CoolingSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'return_temperature': [30, 32]}
         params = {'return_temperature': [30, 32]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_temperature_unit(self):
+        params = {'temperature_unit': [TemperatureUnitChoices.UNIT_CELSIUS]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_region(self):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}

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

@@ -400,8 +400,8 @@ class CoolingSourcePanel(panels.ObjectAttributesPanel):
     type = attrs.ChoiceAttr('type')
     type = attrs.ChoiceAttr('type')
     status = attrs.ChoiceAttr('status')
     status = attrs.ChoiceAttr('status')
     cooling_capacity = attrs.TextAttr('cooling_capacity', format_string=_('{} kW'))
     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'))
+    supply_temperature = attrs.NumericAttr('supply_temperature', unit_accessor='get_temperature_unit_display')
+    return_temperature = attrs.NumericAttr('return_temperature', unit_accessor='get_temperature_unit_display')
     description = attrs.TextAttr('description')
     description = attrs.TextAttr('description')
 
 
 
 

+ 15 - 0
netbox/netbox/choices.py

@@ -11,6 +11,7 @@ __all__ = (
     'DistanceUnitChoices',
     'DistanceUnitChoices',
     'ImportFormatChoices',
     'ImportFormatChoices',
     'ImportMethodChoices',
     'ImportMethodChoices',
+    'TemperatureUnitChoices',
     'WeightUnitChoices',
     'WeightUnitChoices',
 )
 )
 
 
@@ -214,3 +215,17 @@ class DiameterUnitChoices(ChoiceSet):
         (UNIT_CENTIMETER, _('Centimeters')),
         (UNIT_CENTIMETER, _('Centimeters')),
         (UNIT_INCH, _('Inches')),
         (UNIT_INCH, _('Inches')),
     )
     )
+
+
+class TemperatureUnitChoices(ChoiceSet):
+
+    # Metric
+    UNIT_CELSIUS = 'c'
+
+    # Imperial
+    UNIT_FAHRENHEIT = 'f'
+
+    CHOICES = (
+        (UNIT_CELSIUS, _('Celsius')),
+        (UNIT_FAHRENHEIT, _('Fahrenheit')),
+    )

+ 25 - 1
netbox/utilities/conversion.py

@@ -3,9 +3,10 @@ from decimal import Decimal, InvalidOperation
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from dcim.choices import CableLengthUnitChoices
 from dcim.choices import CableLengthUnitChoices
-from netbox.choices import DiameterUnitChoices, WeightUnitChoices
+from netbox.choices import DiameterUnitChoices, TemperatureUnitChoices, WeightUnitChoices
 
 
 __all__ = (
 __all__ = (
+    'to_celsius',
     'to_grams',
     'to_grams',
     'to_meters',
     'to_meters',
     'to_millimeters',
     'to_millimeters',
@@ -69,6 +70,29 @@ def to_meters(length, unit) -> Decimal:
     )
     )
 
 
 
 
+def to_celsius(temperature, unit) -> Decimal:
+    """
+    Convert the given temperature to degrees Celsius, returning a Decimal value. Temperatures may be negative.
+    """
+    try:
+        temperature = Decimal(temperature)
+    except InvalidOperation:
+        raise TypeError(
+            _("Invalid value '{temperature}' for temperature (must be a number)").format(temperature=temperature)
+        )
+
+    if unit == TemperatureUnitChoices.UNIT_CELSIUS:
+        return round(Decimal(temperature), 4)
+    if unit == TemperatureUnitChoices.UNIT_FAHRENHEIT:
+        return round((Decimal(temperature) - 32) * 5 / 9, 4)
+    raise ValueError(
+        _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
+            unit=unit,
+            valid_units=', '.join(TemperatureUnitChoices.values())
+        )
+    )
+
+
 def to_millimeters(diameter, unit) -> Decimal:
 def to_millimeters(diameter, unit) -> Decimal:
     """
     """
     Convert the given diameter to millimeters, returning a Decimal value.
     Convert the given diameter to millimeters, returning a Decimal value.