From b94136c121fa768fba221a9133aa23cda75047ad Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Apr 2026 13:57:29 -0400 Subject: [PATCH] Clean up object attrs --- netbox/dcim/ui/panels.py | 14 ++-- netbox/ipam/ui/attrs.py | 21 +++-- netbox/netbox/tests/test_ui.py | 8 +- netbox/netbox/ui/attrs.py | 103 ++++++++++++++----------- netbox/templates/ui/attrs/numeric.html | 2 +- 5 files changed, 84 insertions(+), 64 deletions(-) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 4249b98fb..a06a4b6b5 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -74,7 +74,7 @@ class RackReservationPanel(panels.ObjectAttributesPanel): unit_count = attrs.TextAttr('unit_count', label=_("Total U's")) status = attrs.ChoiceAttr('status') tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') - user = attrs.RelatedObjectAttr('user') + user = attrs.RelatedObjectAttr('user', linkify=True) description = attrs.TextAttr('description') @@ -219,8 +219,8 @@ class PowerPortPanel(panels.ObjectAttributesPanel): label = attrs.TextAttr('label') type = attrs.ChoiceAttr('type') description = attrs.TextAttr('description') - maximum_draw = attrs.TextAttr('maximum_draw') - allocated_draw = attrs.TextAttr('allocated_draw') + maximum_draw = attrs.TextAttr('maximum_draw', format_string='{}W') + allocated_draw = attrs.TextAttr('allocated_draw', format_string='{}W') class PowerOutletPanel(panels.ObjectAttributesPanel): @@ -243,7 +243,7 @@ class FrontPortPanel(panels.ObjectAttributesPanel): label = attrs.TextAttr('label') type = attrs.ChoiceAttr('type') color = attrs.ColorAttr('color') - positions = attrs.TextAttr('positions') + positions = attrs.NumericAttr('positions') description = attrs.TextAttr('description') @@ -254,7 +254,7 @@ class RearPortPanel(panels.ObjectAttributesPanel): label = attrs.TextAttr('label') type = attrs.ChoiceAttr('type') color = attrs.ColorAttr('color') - positions = attrs.TextAttr('positions') + positions = attrs.NumericAttr('positions') description = attrs.TextAttr('description') @@ -472,7 +472,7 @@ class InterfacePanel(panels.ObjectAttributesPanel): type = attrs.ChoiceAttr('type') speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed')) duplex = attrs.ChoiceAttr('duplex') - mtu = attrs.TextAttr('mtu', label=_('MTU')) + mtu = attrs.NumericAttr('mtu', label=_('MTU')) enabled = attrs.BooleanAttr('enabled') mgmt_only = attrs.BooleanAttr('mgmt_only', label=_('Management only')) description = attrs.TextAttr('description') @@ -481,7 +481,7 @@ class InterfacePanel(panels.ObjectAttributesPanel): mode = attrs.ChoiceAttr('mode', label=_('802.1Q mode')) qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN')) untagged_vlan = attrs.RelatedObjectAttr('untagged_vlan', linkify=True, label=_('Untagged VLAN')) - tx_power = attrs.TextAttr('tx_power', label=_('Transmit power (dBm)')) + tx_power = attrs.TextAttr('tx_power', label=_('Transmit power'), format_string='{} dBm') tunnel = attrs.RelatedObjectAttr('tunnel_termination.tunnel', linkify=True, label=_('Tunnel')) l2vpn = attrs.RelatedObjectAttr('l2vpn_termination.l2vpn', linkify=True, label=_('L2VPN')) diff --git a/netbox/ipam/ui/attrs.py b/netbox/ipam/ui/attrs.py index cd5cba0f2..aab23648b 100644 --- a/netbox/ipam/ui/attrs.py +++ b/netbox/ipam/ui/attrs.py @@ -7,6 +7,9 @@ class VRFDisplayAttr(attrs.ObjectAttribute): """ Renders a VRF reference, displaying 'Global' when no VRF is assigned. Optionally includes the route distinguisher (RD). + + Parameters: + show_rd (bool): If true, the VRF's RD will be included. (Default: False) """ template_name = 'ipam/attrs/vrf.html' @@ -14,11 +17,17 @@ class VRFDisplayAttr(attrs.ObjectAttribute): super().__init__(*args, **kwargs) self.show_rd = show_rd - def render(self, obj, context): - value = self.get_value(obj) - return render_to_string(self.template_name, { - **self.get_context(obj, context), - 'name': context['name'], - 'value': value, + def get_context(self, obj, attr, value, context): + return { 'show_rd': self.show_rd, + } + + def render(self, obj, context): + name = context['name'] + value = self.get_value(obj) + + return render_to_string(self.template_name, { + **self.get_context(obj, name, value, context), + 'name': name, + 'value': value, }) diff --git a/netbox/netbox/tests/test_ui.py b/netbox/netbox/tests/test_ui.py index cb76517c1..b221f0e78 100644 --- a/netbox/netbox/tests/test_ui.py +++ b/netbox/netbox/tests/test_ui.py @@ -76,7 +76,7 @@ class ChoiceAttrTest(TestCase): self.termination.get_role_display(), ) self.assertEqual( - attr.get_context(self.termination, {}), + attr.get_context(self.termination, 'role', attr.get_value(self.termination), {}), {'bg_color': self.termination.get_role_color()}, ) @@ -88,7 +88,7 @@ class ChoiceAttrTest(TestCase): self.termination.interface.get_type_display(), ) self.assertEqual( - attr.get_context(self.termination, {}), + attr.get_context(self.termination, 'interface.type', attr.get_value(self.termination), {}), {'bg_color': None}, ) @@ -100,7 +100,9 @@ class ChoiceAttrTest(TestCase): self.termination.virtual_circuit.get_status_display(), ) self.assertEqual( - attr.get_context(self.termination, {}), + attr.get_context( + self.termination, 'virtual_circuit.status', attr.get_value(self.termination), {} + ), {'bg_color': self.termination.virtual_circuit.get_status_color()}, ) diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index ac798ba5c..3930bafcb 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -29,11 +29,27 @@ PLACEHOLDER_HTML = '' IMAGE_DECODING_CHOICES = ('auto', 'async', 'sync') + +# +# Mixins +# + +class MapURLMixin: + _map_url = None + + @property + def map_url(self): + if self._map_url is True: + return get_config().MAPS_URL + if self._map_url: + return self._map_url + return None + + # # Attributes # - class ObjectAttribute: """ Base class for representing an attribute of an object. @@ -64,17 +80,20 @@ class ObjectAttribute: """ return resolve_attr_path(obj, self.accessor) - def get_context(self, obj, context): + def get_context(self, obj, attr, value, context): """ Return any additional template context used to render the attribute value. Parameters: obj (object): The object for which the attribute is being rendered + attr (str): The name of the attribute being rendered + value: The value of the attribute on the object context (dict): The root template context """ return {} def render(self, obj, context): + name = context['name'] value = self.get_value(obj) # If the value is empty, render a placeholder @@ -82,8 +101,8 @@ class ObjectAttribute: return self.placeholder return render_to_string(self.template_name, { - **self.get_context(obj, context), - 'name': context['name'], + **self.get_context(obj, name, value, context), + 'name': name, 'value': value, }) @@ -112,7 +131,7 @@ class TextAttr(ObjectAttribute): return self.format_string.format(value) return value - def get_context(self, obj, context): + def get_context(self, obj, attr, value, context): return { 'style': self.style, 'copy_button': self.copy_button, @@ -134,7 +153,7 @@ class NumericAttr(ObjectAttribute): self.unit_accessor = unit_accessor self.copy_button = copy_button - def get_context(self, obj, context): + def get_context(self, obj, attr, value, context): unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None return { 'unit': unit, @@ -172,7 +191,7 @@ class ChoiceAttr(ObjectAttribute): return resolve_attr_path(target, field_name) - def get_context(self, obj, context): + def get_context(self, obj, attr, value, context): target, field_name = self._resolve_target(obj) if target is None: return {'bg_color': None} @@ -241,7 +260,7 @@ class ImageAttr(ObjectAttribute): decoding = 'async' self.decoding = decoding - def get_context(self, obj, context): + def get_context(self, obj, attr, value, context): return { 'decoding': self.decoding, 'load_lazy': self.load_lazy, @@ -264,8 +283,7 @@ class RelatedObjectAttr(ObjectAttribute): self.linkify = linkify self.grouped_by = grouped_by - def get_context(self, obj, context): - value = self.get_value(obj) + def get_context(self, obj, attr, value, context): group = getattr(value, self.grouped_by, None) if self.grouped_by else None return { 'linkify': self.linkify, @@ -300,14 +318,13 @@ class RelatedObjectListAttr(RelatedObjectAttr): self.max_items = max_items self.overflow_indicator = overflow_indicator - def _get_items(self, obj): + def _get_items(self, items): """ Retrieve items from the given object using the accessor path. Returns a tuple of (items, has_more) where items is a list of resolved objects and has_more indicates whether additional items exist beyond the max_items limit. """ - items = resolve_attr_path(obj, self.accessor) if items is None: return [], False @@ -322,8 +339,8 @@ class RelatedObjectListAttr(RelatedObjectAttr): return items[:self.max_items], has_more - def get_context(self, obj, context): - items, has_more = self._get_items(obj) + def get_context(self, obj, attr, value, context): + items, has_more = self._get_items(value) return { 'linkify': self.linkify, @@ -338,14 +355,15 @@ class RelatedObjectListAttr(RelatedObjectAttr): } def render(self, obj, context): - context = context or {} - context_data = self.get_context(obj, context) + name = context['name'] + value = self.get_value(obj) + context_data = self.get_context(obj, name, value, context) if not context_data['items']: return self.placeholder return render_to_string(self.template_name, { - 'name': context.get('name'), + 'name': name, **context_data, }) @@ -366,11 +384,13 @@ class NestedObjectAttr(ObjectAttribute): self.linkify = linkify self.max_depth = max_depth - def get_context(self, obj, context): - value = self.get_value(obj) - nodes = value.get_ancestors(include_self=True) - if self.max_depth: - nodes = list(nodes)[-self.max_depth:] + def get_context(self, obj, attr, value, context): + if value is not None: + nodes = value.get_ancestors(include_self=True) + if self.max_depth: + nodes = list(nodes)[-self.max_depth:] + else: + nodes = [] return { 'nodes': nodes, 'linkify': self.linkify, @@ -394,40 +414,35 @@ class GenericForeignKeyAttr(ObjectAttribute): super().__init__(*args, **kwargs) self.linkify = linkify - def get_context(self, obj, context): - value = self.get_value(obj) - content_type = value._meta.verbose_name + def get_context(self, obj, attr, value, context): + content_type = value._meta.verbose_name if value is not None else None return { 'content_type': content_type, 'linkify': self.linkify, } -class AddressAttr(ObjectAttribute): +class AddressAttr(MapURLMixin, ObjectAttribute): """ A physical or mailing address. Parameters: - map_url (bool): If true, the address will render as a hyperlink using settings.MAPS_URL + map_url (bool/str): The URL to use when rendering the address. If True, the address will render as a + hyperlink using settings.MAPS_URL. """ template_name = 'ui/attrs/address.html' def __init__(self, *args, map_url=True, **kwargs): super().__init__(*args, **kwargs) - if map_url is True: - self.map_url = get_config().MAPS_URL - elif map_url: - self.map_url = map_url - else: - self.map_url = None + self._map_url = map_url - def get_context(self, obj, context): + def get_context(self, obj, attr, value, context): return { 'map_url': self.map_url, } -class GPSCoordinatesAttr(ObjectAttribute): +class GPSCoordinatesAttr(MapURLMixin, ObjectAttribute): """ A GPS coordinates pair comprising latitude and longitude values. @@ -440,24 +455,18 @@ class GPSCoordinatesAttr(ObjectAttribute): label = _('GPS coordinates') def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs): - super().__init__(accessor=None, **kwargs) + super().__init__(accessor=latitude_attr, **kwargs) self.latitude_attr = latitude_attr self.longitude_attr = longitude_attr - if map_url is True: - self.map_url = get_config().MAPS_URL - elif map_url: - self.map_url = map_url - else: - self.map_url = None + self._map_url = map_url - def render(self, obj, context=None): - context = context or {} + def render(self, obj, context): latitude = resolve_attr_path(obj, self.latitude_attr) longitude = resolve_attr_path(obj, self.longitude_attr) if latitude is None or longitude is None: return self.placeholder return render_to_string(self.template_name, { - **context, + 'name': context['name'], 'latitude': latitude, 'longitude': longitude, 'map_url': self.map_url, @@ -478,7 +487,7 @@ class DateTimeAttr(ObjectAttribute): super().__init__(*args, **kwargs) self.spec = spec - def get_context(self, obj, context): + def get_context(self, obj, attr, value, context): return { 'spec': self.spec, } @@ -504,7 +513,7 @@ class TemplatedAttr(ObjectAttribute): self.template_name = template_name self.context = context or {} - def get_context(self, obj, context): + def get_context(self, obj, attr, value, context): return { **context, **self.context, diff --git a/netbox/templates/ui/attrs/numeric.html b/netbox/templates/ui/attrs/numeric.html index 5c54f2979..455a66c22 100644 --- a/netbox/templates/ui/attrs/numeric.html +++ b/netbox/templates/ui/attrs/numeric.html @@ -1,5 +1,5 @@ {% load i18n %} - + {{ value }} {% if unit %} {{ unit|lower }}