Explorar el Código

Closes #17127: Add user preference for metric/imperial measurements

Adds a ui.measurement_system user preference (metric / imperial / inherit)
that controls how weight and distance values are displayed throughout the UI.

- WeightAttr and DistanceAttr panel attribute classes convert stored values to
  the preferred unit system using the absolute gram/meter fields
- Public abs_weight / abs_distance properties on WeightMixin / DistanceMixin
  expose normalized values to templates without the underscore restriction
- display_weight and display_distance context-aware template tags for table
  column rendering (WEIGHT template code, DistanceColumn)
- total_weight templates for racks and devices respect the preference
- ObjectAttributesPanel passes preferences through attr render context
- Unit tests covering metric/imperial/inherit and None-value placeholder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Brian Tiemann hace 3 días
padre
commit
7b11ee9f2f

+ 1 - 1
netbox/circuits/ui/panels.py

@@ -120,7 +120,7 @@ class CircuitPanel(panels.ObjectAttributesPanel):
     cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True)
     type = attrs.RelatedObjectAttr('type', linkify=True, colored=True)
     status = attrs.ChoiceAttr('status')
-    distance = attrs.NumericAttr('distance', unit_accessor='get_distance_unit_display')
+    distance = attrs.DistanceAttr('distance')
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     install_date = attrs.DateTimeAttr('install_date', spec='date')
     termination_date = attrs.DateTimeAttr('termination_date', spec='date')

+ 1 - 1
netbox/dcim/tables/template_code.py

@@ -49,7 +49,7 @@ CABLE_LENGTH = """
 
 WEIGHT = """
 {% load helpers %}
-{% if value %}{{ value|floatformat:"-2" }} {{ record.weight_unit }}{% endif %}
+{% display_weight record.weight record.weight_unit record.abs_weight %}
 """
 
 DEVICE_LINK = """

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

@@ -60,8 +60,8 @@ class RackPanel(panels.ObjectAttributesPanel):
 
 
 class RackWeightPanel(panels.ObjectAttributesPanel):
-    weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
-    max_weight = attrs.NumericAttr('max_weight', unit_accessor='get_weight_unit_display', label=_('Maximum weight'))
+    weight = attrs.WeightAttr('weight')
+    max_weight = attrs.WeightAttr('max_weight', label=_('Maximum weight'))
     total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/rack/attrs/total_weight.html')
 
 
@@ -155,7 +155,7 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
     height = attrs.TemplatedAttr('u_height', template_name='dcim/devicetype/attrs/height.html')
     exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization')
     full_depth = attrs.BooleanAttr('is_full_depth')
-    weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
+    weight = attrs.WeightAttr('weight')
     subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child'))
     airflow = attrs.ChoiceAttr('airflow')
     front_image = attrs.ImageAttr('front_image')
@@ -184,7 +184,7 @@ class ModuleTypePanel(panels.ObjectAttributesPanel):
     part_number = attrs.TextAttr('part_number')
     description = attrs.TextAttr('description')
     airflow = attrs.ChoiceAttr('airflow')
-    weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
+    weight = attrs.WeightAttr('weight')
 
 
 class PlatformPanel(panels.NestedGroupObjectPanel):

+ 8 - 0
netbox/netbox/models/mixins.py

@@ -51,6 +51,10 @@ class WeightMixin(models.Model):
     class Meta:
         abstract = True
 
+    @property
+    def abs_weight(self):
+        return self._abs_weight
+
     def save(self, *args, **kwargs):
 
         # Store the given weight (if any) in grams for use in database ordering
@@ -95,6 +99,10 @@ class DistanceMixin(models.Model):
     class Meta:
         abstract = True
 
+    @property
+    def abs_distance(self):
+        return self._abs_distance
+
     def save(self, *args, **kwargs):
         # Store the given distance (if any) in meters for use in database ordering
         if self.distance is not None and self.distance_unit:

+ 12 - 0
netbox/netbox/preferences.py

@@ -73,6 +73,18 @@ PREFERENCES = {
         description=_('Render table rows with alternating colors to increase readability'),
     ),
 
+    # Measurements
+    'ui.measurement_system': UserPreference(
+        label=_('Measurement units'),
+        choices=(
+            ('', _('Inherited')),
+            ('metric', _('Metric')),
+            ('imperial', _('Imperial')),
+        ),
+        default='',
+        description=_('Preferred unit system for displaying weight and distance measurements'),
+    ),
+
     # Miscellaneous
     'data_format': UserPreference(
         label=_('Data format'),

+ 1 - 1
netbox/netbox/tables/columns.py

@@ -721,7 +721,7 @@ class DistanceColumn(TemplateColumn):
     """
     template_code = """
     {% load helpers %}
-    {% if record.distance %}{{ record.distance|floatformat:"-2" }} {{ record.distance_unit }}{% endif %}
+    {% display_distance record.distance record.distance_unit record.abs_distance %}
     """
 
     def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs):

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

@@ -461,6 +461,159 @@ class DateTimeAttrTestCase(TestCase):
         self.assertEqual(context['spec'], 'minutes')
 
 
+class WeightAttrTestCase(TestCase):
+
+    def _ctx(self, system=''):
+        return {'name': 'weight', 'preferences': {'ui.measurement_system': system}}
+
+    def _obj(self, weight, unit, abs_g, display=None):
+        display_fn = (lambda: display) if display else (lambda: unit)
+        return SimpleNamespace(
+            weight=weight,
+            weight_unit=unit,
+            _abs_weight=abs_g,
+            get_weight_unit_display=display_fn,
+        )
+
+    def test_none_returns_placeholder(self):
+        attr = attrs.WeightAttr('weight')
+        obj = SimpleNamespace(weight=None)
+        self.assertEqual(attr.render(obj, self._ctx()), attr.placeholder)
+
+    def test_inherit_shows_stored_value(self):
+        attr = attrs.WeightAttr('weight')
+        obj = self._obj(5, 'kg', 5000, 'Kilograms')
+        result = attr.render(obj, self._ctx(system=''))
+        self.assertIn('5', result)
+        self.assertIn('kilograms', result)
+
+    def test_metric_converts_lbs_to_kg(self):
+        # 10 lb = 4535.92 g → 4535.92 / 1000 = 4.54 kg
+        attr = attrs.WeightAttr('weight')
+        obj = self._obj(10, 'lb', 4535.92, 'Pounds')
+        result = attr.render(obj, self._ctx(system='metric'))
+        self.assertIn('4.54', result)
+        self.assertIn('kg', result)
+
+    def test_metric_no_conversion_for_metric_unit(self):
+        attr = attrs.WeightAttr('weight')
+        obj = self._obj(5, 'kg', 5000, 'Kilograms')
+        result = attr.render(obj, self._ctx(system='metric'))
+        self.assertIn('5', result)
+        self.assertIn('kilograms', result)
+
+    def test_imperial_converts_kg_to_lbs(self):
+        # 1 kg = 1000 g → 1000 / 453.592 = 2.2 lbs
+        attr = attrs.WeightAttr('weight')
+        obj = self._obj(1, 'kg', 1000, 'Kilograms')
+        result = attr.render(obj, self._ctx(system='imperial'))
+        self.assertIn('2.2', result)
+        self.assertIn('lbs', result)
+
+    def test_imperial_no_conversion_for_imperial_unit(self):
+        attr = attrs.WeightAttr('weight')
+        obj = self._obj(10, 'lb', 4535.92, 'Pounds')
+        result = attr.render(obj, self._ctx(system='imperial'))
+        self.assertIn('10', result)
+        self.assertIn('pounds', result)
+
+    def test_metric_no_conversion_when_abs_weight_is_none(self):
+        # abs_weight=None → falsy → falls through to stored value
+        attr = attrs.WeightAttr('weight')
+        obj = SimpleNamespace(
+            weight=10, weight_unit='lb', _abs_weight=None,
+            get_weight_unit_display=lambda: 'Pounds',
+        )
+        result = attr.render(obj, self._ctx(system='metric'))
+        self.assertIn('10', result)
+        self.assertIn('pounds', result)
+
+
+class DistanceAttrTestCase(TestCase):
+
+    def _ctx(self, system=''):
+        return {'name': 'distance', 'preferences': {'ui.measurement_system': system}}
+
+    def _obj(self, distance, unit, abs_m, display=None):
+        display_fn = (lambda: display) if display else (lambda: unit)
+        return SimpleNamespace(
+            distance=distance,
+            distance_unit=unit,
+            _abs_distance=abs_m,
+            get_distance_unit_display=display_fn,
+        )
+
+    def test_none_returns_placeholder(self):
+        attr = attrs.DistanceAttr('distance')
+        obj = SimpleNamespace(distance=None)
+        self.assertEqual(attr.render(obj, self._ctx()), attr.placeholder)
+
+    def test_inherit_shows_stored_value(self):
+        attr = attrs.DistanceAttr('distance')
+        obj = self._obj(10, 'km', 10000, 'Kilometers')
+        result = attr.render(obj, self._ctx(system=''))
+        self.assertIn('10', result)
+        self.assertIn('kilometers', result)
+
+    def test_metric_converts_ft_to_m_under_threshold(self):
+        # 500 ft = 152.4 m (< 1000) → display in m
+        attr = attrs.DistanceAttr('distance')
+        obj = self._obj(500, 'ft', 152.4, 'Feet')
+        result = attr.render(obj, self._ctx(system='metric'))
+        self.assertIn('152.4', result)
+        self.assertNotIn('km', result)
+        self.assertIn('m', result)
+
+    def test_metric_converts_mi_to_km_over_threshold(self):
+        # 5 mi = 8046.72 m (>= 1000) → 8046.72 / 1000 = 8.05 km
+        attr = attrs.DistanceAttr('distance')
+        obj = self._obj(5, 'mi', 8046.72, 'Miles')
+        result = attr.render(obj, self._ctx(system='metric'))
+        self.assertIn('8.05', result)
+        self.assertIn('km', result)
+
+    def test_imperial_converts_m_to_ft_under_threshold(self):
+        # 500 m (< 1609.344) → 500 / 0.3048 = 1640.42 ft
+        attr = attrs.DistanceAttr('distance')
+        obj = self._obj(500, 'm', 500, 'Meters')
+        result = attr.render(obj, self._ctx(system='imperial'))
+        self.assertIn('1640.42', result)
+        self.assertIn('ft', result)
+
+    def test_imperial_converts_km_to_mi_over_threshold(self):
+        # 10 km = 10000 m (>= 1609.344) → 10000 / 1609.344 = 6.21 mi
+        attr = attrs.DistanceAttr('distance')
+        obj = self._obj(10, 'km', 10000, 'Kilometers')
+        result = attr.render(obj, self._ctx(system='imperial'))
+        self.assertIn('6.21', result)
+        self.assertIn('mi', result)
+
+    def test_metric_no_conversion_for_metric_unit(self):
+        attr = attrs.DistanceAttr('distance')
+        obj = self._obj(10, 'km', 10000, 'Kilometers')
+        result = attr.render(obj, self._ctx(system='metric'))
+        self.assertIn('10', result)
+        self.assertIn('kilometers', result)
+
+    def test_imperial_no_conversion_for_imperial_unit(self):
+        attr = attrs.DistanceAttr('distance')
+        obj = self._obj(10, 'mi', 16093.44, 'Miles')
+        result = attr.render(obj, self._ctx(system='imperial'))
+        self.assertIn('10', result)
+        self.assertIn('miles', result)
+
+    def test_metric_no_conversion_when_abs_distance_is_none(self):
+        # abs_distance=None → falls through to stored value
+        attr = attrs.DistanceAttr('distance')
+        obj = SimpleNamespace(
+            distance=10, distance_unit='ft', _abs_distance=None,
+            get_distance_unit_display=lambda: 'Feet',
+        )
+        result = attr.render(obj, self._ctx(system='metric'))
+        self.assertIn('10', result)
+        self.assertIn('feet', result)
+
+
 class ObjectsTablePanelTestCase(TestCase):
     """
     Verify that ObjectsTablePanel.should_render() hides the panel when

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

@@ -11,6 +11,7 @@ __all__ = (
     'ChoiceAttr',
     'ColorAttr',
     'DateTimeAttr',
+    'DistanceAttr',
     'GPSCoordinatesAttr',
     'GenericForeignKeyAttr',
     'ImageAttr',
@@ -23,6 +24,7 @@ __all__ = (
     'TextAttr',
     'TimezoneAttr',
     'UtilizationAttr',
+    'WeightAttr',
 )
 
 PLACEHOLDER_HTML = '<span class="text-muted">&mdash;</span>'
@@ -561,3 +563,101 @@ class UtilizationAttr(ObjectAttribute):
     Renders the value of an attribute as a utilization graph.
     """
     template_name = 'ui/attrs/utilization.html'
+
+
+_IMPERIAL_WEIGHT = {'lb', 'oz'}
+_METRIC_WEIGHT = {'kg', 'g'}
+_IMPERIAL_DISTANCE = {'mi', 'ft'}
+_METRIC_DISTANCE = {'km', 'm'}
+
+
+class WeightAttr(ObjectAttribute):
+    """
+    A weight attribute that converts to the user's preferred measurement system.
+
+    Parameters:
+        unit_attr (str): Name of the field holding the weight unit (default: 'weight_unit')
+        abs_attr (str): Name of the field holding the absolute weight in grams (default: '_abs_weight')
+    """
+    template_name = 'ui/attrs/numeric.html'
+
+    def __init__(self, *args, unit_attr='weight_unit', abs_attr='_abs_weight', **kwargs):
+        super().__init__(*args, **kwargs)
+        self.unit_attr = unit_attr
+        self.abs_attr = abs_attr
+
+    def render(self, obj, context):
+        weight = resolve_attr_path(obj, self.accessor)
+        if weight is None:
+            return self.placeholder
+
+        system = (context.get('preferences') or {}).get('ui.measurement_system') or ''
+        unit = resolve_attr_path(obj, self.unit_attr)
+        abs_weight = resolve_attr_path(obj, self.abs_attr)
+
+        if system == 'metric' and unit in _IMPERIAL_WEIGHT and abs_weight:
+            display_value = round(abs_weight / 1000, 2)
+            display_unit = 'kg'
+        elif system == 'imperial' and unit in _METRIC_WEIGHT and abs_weight:
+            display_value = round(abs_weight / 453.592, 2)
+            display_unit = 'lbs'
+        else:
+            display_value = weight
+            display_unit = resolve_attr_path(obj, 'get_weight_unit_display')().lower()
+
+        return render_to_string(self.template_name, {
+            'name': context['name'],
+            'value': display_value,
+            'unit': display_unit,
+        })
+
+
+class DistanceAttr(ObjectAttribute):
+    """
+    A distance attribute that converts to the user's preferred measurement system.
+
+    Parameters:
+        unit_attr (str): Name of the field holding the distance unit (default: 'distance_unit')
+        abs_attr (str): Name of the field holding the absolute distance in meters (default: '_abs_distance')
+    """
+    template_name = 'ui/attrs/numeric.html'
+
+    def __init__(self, *args, unit_attr='distance_unit', abs_attr='_abs_distance', **kwargs):
+        super().__init__(*args, **kwargs)
+        self.unit_attr = unit_attr
+        self.abs_attr = abs_attr
+
+    def render(self, obj, context):
+        distance = resolve_attr_path(obj, self.accessor)
+        if distance is None:
+            return self.placeholder
+
+        system = (context.get('preferences') or {}).get('ui.measurement_system') or ''
+        unit = resolve_attr_path(obj, self.unit_attr)
+        abs_distance = resolve_attr_path(obj, self.abs_attr)
+
+        if system == 'metric' and unit in _IMPERIAL_DISTANCE and abs_distance is not None:
+            abs_m = float(abs_distance)
+            if abs_m >= 1000:
+                display_value = round(abs_m / 1000, 2)
+                display_unit = 'km'
+            else:
+                display_value = round(abs_m, 2)
+                display_unit = 'm'
+        elif system == 'imperial' and unit in _METRIC_DISTANCE and abs_distance is not None:
+            abs_m = float(abs_distance)
+            if abs_m >= 1609.344:
+                display_value = round(abs_m / 1609.344, 2)
+                display_unit = 'mi'
+            else:
+                display_value = round(abs_m / 0.3048, 2)
+                display_unit = 'ft'
+        else:
+            display_value = distance
+            display_unit = resolve_attr_path(obj, 'get_distance_unit_display')().lower()
+
+        return render_to_string(self.template_name, {
+            'name': context['name'],
+            'value': display_value,
+            'unit': display_unit,
+        })

+ 5 - 1
netbox/netbox/ui/panels.py

@@ -205,7 +205,11 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
             'attrs': [
                 {
                     'label': attr.label or self._name_to_label(name),
-                    'value': attr.render(ctx['object'], {'name': name, 'perms': ctx['perms']}),
+                    'value': attr.render(ctx['object'], {
+                        'name': name,
+                        'perms': ctx['perms'],
+                        'preferences': ctx.get('preferences', {}),
+                    }),
                 } for name, attr in self._attrs.items() if name in attr_names
             ],
         }

+ 8 - 0
netbox/templates/dcim/device/attrs/total_weight.html

@@ -1,3 +1,11 @@
 {% load helpers i18n %}
+{% with system=preferences|get_key:"ui.measurement_system" %}
+{% if system == "imperial" %}
+{{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %}
+{% elif system == "metric" %}
+{{ value|floatformat }} {% trans "Kilograms" %}
+{% else %}
 {{ value|floatformat }} {% trans "Kilograms" %}
 ({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %})
+{% endif %}
+{% endwith %}

+ 8 - 0
netbox/templates/dcim/rack/attrs/total_weight.html

@@ -1,3 +1,11 @@
 {% load helpers i18n %}
+{% with system=preferences|get_key:"ui.measurement_system" %}
+{% if system == "imperial" %}
+{{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %}
+{% elif system == "metric" %}
+{{ value|floatformat }} {% trans "Kilograms" %}
+{% else %}
 {{ value|floatformat }} {% trans "Kilograms" %}
 ({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %})
+{% endif %}
+{% endwith %}

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

@@ -332,6 +332,50 @@ def kg_to_pounds(n):
     return float(n) * 2.204623
 
 
+@register.simple_tag(takes_context=True)
+def display_weight(context, weight, weight_unit, abs_weight):
+    """
+    Render a weight value respecting the user's ui.measurement_system preference.
+    When the stored unit conflicts with the preferred system, converts via abs_weight (grams).
+    Falls back to the stored value/unit when no conversion is needed.
+    """
+    if not weight:
+        return ''
+    system = (context.get('preferences') or {}).get('ui.measurement_system') or ''
+    _IMPERIAL = {'lb', 'oz'}
+    _METRIC = {'kg', 'g'}
+    if system == 'metric' and weight_unit in _IMPERIAL and abs_weight:
+        return f'{round(abs_weight / 1000, 2):g} kg'
+    if system == 'imperial' and weight_unit in _METRIC and abs_weight:
+        return f'{round(abs_weight / 453.592, 2):g} lbs'
+    return f'{weight:g} {weight_unit}'
+
+
+@register.simple_tag(takes_context=True)
+def display_distance(context, distance, distance_unit, abs_distance):
+    """
+    Render a distance value respecting the user's ui.measurement_system preference.
+    When the stored unit conflicts with the preferred system, converts via abs_distance (meters).
+    Falls back to the stored value/unit when no conversion is needed.
+    """
+    if distance is None:
+        return ''
+    system = (context.get('preferences') or {}).get('ui.measurement_system') or ''
+    _IMPERIAL = {'mi', 'ft'}
+    _METRIC = {'km', 'm'}
+    if system == 'metric' and distance_unit in _IMPERIAL and abs_distance is not None:
+        abs_m = float(abs_distance)
+        if abs_m >= 1000:
+            return f'{round(abs_m / 1000, 2):g} km'
+        return f'{round(abs_m, 2):g} m'
+    if system == 'imperial' and distance_unit in _METRIC and abs_distance is not None:
+        abs_m = float(abs_distance)
+        if abs_m >= 1609.344:
+            return f'{round(abs_m / 1609.344, 2):g} mi'
+        return f'{round(abs_m / 0.3048, 2):g} ft'
+    return f'{distance:g} {distance_unit}'
+
+
 @register.filter("startswith")
 def startswith(text: str, starts: str) -> bool:
     """

+ 1 - 1
netbox/wireless/ui/panels.py

@@ -48,4 +48,4 @@ class WirelessLinkPropertiesPanel(panels.ObjectAttributesPanel):
     ssid = attrs.TextAttr('ssid', label=_('SSID'))
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     description = attrs.TextAttr('description')
-    distance = attrs.NumericAttr('distance', unit_accessor='get_distance_unit_display')
+    distance = attrs.DistanceAttr('distance')