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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
bctiemann 2026-05-22 12:16:10 -04:00 committed by GitHub
parent 19df80fe77
commit 659d6d1f85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 437 additions and 19 deletions

View File

@ -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')

View File

@ -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 = """

View File

@ -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):

View File

@ -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:

View File

@ -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'),

View File

@ -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):

View File

@ -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

View File

@ -12,6 +12,7 @@ __all__ = (
'ChoiceAttr',
'ColorAttr',
'DateTimeAttr',
'DistanceAttr',
'GPSCoordinatesAttr',
'GenericForeignKeyAttr',
'ImageAttr',
@ -24,6 +25,7 @@ __all__ = (
'TextAttr',
'TimezoneAttr',
'UtilizationAttr',
'WeightAttr',
)
PLACEHOLDER_HTML = '<span class="text-muted">&mdash;</span>'
@ -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,
})

View File

@ -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
],
}

View File

@ -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 %}

View File

@ -1,3 +0,0 @@
{% load helpers i18n %}
{{ value|floatformat }} {% trans "Kilograms" %}
({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %})

View File

@ -1,3 +0,0 @@
{% load helpers i18n %}
{{ value|floatformat }} {% trans "Kilograms" %}
({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %})

View File

@ -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')),
)

View File

@ -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:
"""

View File

@ -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')