Bladeren bron

configurable temperature units

Arthur 1 week geleden
bovenliggende
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
 
-The design supply (cold) coolant temperature, in degrees Celsius (°C).
+The design supply (cold) coolant temperature, expressed in the selected temperature unit.
 
 ### 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 netbox.api.fields import ChoiceField, RelatedObjectCountField
 from netbox.api.serializers import PrimaryModelSerializer
+from netbox.choices import TemperatureUnitChoices
 from tenancy.api.serializers_.tenants import TenantSerializer
 
 from .base import ConnectedEndpointsSerializer
@@ -33,6 +34,12 @@ class CoolingSourceSerializer(PrimaryModelSerializer):
         choices=CoolingSourceStatusChoices,
         default=lambda: CoolingSourceStatusChoices.STATUS_ACTIVE,
     )
+    temperature_unit = ChoiceField(
+        choices=TemperatureUnitChoices,
+        allow_blank=True,
+        required=False,
+        allow_null=True
+    )
 
     # Related object counts
     cooling_feed_count = RelatedObjectCountField('cooling_feeds')
@@ -41,8 +48,8 @@ class CoolingSourceSerializer(PrimaryModelSerializer):
         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',
+            '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')
 

+ 8 - 2
netbox/dcim/filtersets.py

@@ -12,7 +12,7 @@ from extras.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate
 from ipam.filtersets import PrimaryIPFilterSet
 from ipam.models import ASN, VRF, IPAddress, VLANTranslationPolicy
-from netbox.choices import ColorChoices
+from netbox.choices import ColorChoices, TemperatureUnitChoices
 from netbox.filtersets import (
     AttributeFiltersMixin,
     BaseFilterSet,
@@ -3217,11 +3217,17 @@ class CoolingSourceFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
         distinct=False,
         null_value=None
     )
+    temperature_unit = django_filters.MultipleChoiceFilter(
+        choices=TemperatureUnitChoices,
+        distinct=False,
+        null_value=None
+    )
 
     class Meta:
         model = CoolingSource
         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):

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

@@ -1099,11 +1099,20 @@ class CoolingSourceBulkEditForm(PrimaryModelBulkEditForm):
         label=_('Return temperature'),
         required=False
     )
+    temperature_unit = forms.ChoiceField(
+        label=_('Temperature unit'),
+        choices=add_blank_choice(TemperatureUnitChoices),
+        required=False,
+        initial=''
+    )
 
     model = CoolingSource
     fieldsets = (
         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 = (
         '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,
         help_text=_('Operational status')
     )
+    temperature_unit = CSVChoiceField(
+        label=_('Temperature unit'),
+        choices=TemperatureUnitChoices,
+        required=False,
+        help_text=_('Unit for supply/return temperatures')
+    )
 
     class Meta:
         model = CoolingSource
         fields = (
             '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):

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

@@ -1465,6 +1465,8 @@ class CoolingSourceFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm)
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         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('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
@@ -1507,6 +1509,23 @@ class CoolingSourceFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm)
         choices=CoolingSourceStatusChoices,
         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)
 
 

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

@@ -985,7 +985,9 @@ class CoolingSourceForm(PrimaryModelForm):
     fieldsets = (
         FieldSet('site', 'location', 'name', 'type', 'status', 'description', 'tags', name=_('Cooling Source')),
         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
         fields = [
             '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
 
 from dcim.choices import *
-from netbox.choices import DiameterUnitChoices
+from netbox.choices import DiameterUnitChoices, TemperatureUnitChoices
 
 __all__ = (
     'CableEndEnum',
@@ -48,6 +48,7 @@ __all__ = (
     'RackWidthEnum',
     'SiteStatusEnum',
     'SubdeviceRoleEnum',
+    'TemperatureUnitEnum',
     'VirtualDeviceContextStatusEnum',
 )
 
@@ -95,4 +96,5 @@ RackStatusEnum = strawberry.enum(RackStatusChoices.as_enum(prefix='status'))
 RackWidthEnum = strawberry.enum(RackWidthChoices.as_enum(prefix='width'))
 SiteStatusEnum = strawberry.enum(SiteStatusChoices.as_enum(prefix='status'))
 SubdeviceRoleEnum = strawberry.enum(SubdeviceRoleChoices.as_enum(prefix='role'))
+TemperatureUnitEnum = strawberry.enum(TemperatureUnitChoices.as_enum(prefix='unit'))
 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 = (
         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)

+ 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.core.validators
@@ -686,13 +686,29 @@ class Migration(migrations.Migration):
                 (
                     "supply_temperature",
                     models.DecimalField(
-                        blank=True, decimal_places=2, max_digits=5, null=True
+                        blank=True, decimal_places=2, max_digits=6, null=True
                     ),
                 ),
                 (
                     "return_temperature",
                     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 dcim.choices import *
+from netbox.choices import TemperatureUnitChoices
 from netbox.models import PrimaryModel
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
+from utilities.conversion import to_celsius
 
 from .device_components import CabledObjectModel, PathEndpoint
 
@@ -62,23 +64,44 @@ class CoolingSource(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
     )
     supply_temperature = models.DecimalField(
         verbose_name=_('supply temperature'),
-        max_digits=5,
+        max_digits=6,
         decimal_places=2,
         blank=True,
         null=True,
-        help_text=_('Design supply temperature (°C)')
+        help_text=_('Design supply temperature')
     )
     return_temperature = models.DecimalField(
         verbose_name=_('return temperature'),
-        max_digits=5,
+        max_digits=6,
         decimal_places=2,
         blank=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 = (
         'site', 'location', 'type', 'status', 'cooling_capacity', 'supply_temperature', 'return_temperature',
+        'temperature_unit',
     )
     prerequisite_models = (
         'dcim.Site',
@@ -98,9 +121,40 @@ class CoolingSource(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
     def __str__(self):
         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):
         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):
         super().clean()
 
@@ -111,6 +165,10 @@ class CoolingSource(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
                     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):
     """

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

@@ -55,10 +55,15 @@ class CoolingSourceTable(ContactsColumnMixin, PrimaryModelTable):
         verbose_name=_('Cooling Capacity (kW)')
     )
     supply_temperature = tables.Column(
-        verbose_name=_('Supply Temperature (°C)')
+        verbose_name=_('Supply Temperature'),
+        order_by=('_abs_supply_temperature',)
     )
     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(
         viewname='dcim:coolingfeed_list',
@@ -73,8 +78,8 @@ class CoolingSourceTable(ContactsColumnMixin, PrimaryModelTable):
         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',
+            'return_temperature', 'temperature_unit', 'cooling_feed_count', 'contacts', 'description', 'comments',
+            'tags', 'created', 'last_updated',
         )
         default_columns = (
             '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 ipam.choices import VLANQinQRoleChoices
 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 users.models import User
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
@@ -8234,6 +8234,7 @@ class CoolingSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
                 cooling_capacity=100,
                 supply_temperature=18,
                 return_temperature=30,
+                temperature_unit=TemperatureUnitChoices.UNIT_CELSIUS,
                 description='foobar1'
             ),
             CoolingSource(
@@ -8245,6 +8246,7 @@ class CoolingSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
                 cooling_capacity=200,
                 supply_temperature=20,
                 return_temperature=32,
+                temperature_unit=TemperatureUnitChoices.UNIT_CELSIUS,
                 description='foobar2'
             ),
             CoolingSource(
@@ -8256,6 +8258,7 @@ class CoolingSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
                 cooling_capacity=300,
                 supply_temperature=22,
                 return_temperature=34,
+                temperature_unit=TemperatureUnitChoices.UNIT_FAHRENHEIT,
                 description='foobar3'
             ),
         )
@@ -8293,6 +8296,10 @@ class CoolingSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'return_temperature': [30, 32]}
         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):
         regions = Region.objects.all()[:2]
         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')
     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'))
+    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')
 
 

+ 15 - 0
netbox/netbox/choices.py

@@ -11,6 +11,7 @@ __all__ = (
     'DistanceUnitChoices',
     'ImportFormatChoices',
     'ImportMethodChoices',
+    'TemperatureUnitChoices',
     'WeightUnitChoices',
 )
 
@@ -214,3 +215,17 @@ class DiameterUnitChoices(ChoiceSet):
         (UNIT_CENTIMETER, _('Centimeters')),
         (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 dcim.choices import CableLengthUnitChoices
-from netbox.choices import DiameterUnitChoices, WeightUnitChoices
+from netbox.choices import DiameterUnitChoices, TemperatureUnitChoices, WeightUnitChoices
 
 __all__ = (
+    'to_celsius',
     'to_grams',
     'to_meters',
     '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:
     """
     Convert the given diameter to millimeters, returning a Decimal value.