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