Files
netbox/netbox/netbox/ui/attrs.py
Arthur Hanson 07bb6aa365 #20923: Migrate Users object to declarative layouts (#21568)
This continues the migration of object views in the user app to NetBox v4.5’s declarative layouts.
Replace legacy object view templates with declarative layouts for:
   - Users
   - Groups
   - API Tokens
   - Permissions
   - Owner Groups
   - Owners
2026-03-10 16:04:24 +01:00

423 lines
13 KiB
Python

from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from netbox.config import get_config
from utilities.data import resolve_attr_path
__all__ = (
'AddressAttr',
'BooleanAttr',
'ChoiceAttr',
'ColorAttr',
'DateTimeAttr',
'GPSCoordinatesAttr',
'GenericForeignKeyAttr',
'ImageAttr',
'NestedObjectAttr',
'NumericAttr',
'ObjectAttribute',
'RelatedObjectAttr',
'TemplatedAttr',
'TextAttr',
'TimezoneAttr',
'UtilizationAttr',
)
PLACEHOLDER_HTML = '<span class="text-muted">&mdash;</span>'
IMAGE_DECODING_CHOICES = ('auto', 'async', 'sync')
#
# Attributes
#
class ObjectAttribute:
"""
Base class for representing an attribute of an object.
Attributes:
template_name (str): The name of the template to render
placeholder (str): HTML to render for empty/null values
Parameters:
accessor (str): The dotted path to the attribute being rendered (e.g. "site.region.name")
label (str): Human-friendly label for the rendered attribute
"""
template_name = None
label = None
placeholder = mark_safe(PLACEHOLDER_HTML)
def __init__(self, accessor, label=None):
self.accessor = accessor
if label is not None:
self.label = label
def get_value(self, obj):
"""
Return the value of the attribute.
Parameters:
obj (object): The object for which the attribute is being rendered
"""
return resolve_attr_path(obj, self.accessor)
def get_context(self, obj, context):
"""
Return any additional template context used to render the attribute value.
Parameters:
obj (object): The object for which the attribute is being rendered
context (dict): The root template context
"""
return {}
def render(self, obj, context):
value = self.get_value(obj)
# If the value is empty, render a placeholder
if value in (None, ''):
return self.placeholder
return render_to_string(self.template_name, {
**self.get_context(obj, context),
'name': context['name'],
'value': value,
})
class TextAttr(ObjectAttribute):
"""
A text attribute.
Parameters:
style (str): CSS class to apply to the rendered attribute
format_string (str): If specified, the value will be formatted using this string when rendering
copy_button (bool): Set to True to include a copy-to-clipboard button
"""
template_name = 'ui/attrs/text.html'
def __init__(self, *args, style=None, format_string=None, copy_button=False, **kwargs):
super().__init__(*args, **kwargs)
self.style = style
self.format_string = format_string
self.copy_button = copy_button
def get_value(self, obj):
value = resolve_attr_path(obj, self.accessor)
# Apply format string (if any)
if value is not None and value != '' and self.format_string:
return self.format_string.format(value)
return value
def get_context(self, obj, context):
return {
'style': self.style,
'copy_button': self.copy_button,
}
class NumericAttr(ObjectAttribute):
"""
An integer or float attribute.
Parameters:
unit_accessor (str): Accessor for the unit of measurement to display alongside the value (if any)
copy_button (bool): Set to True to include a copy-to-clipboard button
"""
template_name = 'ui/attrs/numeric.html'
def __init__(self, *args, unit_accessor=None, copy_button=False, **kwargs):
super().__init__(*args, **kwargs)
self.unit_accessor = unit_accessor
self.copy_button = copy_button
def get_context(self, obj, context):
unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None
return {
'unit': unit,
'copy_button': self.copy_button,
}
class ChoiceAttr(ObjectAttribute):
"""
A selection from a set of choices.
The class calls get_FOO_display() on the object to retrieve the human-friendly choice label. If a get_FOO_color()
method exists on the object, it will be used to render a background color for the attribute value.
"""
template_name = 'ui/attrs/choice.html'
def get_value(self, obj):
try:
return getattr(obj, f'get_{self.accessor}_display')()
except AttributeError:
return resolve_attr_path(obj, self.accessor)
def get_context(self, obj, context):
try:
bg_color = getattr(obj, f'get_{self.accessor}_color')()
except AttributeError:
bg_color = None
return {
'bg_color': bg_color,
}
class BooleanAttr(ObjectAttribute):
"""
A boolean attribute.
Parameters:
display_false (bool): If False, a placeholder will be rendered instead of the "False" indication
"""
template_name = 'ui/attrs/boolean.html'
def __init__(self, *args, display_false=True, **kwargs):
super().__init__(*args, **kwargs)
self.display_false = display_false
def get_value(self, obj):
value = super().get_value(obj)
if value is False and self.display_false is False:
return None
return value
class ColorAttr(ObjectAttribute):
"""
An RGB color value.
"""
template_name = 'ui/attrs/color.html'
label = _('Color')
class ImageAttr(ObjectAttribute):
"""
An attribute representing an image field on the model. Displays the uploaded image.
Parameters:
load_lazy (bool): If True, the image will be loaded lazily (default: True)
decoding (str): Image decoding option ('async', 'sync', 'auto', None)
"""
template_name = 'ui/attrs/image.html'
def __init__(self, *args, load_lazy=True, decoding=None, **kwargs):
super().__init__(*args, **kwargs)
self.load_lazy = load_lazy
if decoding is not None and decoding not in IMAGE_DECODING_CHOICES:
raise ValueError(
_('Invalid decoding option: {decoding}! Must be one of {image_decoding_choices}').format(
decoding=decoding, image_decoding_choices=', '.join(IMAGE_DECODING_CHOICES)
)
)
# Compute default decoding:
# - lazy images: async decoding (performance-friendly hint)
# - non-lazy images: omit decoding (browser default/auto)
if decoding is None and load_lazy:
decoding = 'async'
self.decoding = decoding
def get_context(self, obj, context):
return {
'decoding': self.decoding,
'load_lazy': self.load_lazy,
}
class RelatedObjectAttr(ObjectAttribute):
"""
An attribute representing a related object.
Parameters:
linkify (bool): If True, the rendered value will be hyperlinked to the related object's detail view
grouped_by (str): A second-order object to annotate alongside the related object; for example, an attribute
representing the dcim.Site model might specify grouped_by="region"
"""
template_name = 'ui/attrs/object.html'
def __init__(self, *args, linkify=None, grouped_by=None, **kwargs):
super().__init__(*args, **kwargs)
self.linkify = linkify
self.grouped_by = grouped_by
def get_context(self, obj, context):
value = self.get_value(obj)
group = getattr(value, self.grouped_by, None) if self.grouped_by else None
return {
'linkify': self.linkify,
'group': group,
}
class NestedObjectAttr(ObjectAttribute):
"""
An attribute representing a related nested object. Similar to `RelatedObjectAttr`, but includes the ancestors of the
related object in the rendered output.
Parameters:
linkify (bool): If True, the rendered value will be hyperlinked to the related object's detail view
max_depth (int): Maximum number of ancestors to display (default: all)
"""
template_name = 'ui/attrs/nested_object.html'
def __init__(self, *args, linkify=None, max_depth=None, **kwargs):
super().__init__(*args, **kwargs)
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:]
return {
'nodes': nodes,
'linkify': self.linkify,
}
class GenericForeignKeyAttr(ObjectAttribute):
"""
An attribute representing a related generic relation object.
This attribute is similar to `RelatedObjectAttr` but uses the
ContentType of the related object to be displayed alongside the value.
Parameters:
linkify (bool): If True, the rendered value will be hyperlinked
to the related object's detail view
"""
template_name = 'ui/attrs/generic_object.html'
def __init__(self, *args, linkify=None, **kwargs):
super().__init__(*args, **kwargs)
self.linkify = linkify
def get_context(self, obj, context):
value = self.get_value(obj)
content_type = value._meta.verbose_name
return {
'content_type': content_type,
'linkify': self.linkify,
}
class AddressAttr(ObjectAttribute):
"""
A physical or mailing address.
Parameters:
map_url (bool): 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
def get_context(self, obj, context):
return {
'map_url': self.map_url,
}
class GPSCoordinatesAttr(ObjectAttribute):
"""
A GPS coordinates pair comprising latitude and longitude values.
Parameters:
latitude_attr (float): The name of the field containing the latitude value
longitude_attr (float): The name of the field containing the longitude value
map_url (bool): If true, the address will render as a hyperlink using settings.MAPS_URL
"""
template_name = 'ui/attrs/gps_coordinates.html'
label = _('GPS coordinates')
def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs):
super().__init__(accessor=None, **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
def render(self, obj, context=None):
context = context or {}
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,
'latitude': latitude,
'longitude': longitude,
'map_url': self.map_url,
})
class DateTimeAttr(ObjectAttribute):
"""
A date or datetime attribute.
Parameters:
spec (str): Controls the rendering format. Use 'date' for date-only rendering,
or 'seconds'/'minutes' for datetime rendering with the given precision.
"""
template_name = 'ui/attrs/datetime.html'
def __init__(self, *args, spec='seconds', **kwargs):
super().__init__(*args, **kwargs)
self.spec = spec
def get_context(self, obj, context):
return {
'spec': self.spec,
}
class TimezoneAttr(ObjectAttribute):
"""
A timezone value. Includes the numeric offset from UTC.
"""
template_name = 'ui/attrs/timezone.html'
class TemplatedAttr(ObjectAttribute):
"""
Renders an attribute using a custom template.
Parameters:
template_name (str): The name of the template to render
context (dict): Additional context to pass to the template when rendering
"""
def __init__(self, *args, template_name, context=None, **kwargs):
super().__init__(*args, **kwargs)
self.template_name = template_name
self.context = context or {}
def get_context(self, obj, context):
return {
**self.context,
'object': obj,
}
class UtilizationAttr(ObjectAttribute):
"""
Renders the value of an attribute as a utilization graph.
"""
template_name = 'ui/attrs/utilization.html'