diff --git a/netbox/circuits/ui/panels.py b/netbox/circuits/ui/panels.py index a874da3da..8111f61b0 100644 --- a/netbox/circuits/ui/panels.py +++ b/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') diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 2b786aec5..c8baf2942 100644 --- a/netbox/dcim/tables/template_code.py +++ b/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 = """ diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 29d0a1ed3..478ddf634 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -60,9 +60,9 @@ 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')) - total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/rack/attrs/total_weight.html') + weight = attrs.WeightAttr('weight') + max_weight = attrs.WeightAttr('max_weight', label=_('Maximum weight')) + total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/attrs/total_weight.html') class RackRolePanel(panels.OrganizationalObjectPanel): @@ -137,7 +137,7 @@ class DeviceDeviceTypePanel(panels.ObjectAttributesPanel): class DeviceDimensionsPanel(panels.ObjectAttributesPanel): title = _('Dimensions') - total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html') + total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/attrs/total_weight.html') class DeviceRolePanel(panels.NestedGroupObjectPanel): @@ -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): diff --git a/netbox/netbox/models/mixins.py b/netbox/netbox/models/mixins.py index ef1cd9e96..6f6f6b1a7 100644 --- a/netbox/netbox/models/mixins.py +++ b/netbox/netbox/models/mixins.py @@ -51,6 +51,11 @@ class WeightMixin(models.Model): class Meta: abstract = True + @property + def abs_weight(self): + # Public alias for _abs_weight; Django templates cannot access underscore-prefixed attributes. + return self._abs_weight + def save(self, *args, **kwargs): # Store the given weight (if any) in grams for use in database ordering @@ -95,6 +100,11 @@ class DistanceMixin(models.Model): class Meta: abstract = True + @property + def abs_distance(self): + # Public alias for _abs_distance; Django templates cannot access underscore-prefixed attributes. + 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: diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index ba8a33262..f61a31444 100644 --- a/netbox/netbox/preferences.py +++ b/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'), diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index c07121549..e0d16c664 100644 --- a/netbox/netbox/tables/columns.py +++ b/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): diff --git a/netbox/netbox/tests/test_ui.py b/netbox/netbox/tests/test_ui.py index a86277c5c..55cbb3842 100644 --- a/netbox/netbox/tests/test_ui.py +++ b/netbox/netbox/tests/test_ui.py @@ -1,7 +1,8 @@ from decimal import Decimal from types import SimpleNamespace -from django.test import RequestFactory, TestCase +from django.template import Context, Template +from django.test import RequestFactory, SimpleTestCase, TestCase from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices from circuits.models import ( @@ -535,6 +536,254 @@ class DateTimeAttrTestCase(TestCase): self.assertEqual(context['spec'], 'minutes') +class WeightAttrTestCase(SimpleTestCase): + + 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('kg', 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('kg', 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_converts_kg_to_singular_lb(self): + # 453.592 g = exactly 1.0 lb → singular 'lb' + attr = attrs.WeightAttr('weight') + obj = self._obj(1, 'kg', 453.592, 'Kilograms') + result = attr.render(obj, self._ctx(system='imperial')) + self.assertIn('1.0', result) + self.assertIn('lb', result) + self.assertNotIn('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('lbs', 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) + result = attr.render(obj, self._ctx(system='metric')) + self.assertIn('10', result) + self.assertIn('lbs', result) + + +class DistanceAttrTestCase(SimpleTestCase): + + 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('km', 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('km', 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('mi', 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) + result = attr.render(obj, self._ctx(system='metric')) + self.assertIn('10', result) + self.assertIn('ft', result) + + +class DisplayWeightTagTestCase(SimpleTestCase): + TEMPLATE = Template('{% load helpers %}{% display_weight weight weight_unit abs_weight %}') + + def _render(self, weight, weight_unit, abs_weight, system=''): + ctx = Context({ + 'preferences': {'ui.measurement_system': system}, + 'weight': weight, + 'weight_unit': weight_unit, + 'abs_weight': abs_weight, + }) + return self.TEMPLATE.render(ctx).strip() + + def test_none_weight_returns_empty(self): + self.assertEqual(self._render(None, 'kg', None), '') + + def test_zero_weight_is_not_suppressed(self): + self.assertEqual(self._render(0, 'kg', 0), '0 kg') + + def test_inherit_stores_kg(self): + self.assertEqual(self._render(5, 'kg', 5000), '5 kg') + + def test_inherit_stores_lb_plural(self): + self.assertEqual(self._render(10, 'lb', 4535.92), '10 lbs') + + def test_inherit_stores_lb_singular(self): + self.assertEqual(self._render(1, 'lb', 453.592), '1 lb') + + def test_metric_converts_lb_to_kg(self): + # 10 lb = 4535.92 g → round(4535.92/1000, 2) = 4.54 kg + result = self._render(10, 'lb', 4535.92, system='metric') + self.assertEqual(result, '4.54 kg') + + def test_imperial_converts_kg_to_lbs(self): + # 1 kg = 1000 g → round(1000/453.592, 2) = 2.2 lbs + result = self._render(1, 'kg', 1000, system='imperial') + self.assertEqual(result, '2.2 lbs') + + def test_imperial_converts_kg_to_singular_lb(self): + # 453.592 g = 1.0 lb → singular + result = self._render(1, 'kg', 453.592, system='imperial') + self.assertEqual(result, '1 lb') + + def test_metric_no_conversion_for_metric_unit(self): + result = self._render(5, 'kg', 5000, system='metric') + self.assertEqual(result, '5 kg') + + def test_imperial_no_conversion_for_imperial_unit(self): + result = self._render(10, 'lb', 4535.92, system='imperial') + self.assertEqual(result, '10 lbs') + + +class DisplayDistanceTagTestCase(SimpleTestCase): + TEMPLATE = Template('{% load helpers %}{% display_distance distance distance_unit abs_distance %}') + + def _render(self, distance, distance_unit, abs_distance, system=''): + ctx = Context({ + 'preferences': {'ui.measurement_system': system}, + 'distance': distance, + 'distance_unit': distance_unit, + 'abs_distance': abs_distance, + }) + return self.TEMPLATE.render(ctx).strip() + + def test_none_distance_returns_empty(self): + self.assertEqual(self._render(None, 'km', None), '') + + def test_inherit_stores_km(self): + self.assertEqual(self._render(10, 'km', 10000), '10 km') + + def test_metric_converts_ft_to_m_under_threshold(self): + # 500 ft = 152.4 m (< 1000) + self.assertEqual(self._render(500, 'ft', 152.4, system='metric'), '152.4 m') + + def test_metric_converts_mi_to_km_over_threshold(self): + # 5 mi = 8046.72 m (>= 1000) → 8.05 km + self.assertEqual(self._render(5, 'mi', 8046.72, system='metric'), '8.05 km') + + def test_imperial_converts_m_to_ft_under_threshold(self): + # 500 m (< 1609.344) → 500/0.3048 = 1640.42 ft + self.assertEqual(self._render(500, 'm', 500, system='imperial'), '1640.42 ft') + + def test_imperial_converts_km_to_mi_over_threshold(self): + # 10 km = 10000 m (>= 1609.344) → 6.21 mi + self.assertEqual(self._render(10, 'km', 10000, system='imperial'), '6.21 mi') + + def test_metric_no_conversion_for_metric_unit(self): + self.assertEqual(self._render(10, 'km', 10000, system='metric'), '10 km') + + def test_imperial_no_conversion_for_imperial_unit(self): + self.assertEqual(self._render(10, 'mi', 16093.44, system='imperial'), '10 mi') + + class ObjectsTablePanelTestCase(TestCase): """ Verify that ObjectsTablePanel.should_render() hides the panel when diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 95bf0a8a4..8734fb4a1 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -12,6 +12,7 @@ __all__ = ( 'ChoiceAttr', 'ColorAttr', 'DateTimeAttr', + 'DistanceAttr', 'GPSCoordinatesAttr', 'GenericForeignKeyAttr', 'ImageAttr', @@ -24,6 +25,7 @@ __all__ = ( 'TextAttr', 'TimezoneAttr', 'UtilizationAttr', + 'WeightAttr', ) PLACEHOLDER_HTML = '' @@ -569,3 +571,110 @@ 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'} + + +def compute_weight_display(weight, weight_unit, abs_weight, system): + """ + Return (display_value, display_unit) for a weight, respecting the user's measurement system. + abs_weight is in grams (from WeightMixin._abs_weight). + oz and g pass through unchanged since there is no cross-system equivalent. + """ + if system == 'metric' and weight_unit in IMPERIAL_WEIGHT and abs_weight is not None: + return round(abs_weight / 1000, 2), 'kg' + if system == 'imperial' and weight_unit in METRIC_WEIGHT and abs_weight is not None: + lbs = round(abs_weight / 453.592, 2) + return lbs, 'lb' if lbs == 1 else 'lbs' + if weight_unit == 'lb': + return weight, 'lb' if weight == 1 else 'lbs' + return weight, weight_unit + + +def compute_distance_display(distance, distance_unit, abs_distance, system): + """ + Return (display_value, display_unit) for a distance, respecting the user's measurement system. + abs_distance is in metres (from DistanceMixin._abs_distance). + Distances < 1 km are shown in metres; < 1 mi are shown in feet. + """ + if system == 'metric' and distance_unit in IMPERIAL_DISTANCE and abs_distance is not None: + abs_m = float(abs_distance) + if abs_m >= 1000: + return round(abs_m / 1000, 2), 'km' + return round(abs_m, 2), 'm' + if system == 'imperial' and distance_unit in METRIC_DISTANCE and abs_distance is not None: + abs_m = float(abs_distance) + if abs_m >= 1609.344: + return round(abs_m / 1609.344, 2), 'mi' + return round(abs_m / 0.3048, 2), 'ft' + return distance, distance_unit + + +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): The internal _abs_weight field name on WeightMixin (stored in grams). + Accessed via Python — not subject to Django's template underscore restriction. + """ + 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) + display_value, display_unit = compute_weight_display(weight, unit, abs_weight, system) + + 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): The internal _abs_distance field name on DistanceMixin (stored in metres). + Accessed via Python — not subject to Django's template underscore restriction. + """ + 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) + display_value, display_unit = compute_distance_display(distance, unit, abs_distance, system) + + return render_to_string(self.template_name, { + 'name': context['name'], + 'value': display_value, + 'unit': display_unit, + }) diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index dadcd285a..06738a2b6 100644 --- a/netbox/netbox/ui/panels.py +++ b/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': context.get('preferences', {}), + }), } for name, attr in self._attrs.items() if name in attr_names ], } diff --git a/netbox/templates/dcim/attrs/total_weight.html b/netbox/templates/dcim/attrs/total_weight.html new file mode 100644 index 000000000..91ebb8da2 --- /dev/null +++ b/netbox/templates/dcim/attrs/total_weight.html @@ -0,0 +1,10 @@ +{% load helpers i18n %} +{% with system=preferences|get_key:"ui.measurement_system" %} +{% if system == "imperial" %} +{% with lbs=value|kg_to_pounds|floatformat %}{{ lbs }} {% trans "Pound" %}{{ lbs|pluralize }}{% endwith %} +{% elif system == "metric" %} +{% with formatted=value|floatformat %}{{ formatted }} {% trans "Kilogram" %}{{ formatted|pluralize }}{% endwith %} +{% else %} +{% with formatted=value|floatformat %}{% with lbs=value|kg_to_pounds|floatformat %}{{ formatted }} {% trans "Kilogram" %}{{ formatted|pluralize }} ({{ lbs }} {% trans "Pound" %}{{ lbs|pluralize }}){% endwith %}{% endwith %} +{% endif %} +{% endwith %} \ No newline at end of file diff --git a/netbox/templates/dcim/device/attrs/total_weight.html b/netbox/templates/dcim/device/attrs/total_weight.html deleted file mode 100644 index 73ac54ef5..000000000 --- a/netbox/templates/dcim/device/attrs/total_weight.html +++ /dev/null @@ -1,3 +0,0 @@ -{% load helpers i18n %} -{{ value|floatformat }} {% trans "Kilograms" %} -({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %}) diff --git a/netbox/templates/dcim/rack/attrs/total_weight.html b/netbox/templates/dcim/rack/attrs/total_weight.html deleted file mode 100644 index 73ac54ef5..000000000 --- a/netbox/templates/dcim/rack/attrs/total_weight.html +++ /dev/null @@ -1,3 +0,0 @@ -{% load helpers i18n %} -{{ value|floatformat }} {% trans "Kilograms" %} -({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %}) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index c5ee10ae4..abf492ae4 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -72,7 +72,7 @@ class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass): fieldsets = ( FieldSet( 'locale.language', 'ui.copilot_enabled', 'pagination.per_page', 'pagination.placement', - 'ui.tables.striping', name=_('User Interface') + 'ui.tables.striping', 'ui.measurement_system', name=_('User Interface') ), FieldSet('data_format', 'csv_delimiter', name=_('Miscellaneous')), ) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 5fc934b89..674798499 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -10,6 +10,10 @@ from django.utils.translation import gettext_lazy as _ from core.models import ObjectType from netbox.settings import DISK_BASE_UNIT, RAM_BASE_UNIT +from netbox.ui.attrs import ( + compute_distance_display, + compute_weight_display, +) from utilities.forms import TableConfigForm, get_selected_values from utilities.forms.mixins import FORM_FIELD_LOOKUPS from utilities.views import get_action_url, get_viewname @@ -18,6 +22,8 @@ __all__ = ( 'action_url', 'applied_filters', 'as_range', + 'display_distance', + 'display_weight', 'divide', 'get_item', 'get_key', @@ -332,6 +338,30 @@ 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. + """ + if weight is None: + return '' + system = (context.get('preferences') or {}).get('ui.measurement_system') or '' + value, unit = compute_weight_display(weight, weight_unit, abs_weight, system) + return f'{value:g} {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. + """ + if distance is None: + return '' + system = (context.get('preferences') or {}).get('ui.measurement_system') or '' + value, unit = compute_distance_display(distance, distance_unit, abs_distance, system) + return f'{value:g} {unit}' + + @register.filter("startswith") def startswith(text: str, starts: str) -> bool: """ diff --git a/netbox/wireless/ui/panels.py b/netbox/wireless/ui/panels.py index df440724a..385a1f853 100644 --- a/netbox/wireless/ui/panels.py +++ b/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')