Clean up object attrs

This commit is contained in:
Jeremy Stretch
2026-04-02 13:57:29 -04:00
parent 5b4c8d47be
commit b94136c121
5 changed files with 84 additions and 64 deletions

View File

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

View File

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

View File

@@ -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()},
)

View File

@@ -29,11 +29,27 @@ PLACEHOLDER_HTML = '<span class="text-muted">&mdash;</span>'
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,

View File

@@ -1,5 +1,5 @@
{% load i18n %}
<span{% if style %} class="{{ style }}"{% endif %}>
<span>
<span{% if name %} id="attr_{{ name }}"{% endif %}>{{ value }}</span>
{% if unit %}
{{ unit|lower }}