Split ObjectPanel into a base class and ObjectAttrsPanel; use base class for e.g. CommentsPanels, JSONPanel, etc.

This commit is contained in:
Jeremy Stretch
2025-11-05 13:21:37 -05:00
parent 838794a5cf
commit 281cb4f586
6 changed files with 164 additions and 87 deletions

View File

@@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
from netbox.ui import attrs, panels
class SitePanel(panels.ObjectPanel):
class SitePanel(panels.ObjectAttributesPanel):
region = attrs.NestedObjectAttr('region', linkify=True)
group = attrs.NestedObjectAttr('group', linkify=True)
status = attrs.ChoiceAttr('status')
@@ -23,7 +23,7 @@ class LocationPanel(panels.NestedGroupObjectPanel):
facility = attrs.TextAttr('facility')
class RackDimensionsPanel(panels.ObjectPanel):
class RackDimensionsPanel(panels.ObjectAttributesPanel):
form_factor = attrs.ChoiceAttr('form_factor')
width = attrs.ChoiceAttr('width')
u_height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height'))
@@ -33,12 +33,12 @@ class RackDimensionsPanel(panels.ObjectPanel):
mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm')
class RackNumberingPanel(panels.ObjectPanel):
class RackNumberingPanel(panels.ObjectAttributesPanel):
starting_unit = attrs.TextAttr('starting_unit')
desc_units = attrs.BooleanAttr('desc_units', label=_('Descending units'))
class RackPanel(panels.ObjectPanel):
class RackPanel(panels.ObjectAttributesPanel):
region = attrs.NestedObjectAttr('site.region', linkify=True)
site = attrs.ObjectAttr('site', linkify=True, grouped_by='group')
location = attrs.NestedObjectAttr('location', linkify=True)
@@ -55,7 +55,7 @@ class RackPanel(panels.ObjectPanel):
power_utilization = attrs.UtilizationAttr('get_power_utilization')
class RackWeightPanel(panels.ObjectPanel):
class RackWeightPanel(panels.ObjectAttributesPanel):
weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
max_weight = attrs.NumericAttr('max_weight', unit_accessor='get_weight_unit_display', label=_('Maximum weight'))
total_weight = attrs.NumericAttr('total_weight', unit_accessor='get_weight_unit_display')
@@ -65,14 +65,14 @@ class RackRolePanel(panels.OrganizationalObjectPanel):
color = attrs.ColorAttr('color')
class RackTypePanel(panels.ObjectPanel):
class RackTypePanel(panels.ObjectAttributesPanel):
manufacturer = attrs.ObjectAttr('manufacturer', linkify=True)
model = attrs.TextAttr('model')
description = attrs.TextAttr('description')
airflow = attrs.ChoiceAttr('airflow')
class DevicePanel(panels.ObjectPanel):
class DevicePanel(panels.ObjectAttributesPanel):
region = attrs.NestedObjectAttr('site.region', linkify=True)
site = attrs.ObjectAttr('site', linkify=True, grouped_by='group')
location = attrs.NestedObjectAttr('location', linkify=True)
@@ -89,7 +89,7 @@ class DevicePanel(panels.ObjectPanel):
config_template = attrs.ObjectAttr('config_template', linkify=True)
class DeviceManagementPanel(panels.ObjectPanel):
class DeviceManagementPanel(panels.ObjectAttributesPanel):
status = attrs.ChoiceAttr('status')
role = attrs.NestedObjectAttr('role', linkify=True, max_depth=3)
platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3)
@@ -110,7 +110,7 @@ class DeviceManagementPanel(panels.ObjectPanel):
)
class DeviceTypePanel(panels.ObjectPanel):
class DeviceTypePanel(panels.ObjectAttributesPanel):
manufacturer = attrs.ObjectAttr('manufacturer', linkify=True)
model = attrs.TextAttr('model')
part_number = attrs.TextAttr('part_number')
@@ -126,6 +126,6 @@ class DeviceTypePanel(panels.ObjectPanel):
rear_image = attrs.ImageAttr('rear_image')
class ModuleTypeProfilePanel(panels.ObjectPanel):
class ModuleTypeProfilePanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')

View File

@@ -5,6 +5,7 @@ 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
#
@@ -26,15 +27,6 @@ class Attr(ABC):
def render(self, obj, context=None):
pass
@staticmethod
def _resolve_attr(obj, path):
cur = obj
for part in path.split('.'):
if cur is None:
return None
cur = getattr(cur, part) if hasattr(cur, part) else cur.get(part) if isinstance(cur, dict) else None
return cur
class TextAttr(Attr):
template_name = 'ui/attrs/text.html'
@@ -47,7 +39,7 @@ class TextAttr(Attr):
def render(self, obj, context=None):
context = context or {}
value = self._resolve_attr(obj, self.accessor)
value = resolve_attr_path(obj, self.accessor)
if value in (None, ''):
return self.placeholder
if self.format_string:
@@ -70,10 +62,10 @@ class NumericAttr(Attr):
def render(self, obj, context=None):
context = context or {}
value = self._resolve_attr(obj, self.accessor)
value = resolve_attr_path(obj, self.accessor)
if value in (None, ''):
return self.placeholder
unit = self._resolve_attr(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 render_to_string(self.template_name, {
**context,
'value': value,
@@ -90,7 +82,7 @@ class ChoiceAttr(Attr):
try:
value = getattr(obj, f'get_{self.accessor}_display')()
except AttributeError:
value = self._resolve_attr(obj, self.accessor)
value = resolve_attr_path(obj, self.accessor)
if value in (None, ''):
return self.placeholder
try:
@@ -113,7 +105,7 @@ class BooleanAttr(Attr):
def render(self, obj, context=None):
context = context or {}
value = self._resolve_attr(obj, self.accessor)
value = resolve_attr_path(obj, self.accessor)
if value in (None, '') and not self.display_false:
return self.placeholder
return render_to_string(self.template_name, {
@@ -128,7 +120,7 @@ class ColorAttr(Attr):
def render(self, obj, context=None):
context = context or {}
value = self._resolve_attr(obj, self.accessor)
value = resolve_attr_path(obj, self.accessor)
return render_to_string(self.template_name, {
**context,
'color': value,
@@ -140,7 +132,7 @@ class ImageAttr(Attr):
def render(self, obj, context=None):
context = context or {}
value = self._resolve_attr(obj, self.accessor)
value = resolve_attr_path(obj, self.accessor)
if value in (None, ''):
return self.placeholder
return render_to_string(self.template_name, {
@@ -159,7 +151,7 @@ class ObjectAttr(Attr):
def render(self, obj, context=None):
context = context or {}
value = self._resolve_attr(obj, self.accessor)
value = resolve_attr_path(obj, self.accessor)
if value is None:
return self.placeholder
group = getattr(value, self.grouped_by, None) if self.grouped_by else None
@@ -182,7 +174,7 @@ class NestedObjectAttr(Attr):
def render(self, obj, context=None):
context = context or {}
value = self._resolve_attr(obj, self.accessor)
value = resolve_attr_path(obj, self.accessor)
if value is None:
return self.placeholder
nodes = value.get_ancestors(include_self=True)
@@ -209,7 +201,7 @@ class AddressAttr(Attr):
def render(self, obj, context=None):
context = context or {}
value = self._resolve_attr(obj, self.accessor)
value = resolve_attr_path(obj, self.accessor)
if value in (None, ''):
return self.placeholder
return render_to_string(self.template_name, {
@@ -236,8 +228,8 @@ class GPSCoordinatesAttr(Attr):
def render(self, obj, context=None):
context = context or {}
latitude = self._resolve_attr(obj, self.latitude_attr)
longitude = self._resolve_attr(obj, self.longitude_attr)
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, {
@@ -253,7 +245,7 @@ class TimezoneAttr(Attr):
def render(self, obj, context=None):
context = context or {}
value = self._resolve_attr(obj, self.accessor)
value = resolve_attr_path(obj, self.accessor)
if value in (None, ''):
return self.placeholder
return render_to_string(self.template_name, {
@@ -270,7 +262,7 @@ class TemplatedAttr(Attr):
def render(self, obj, context=None):
context = context or {}
value = self._resolve_attr(obj, self.accessor)
value = resolve_attr_path(obj, self.accessor)
if value is None:
return self.placeholder
return render_to_string(
@@ -289,7 +281,7 @@ class UtilizationAttr(Attr):
def render(self, obj, context=None):
context = context or {}
value = self._resolve_attr(obj, self.accessor)
value = resolve_attr_path(obj, self.accessor)
return render_to_string(self.template_name, {
**context,
'value': value,

View File

@@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
from netbox.ui import attrs
from netbox.ui.actions import CopyContent
from utilities.data import resolve_attr_path
from utilities.querydict import dict_to_querydict
from utilities.string import title
from utilities.templatetags.plugins import _get_registered_content
@@ -15,6 +16,7 @@ __all__ = (
'CommentsPanel',
'JSONPanel',
'NestedGroupObjectPanel',
'ObjectAttributesPanel',
'ObjectPanel',
'ObjectsTablePanel',
'OrganizationalObjectPanel',
@@ -25,6 +27,10 @@ __all__ = (
)
#
# Base classes
#
class Panel(ABC):
"""
A block of content rendered within an HTML template.
@@ -74,7 +80,44 @@ class Panel(ABC):
return render_to_string(self.template_name, self.get_context(context))
class ObjectPanelMeta(ABCMeta):
#
# Object-specific panels
#
class ObjectPanel(Panel):
"""
Base class for object-specific panels.
"""
accessor = 'object'
def __init__(self, accessor=None, **kwargs):
"""
Instantiate a new ObjectPanel.
Parameters:
accessor: The name of the attribute on the object (default: "object")
"""
super().__init__(**kwargs)
if accessor is not None:
self.accessor = accessor
def get_context(self, context):
"""
Return the context data to be used when rendering the panel.
Parameters:
context: The template context
"""
obj = resolve_attr_path(context, self.accessor)
return {
**super().get_context(context),
'title': self.title or title(obj._meta.verbose_name),
'object': obj,
}
class ObjectAttributesPanelMeta(ABCMeta):
def __new__(mcls, name, bases, namespace, **kwargs):
declared = {}
@@ -101,7 +144,7 @@ class ObjectPanelMeta(ABCMeta):
return cls
class ObjectPanel(Panel, metaclass=ObjectPanelMeta):
class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
"""
A panel which displays selected attributes of an object.
@@ -109,10 +152,9 @@ class ObjectPanel(Panel, metaclass=ObjectPanelMeta):
template_name: The name of the template to render
accessor: The name of the attribute on the object
"""
template_name = 'ui/panels/object.html'
accessor = None
template_name = 'ui/panels/object_attributes.html'
def __init__(self, accessor=None, only=None, exclude=None, **kwargs):
def __init__(self, only=None, exclude=None, **kwargs):
"""
Instantiate a new ObjectPanel.
@@ -123,9 +165,6 @@ class ObjectPanel(Panel, metaclass=ObjectPanelMeta):
"""
super().__init__(**kwargs)
if accessor is not None:
self.accessor = accessor
# Set included/excluded attributes
if only is not None and exclude is not None:
raise ValueError("only and exclude cannot both be specified.")
@@ -155,21 +194,20 @@ class ObjectPanel(Panel, metaclass=ObjectPanelMeta):
elif self.exclude:
attr_names -= set(self.exclude)
obj = getattr(context['object'], self.accessor) if self.accessor else context['object']
ctx = super().get_context(context)
return {
**super().get_context(context),
'title': self.title or title(obj._meta.verbose_name),
**ctx,
'attrs': [
{
'label': attr.label or self._name_to_label(name),
'value': attr.render(obj, {'name': name}),
'value': attr.render(ctx['object'], {'name': name}),
} for name, attr in self._attrs.items() if name in attr_names
],
}
class OrganizationalObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta):
class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta):
"""
An ObjectPanel with attributes common to OrganizationalModels.
"""
@@ -177,20 +215,82 @@ class OrganizationalObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta):
description = attrs.TextAttr('description', label=_('Description'))
class NestedGroupObjectPanel(OrganizationalObjectPanel, metaclass=ObjectPanelMeta):
class NestedGroupObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta):
"""
An ObjectPanel with attributes common to NestedGroupObjects.
"""
parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True)
class CommentsPanel(Panel):
class CommentsPanel(ObjectPanel):
"""
A panel which displays comments associated with an object.
"""
template_name = 'ui/panels/comments.html'
title = _('Comments')
def __init__(self, field_name='comments', **kwargs):
"""
Instantiate a new CommentsPanel.
Parameters:
field_name: The name of the comment field on the object
"""
super().__init__(**kwargs)
self.field_name = field_name
def get_context(self, context):
"""
Return the context data to be used when rendering the panel.
Parameters:
context: The template context
"""
return {
**super().get_context(context),
'comments': getattr(context['object'], self.field_name),
}
class JSONPanel(ObjectPanel):
"""
A panel which renders formatted JSON data from an object's JSONField.
"""
template_name = 'ui/panels/json.html'
def __init__(self, field_name, copy_button=True, **kwargs):
"""
Instantiate a new JSONPanel.
Parameters:
field_name: The name of the JSON field on the object
copy_button: Set to True (default) to include a copy-to-clipboard button
"""
super().__init__(**kwargs)
self.field_name = field_name
if copy_button:
self.actions.append(
CopyContent(f'panel_{field_name}'),
)
def get_context(self, context):
"""
Return the context data to be used when rendering the panel.
Parameters:
context: The template context
"""
return {
**super().get_context(context),
'data': getattr(context['object'], self.field_name),
'field_name': self.field_name,
}
#
# Miscellaneous panels
#
class RelatedObjectsPanel(Panel):
"""
@@ -261,42 +361,6 @@ class ObjectsTablePanel(Panel):
}
class JSONPanel(Panel):
"""
A panel which renders formatted JSON data.
"""
template_name = 'ui/panels/json.html'
def __init__(self, field_name, copy_button=True, **kwargs):
"""
Instantiate a new JSONPanel.
Parameters:
field_name: The name of the JSON field on the object
copy_button: Set to True (default) to include a copy-to-clipboard button
"""
super().__init__(**kwargs)
self.field_name = field_name
if copy_button:
self.actions.append(
CopyContent(f'panel_{field_name}'),
)
def get_context(self, context):
"""
Return the context data to be used when rendering the panel.
Parameters:
context: The template context
"""
return {
**super().get_context(context),
'data': getattr(context['object'], self.field_name),
'field_name': self.field_name,
}
class TemplatePanel(Panel):
"""
A panel which renders content using an HTML template.

View File

@@ -3,8 +3,8 @@
{% block panel_content %}
<div class="card-body">
{% if object.comments %}
{{ object.comments|markdown }}
{% if comments %}
{{ comments|markdown }}
{% else %}
<span class="text-muted">{% trans "None" %}</span>
{% endif %}

View File

@@ -12,6 +12,7 @@ __all__ = (
'flatten_dict',
'ranges_to_string',
'ranges_to_string_list',
'resolve_attr_path',
'shallow_compare_dict',
'string_to_ranges',
)
@@ -213,3 +214,23 @@ def string_to_ranges(value):
return None
values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)'))
return values
#
# Attribute resolution
#
def resolve_attr_path(obj, path):
"""
Follow a dotted path across attributes and/or dictionary keys and return the final value.
Parameters:
obj: The starting object
path: The dotted path to follow (e.g. "foo.bar.baz")
"""
cur = obj
for part in path.split('.'):
if cur is None:
return None
cur = getattr(cur, part) if hasattr(cur, part) else cur.get(part)
return cur