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")) unit_count = attrs.TextAttr('unit_count', label=_("Total U's"))
status = attrs.ChoiceAttr('status') status = attrs.ChoiceAttr('status')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
user = attrs.RelatedObjectAttr('user') user = attrs.RelatedObjectAttr('user', linkify=True)
description = attrs.TextAttr('description') description = attrs.TextAttr('description')
@@ -219,8 +219,8 @@ class PowerPortPanel(panels.ObjectAttributesPanel):
label = attrs.TextAttr('label') label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type') type = attrs.ChoiceAttr('type')
description = attrs.TextAttr('description') description = attrs.TextAttr('description')
maximum_draw = attrs.TextAttr('maximum_draw') maximum_draw = attrs.TextAttr('maximum_draw', format_string='{}W')
allocated_draw = attrs.TextAttr('allocated_draw') allocated_draw = attrs.TextAttr('allocated_draw', format_string='{}W')
class PowerOutletPanel(panels.ObjectAttributesPanel): class PowerOutletPanel(panels.ObjectAttributesPanel):
@@ -243,7 +243,7 @@ class FrontPortPanel(panels.ObjectAttributesPanel):
label = attrs.TextAttr('label') label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type') type = attrs.ChoiceAttr('type')
color = attrs.ColorAttr('color') color = attrs.ColorAttr('color')
positions = attrs.TextAttr('positions') positions = attrs.NumericAttr('positions')
description = attrs.TextAttr('description') description = attrs.TextAttr('description')
@@ -254,7 +254,7 @@ class RearPortPanel(panels.ObjectAttributesPanel):
label = attrs.TextAttr('label') label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type') type = attrs.ChoiceAttr('type')
color = attrs.ColorAttr('color') color = attrs.ColorAttr('color')
positions = attrs.TextAttr('positions') positions = attrs.NumericAttr('positions')
description = attrs.TextAttr('description') description = attrs.TextAttr('description')
@@ -472,7 +472,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
type = attrs.ChoiceAttr('type') type = attrs.ChoiceAttr('type')
speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed')) speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
duplex = attrs.ChoiceAttr('duplex') duplex = attrs.ChoiceAttr('duplex')
mtu = attrs.TextAttr('mtu', label=_('MTU')) mtu = attrs.NumericAttr('mtu', label=_('MTU'))
enabled = attrs.BooleanAttr('enabled') enabled = attrs.BooleanAttr('enabled')
mgmt_only = attrs.BooleanAttr('mgmt_only', label=_('Management only')) mgmt_only = attrs.BooleanAttr('mgmt_only', label=_('Management only'))
description = attrs.TextAttr('description') description = attrs.TextAttr('description')
@@ -481,7 +481,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
mode = attrs.ChoiceAttr('mode', label=_('802.1Q mode')) mode = attrs.ChoiceAttr('mode', label=_('802.1Q mode'))
qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN')) qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
untagged_vlan = attrs.RelatedObjectAttr('untagged_vlan', linkify=True, label=_('Untagged VLAN')) 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')) tunnel = attrs.RelatedObjectAttr('tunnel_termination.tunnel', linkify=True, label=_('Tunnel'))
l2vpn = attrs.RelatedObjectAttr('l2vpn_termination.l2vpn', linkify=True, label=_('L2VPN')) 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 Renders a VRF reference, displaying 'Global' when no VRF is assigned. Optionally includes
the route distinguisher (RD). 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' template_name = 'ipam/attrs/vrf.html'
@@ -14,11 +17,17 @@ class VRFDisplayAttr(attrs.ObjectAttribute):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.show_rd = show_rd self.show_rd = show_rd
def render(self, obj, context): def get_context(self, obj, attr, value, context):
value = self.get_value(obj) return {
return render_to_string(self.template_name, {
**self.get_context(obj, context),
'name': context['name'],
'value': value,
'show_rd': self.show_rd, '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.termination.get_role_display(),
) )
self.assertEqual( 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()}, {'bg_color': self.termination.get_role_color()},
) )
@@ -88,7 +88,7 @@ class ChoiceAttrTest(TestCase):
self.termination.interface.get_type_display(), self.termination.interface.get_type_display(),
) )
self.assertEqual( self.assertEqual(
attr.get_context(self.termination, {}), attr.get_context(self.termination, 'interface.type', attr.get_value(self.termination), {}),
{'bg_color': None}, {'bg_color': None},
) )
@@ -100,7 +100,9 @@ class ChoiceAttrTest(TestCase):
self.termination.virtual_circuit.get_status_display(), self.termination.virtual_circuit.get_status_display(),
) )
self.assertEqual( 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()}, {'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') 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 # Attributes
# #
class ObjectAttribute: class ObjectAttribute:
""" """
Base class for representing an attribute of an object. Base class for representing an attribute of an object.
@@ -64,17 +80,20 @@ class ObjectAttribute:
""" """
return resolve_attr_path(obj, self.accessor) 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. Return any additional template context used to render the attribute value.
Parameters: Parameters:
obj (object): The object for which the attribute is being rendered 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 context (dict): The root template context
""" """
return {} return {}
def render(self, obj, context): def render(self, obj, context):
name = context['name']
value = self.get_value(obj) value = self.get_value(obj)
# If the value is empty, render a placeholder # If the value is empty, render a placeholder
@@ -82,8 +101,8 @@ class ObjectAttribute:
return self.placeholder return self.placeholder
return render_to_string(self.template_name, { return render_to_string(self.template_name, {
**self.get_context(obj, context), **self.get_context(obj, name, value, context),
'name': context['name'], 'name': name,
'value': value, 'value': value,
}) })
@@ -112,7 +131,7 @@ class TextAttr(ObjectAttribute):
return self.format_string.format(value) return self.format_string.format(value)
return value return value
def get_context(self, obj, context): def get_context(self, obj, attr, value, context):
return { return {
'style': self.style, 'style': self.style,
'copy_button': self.copy_button, 'copy_button': self.copy_button,
@@ -134,7 +153,7 @@ class NumericAttr(ObjectAttribute):
self.unit_accessor = unit_accessor self.unit_accessor = unit_accessor
self.copy_button = copy_button 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 unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None
return { return {
'unit': unit, 'unit': unit,
@@ -172,7 +191,7 @@ class ChoiceAttr(ObjectAttribute):
return resolve_attr_path(target, field_name) 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) target, field_name = self._resolve_target(obj)
if target is None: if target is None:
return {'bg_color': None} return {'bg_color': None}
@@ -241,7 +260,7 @@ class ImageAttr(ObjectAttribute):
decoding = 'async' decoding = 'async'
self.decoding = decoding self.decoding = decoding
def get_context(self, obj, context): def get_context(self, obj, attr, value, context):
return { return {
'decoding': self.decoding, 'decoding': self.decoding,
'load_lazy': self.load_lazy, 'load_lazy': self.load_lazy,
@@ -264,8 +283,7 @@ class RelatedObjectAttr(ObjectAttribute):
self.linkify = linkify self.linkify = linkify
self.grouped_by = grouped_by self.grouped_by = grouped_by
def get_context(self, obj, context): def get_context(self, obj, attr, value, context):
value = self.get_value(obj)
group = getattr(value, self.grouped_by, None) if self.grouped_by else None group = getattr(value, self.grouped_by, None) if self.grouped_by else None
return { return {
'linkify': self.linkify, 'linkify': self.linkify,
@@ -300,14 +318,13 @@ class RelatedObjectListAttr(RelatedObjectAttr):
self.max_items = max_items self.max_items = max_items
self.overflow_indicator = overflow_indicator 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. 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 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. and has_more indicates whether additional items exist beyond the max_items limit.
""" """
items = resolve_attr_path(obj, self.accessor)
if items is None: if items is None:
return [], False return [], False
@@ -322,8 +339,8 @@ class RelatedObjectListAttr(RelatedObjectAttr):
return items[:self.max_items], has_more return items[:self.max_items], has_more
def get_context(self, obj, context): def get_context(self, obj, attr, value, context):
items, has_more = self._get_items(obj) items, has_more = self._get_items(value)
return { return {
'linkify': self.linkify, 'linkify': self.linkify,
@@ -338,14 +355,15 @@ class RelatedObjectListAttr(RelatedObjectAttr):
} }
def render(self, obj, context): def render(self, obj, context):
context = context or {} name = context['name']
context_data = self.get_context(obj, context) value = self.get_value(obj)
context_data = self.get_context(obj, name, value, context)
if not context_data['items']: if not context_data['items']:
return self.placeholder return self.placeholder
return render_to_string(self.template_name, { return render_to_string(self.template_name, {
'name': context.get('name'), 'name': name,
**context_data, **context_data,
}) })
@@ -366,11 +384,13 @@ class NestedObjectAttr(ObjectAttribute):
self.linkify = linkify self.linkify = linkify
self.max_depth = max_depth self.max_depth = max_depth
def get_context(self, obj, context): def get_context(self, obj, attr, value, context):
value = self.get_value(obj) if value is not None:
nodes = value.get_ancestors(include_self=True) nodes = value.get_ancestors(include_self=True)
if self.max_depth: if self.max_depth:
nodes = list(nodes)[-self.max_depth:] nodes = list(nodes)[-self.max_depth:]
else:
nodes = []
return { return {
'nodes': nodes, 'nodes': nodes,
'linkify': self.linkify, 'linkify': self.linkify,
@@ -394,40 +414,35 @@ class GenericForeignKeyAttr(ObjectAttribute):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.linkify = linkify self.linkify = linkify
def get_context(self, obj, context): def get_context(self, obj, attr, value, context):
value = self.get_value(obj) content_type = value._meta.verbose_name if value is not None else None
content_type = value._meta.verbose_name
return { return {
'content_type': content_type, 'content_type': content_type,
'linkify': self.linkify, 'linkify': self.linkify,
} }
class AddressAttr(ObjectAttribute): class AddressAttr(MapURLMixin, ObjectAttribute):
""" """
A physical or mailing address. A physical or mailing address.
Parameters: 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' template_name = 'ui/attrs/address.html'
def __init__(self, *args, map_url=True, **kwargs): def __init__(self, *args, map_url=True, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if map_url is True: self._map_url = map_url
self.map_url = get_config().MAPS_URL
elif map_url:
self.map_url = map_url
else:
self.map_url = None
def get_context(self, obj, context): def get_context(self, obj, attr, value, context):
return { return {
'map_url': self.map_url, 'map_url': self.map_url,
} }
class GPSCoordinatesAttr(ObjectAttribute): class GPSCoordinatesAttr(MapURLMixin, ObjectAttribute):
""" """
A GPS coordinates pair comprising latitude and longitude values. A GPS coordinates pair comprising latitude and longitude values.
@@ -440,24 +455,18 @@ class GPSCoordinatesAttr(ObjectAttribute):
label = _('GPS coordinates') label = _('GPS coordinates')
def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs): 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.latitude_attr = latitude_attr
self.longitude_attr = longitude_attr self.longitude_attr = longitude_attr
if map_url is True: self._map_url = map_url
self.map_url = get_config().MAPS_URL
elif map_url:
self.map_url = map_url
else:
self.map_url = None
def render(self, obj, context=None): def render(self, obj, context):
context = context or {}
latitude = resolve_attr_path(obj, self.latitude_attr) latitude = resolve_attr_path(obj, self.latitude_attr)
longitude = resolve_attr_path(obj, self.longitude_attr) longitude = resolve_attr_path(obj, self.longitude_attr)
if latitude is None or longitude is None: if latitude is None or longitude is None:
return self.placeholder return self.placeholder
return render_to_string(self.template_name, { return render_to_string(self.template_name, {
**context, 'name': context['name'],
'latitude': latitude, 'latitude': latitude,
'longitude': longitude, 'longitude': longitude,
'map_url': self.map_url, 'map_url': self.map_url,
@@ -478,7 +487,7 @@ class DateTimeAttr(ObjectAttribute):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.spec = spec self.spec = spec
def get_context(self, obj, context): def get_context(self, obj, attr, value, context):
return { return {
'spec': self.spec, 'spec': self.spec,
} }
@@ -504,7 +513,7 @@ class TemplatedAttr(ObjectAttribute):
self.template_name = template_name self.template_name = template_name
self.context = context or {} self.context = context or {}
def get_context(self, obj, context): def get_context(self, obj, attr, value, context):
return { return {
**context, **context,
**self.context, **self.context,

View File

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