Change approach for declaring object panels

This commit is contained in:
Jeremy Stretch 2025-10-30 10:46:22 -04:00
parent fd3a9a0c37
commit 3890043b06
9 changed files with 198 additions and 158 deletions

View File

@ -0,0 +1,26 @@
from django.utils.translation import gettext_lazy as _
from netbox.templates.components import (
GPSCoordinatesAttr, NestedObjectAttr, ObjectAttr, ObjectDetailsPanel, TemplatedAttr, TextAttr,
)
class DevicePanel(ObjectDetailsPanel):
region = NestedObjectAttr('site.region', linkify=True)
site = ObjectAttr('site', linkify=True, grouped_by='group')
location = NestedObjectAttr('location', linkify=True)
rack = TemplatedAttr('rack', template_name='dcim/device/attrs/rack.html')
virtual_chassis = NestedObjectAttr('virtual_chassis', linkify=True)
parent_device = TemplatedAttr(
'parent_bay',
template_name='dcim/device/attrs/parent_device.html',
label=_('Parent Device'),
)
gps_coordinates = GPSCoordinatesAttr()
tenant = ObjectAttr('tenant', linkify=True, grouped_by='group')
device_type = ObjectAttr('device_type', linkify=True, grouped_by='manufacturer')
description = TextAttr('description')
airflow = TextAttr('get_airflow_display')
serial = TextAttr('serial', style='font-monospace')
asset_tag = TextAttr('asset_tag', style='font-monospace')
config_template = ObjectAttr('config_template', linkify=True)

View File

@ -12,13 +12,11 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import View from django.views.generic import View
from circuits.models import Circuit, CircuitTermination from circuits.models import Circuit, CircuitTermination
from dcim.template_components.object_panels import DevicePanel
from extras.views import ObjectConfigContextView, ObjectRenderConfigView from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.object_actions import * from netbox.object_actions import *
from netbox.templates.components import (
AttributesPanel, EmbeddedTemplate, GPSCoordinatesAttr, NestedObjectAttr, ObjectAttr, TextAttr,
)
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
@ -2226,28 +2224,10 @@ class DeviceView(generic.ObjectView):
else: else:
vc_members = [] vc_members = []
device_attrs = AttributesPanel(_('Device'), {
_('Region'): NestedObjectAttr(instance.site.region, linkify=True),
_('Site'): ObjectAttr(instance.site, linkify=True, grouped_by='group'),
_('Location'): ObjectAttr(instance.location, linkify=True),
# TODO: Include position & face of parent device (if applicable)
_('Rack'): EmbeddedTemplate('dcim/device/attrs/rack.html', {'device': instance}),
_('Virtual Chassis'): ObjectAttr(instance.virtual_chassis, linkify=True),
_('Parent Device'): EmbeddedTemplate('dcim/device/attrs/parent_device.html', {'device': instance}),
_('GPS Coordinates'): GPSCoordinatesAttr(instance.latitude, instance.longitude),
_('Tenant'): ObjectAttr(instance.tenant, linkify=True, grouped_by='group'),
_('Device Type'): ObjectAttr(instance.device_type, linkify=True, grouped_by='manufacturer'),
_('Description'): TextAttr(instance.description),
_('Airflow'): TextAttr(instance.get_airflow_display()),
_('Serial Number'): TextAttr(instance.serial, style='font-monospace'),
_('Asset Tag'): TextAttr(instance.asset_tag, style='font-monospace'),
_('Config Template'): ObjectAttr(instance.config_template, linkify=True),
})
return { return {
'vc_members': vc_members, 'vc_members': vc_members,
'svg_extra': f'highlight=id:{instance.pk}', 'svg_extra': f'highlight=id:{instance.pk}',
'device_attrs': device_attrs, 'device_panel': DevicePanel(instance, _('Device')),
} }

View File

@ -1,12 +1,143 @@
from abc import ABC, abstractmethod from abc import ABC, ABCMeta, abstractmethod
from functools import cached_property
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from netbox.config import get_config from netbox.config import get_config
from utilities.string import title
#
# Attributes
#
class Attr:
template_name = None
placeholder = mark_safe('<span class="text-muted">&mdash;</span>')
def __init__(self, accessor, label=None, template_name=None):
self.accessor = accessor
self.label = label
self.template_name = template_name or self.template_name
@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):
def __init__(self, *args, style=None, **kwargs):
super().__init__(*args, **kwargs)
self.style = style
def render(self, obj):
value = self._resolve_attr(obj, self.accessor)
if value in (None, ''):
return self.placeholder
if self.style:
return mark_safe(f'<span class="{self.style}">{escape(value)}</span>')
return value
class ObjectAttr(Attr):
template_name = 'components/object.html'
def __init__(self, *args, linkify=None, grouped_by=None, **kwargs):
super().__init__(*args, **kwargs)
self.linkify = linkify
self.grouped_by = grouped_by
# Derive label from related object if not explicitly set
if self.label is None:
self.label = title(self.accessor)
def render(self, obj):
value = self._resolve_attr(obj, self.accessor)
if value is None:
return self.placeholder
group = getattr(value, self.grouped_by, None) if self.grouped_by else None
return render_to_string(self.template_name, {
'object': value,
'group': group,
'linkify': self.linkify,
})
class NestedObjectAttr(Attr):
template_name = 'components/nested_object.html'
def __init__(self, *args, linkify=None, **kwargs):
super().__init__(*args, **kwargs)
self.linkify = linkify
def render(self, obj):
value = self._resolve_attr(obj, self.accessor)
if value is None:
return self.placeholder
return render_to_string(self.template_name, {
'nodes': value.get_ancestors(include_self=True),
'linkify': self.linkify,
})
class GPSCoordinatesAttr(Attr):
template_name = 'components/gps_coordinates.html'
def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs):
kwargs.setdefault('label', _('GPS Coordinates'))
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):
latitude = self._resolve_attr(obj, self.latitude_attr)
longitude = self._resolve_attr(obj, self.longitude_attr)
if latitude is None or longitude is None:
return self.placeholder
return render_to_string(self.template_name, {
'latitude': latitude,
'longitude': longitude,
'map_url': self.map_url,
})
class TemplatedAttr(Attr):
def __init__(self, *args, context=None, **kwargs):
super().__init__(*args, **kwargs)
self.context = context or {}
def render(self, obj):
return render_to_string(
self.template_name,
{
**self.context,
'object': obj,
'value': self._resolve_attr(obj, self.accessor),
}
)
#
# Components
#
class Component(ABC): class Component(ABC):
@abstractmethod @abstractmethod
@ -17,123 +148,38 @@ class Component(ABC):
return self.render() return self.render()
# class ObjectDetailsPanelMeta(ABCMeta):
# Attributes
#
class Attr(Component): def __new__(mcls, name, bases, attrs):
template_name = None # Collect all declared attributes
placeholder = mark_safe('<span class="text-muted">&mdash;</span>') attrs['_attrs'] = {}
for key, val in list(attrs.items()):
if isinstance(val, Attr):
attrs['_attrs'][key] = val
return super().__new__(mcls, name, bases, attrs)
class TextAttr(Attr): class ObjectDetailsPanel(Component, metaclass=ObjectDetailsPanelMeta):
template_name = 'components/object_details_panel.html'
def __init__(self, value, style=None): def __init__(self, obj, title=None):
self.value = value
self.style = style
def render(self):
if self.value in (None, ''):
return self.placeholder
if self.style:
return mark_safe(f'<span class="{self.style}">{escape(self.value)}</span>')
return self.value
class ObjectAttr(Attr):
template_name = 'components/object.html'
def __init__(self, obj, linkify=None, grouped_by=None, template_name=None):
self.object = obj self.object = obj
self.linkify = linkify self.title = title or obj._meta.verbose_name
self.group = getattr(obj, grouped_by, None) if grouped_by else None
self.template_name = template_name or self.template_name
def render(self): @cached_property
if self.object is None: def attributes(self):
return self.placeholder return [
{
# Determine object & group URLs 'label': attr.label or title(name),
# TODO: Add support for reverse() lookups 'value': attr.render(self.object),
if self.linkify and hasattr(self.object, 'get_absolute_url'): } for name, attr in self._attrs.items()
object_url = self.object.get_absolute_url() ]
else:
object_url = None
if self.linkify and hasattr(self.group, 'get_absolute_url'):
group_url = self.group.get_absolute_url()
else:
group_url = None
return render_to_string(self.template_name, {
'object': self.object,
'object_url': object_url,
'group': self.group,
'group_url': group_url,
})
class NestedObjectAttr(Attr):
template_name = 'components/nested_object.html'
def __init__(self, obj, linkify=None):
self.object = obj
self.linkify = linkify
def render(self):
if not self.object:
return self.placeholder
return render_to_string(self.template_name, {
'nodes': self.object.get_ancestors(include_self=True),
'linkify': self.linkify,
})
class GPSCoordinatesAttr(Attr):
template_name = 'components/gps_coordinates.html'
def __init__(self, latitude, longitude, map_url=True):
self.latitude = latitude
self.longitude = longitude
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):
if not (self.latitude and self.longitude):
return self.placeholder
return render_to_string(self.template_name, {
'latitude': self.latitude,
'longitude': self.longitude,
'map_url': self.map_url,
})
#
# Components
#
class AttributesPanel(Component):
template_name = 'components/attributes_panel.html'
def __init__(self, title, attrs):
self.title = title
self.attrs = attrs
def render(self): def render(self):
return render_to_string(self.template_name, { return render_to_string(self.template_name, {
'title': self.title, 'title': self.title,
'attrs': self.attrs, 'attrs': self.attributes,
}) })
def __str__(self):
class EmbeddedTemplate(Component): return self.render()
def __init__(self, template_name, context=None):
self.template_name = template_name
self.context = context or {}
def render(self):
return render_to_string(self.template_name, self.context)

View File

@ -2,25 +2,13 @@
{# Display an object with its parent group #} {# Display an object with its parent group #}
<ol class="breadcrumb" aria-label="breadcrumbs"> <ol class="breadcrumb" aria-label="breadcrumbs">
<li class="breadcrumb-item"> <li class="breadcrumb-item">
{% if group_url %} {% if linkify %}{{ group|linkify }}{% else %}{{ group }}{% endif %}
<a href="{{ group_url }}">{{ group }}</a>
{% else %}
{{ object.group }}
{% endif %}
</li> </li>
<li class="breadcrumb-item"> <li class="breadcrumb-item">
{% if object_url %} {% if linkify %}{{ object|linkify }}{% else %}{{ object }}{% endif %}
<a href="{{ object_url }}">{{ object }}</a>
{% else %}
{{ object }}
{% endif %}
</li> </li>
</ol> </ol>
{% else %} {% else %}
{# Display only the object #} {# Display only the object #}
{% if object_url %} {% if linkify %}{{ object|linkify }}{% else %}{{ object }}{% endif %}
<a href="{{ object_url }}">{{ object }}</a>
{% else %}
{{ object }}
{% endif %}
{% endif %} {% endif %}

View File

@ -1,11 +1,11 @@
<div class="card"> <div class="card">
<h2 class="card-header">{{ title }}</h2> <h2 class="card-header">{{ title }}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
{% for label, attr in attrs.items %} {% for attr in attrs %}
<tr> <tr>
<th scope="row">{{ label }}</th> <th scope="row">{{ attr.label }}</th>
<td> <td>
<div class="d-flex justify-content-between align-items-start">{{ attr }}</div> <div class="d-flex justify-content-between align-items-start">{{ attr.value }}</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -177,7 +177,7 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-12 col-xl-6"> <div class="col col-12 col-xl-6">
{{ device_attrs }} {{ device_panel }}
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Management" %}</h2> <h2 class="card-header">{% trans "Management" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">

View File

@ -1,4 +1,4 @@
{% if device.parent_bay %} {% if value %}
<ol class="breadcrumb" aria-label="breadcrumbs"> <ol class="breadcrumb" aria-label="breadcrumbs">
<li class="breadcrumb-item">{{ device.parent_bay.device|linkify }}</li> <li class="breadcrumb-item">{{ device.parent_bay.device|linkify }}</li>
<li class="breadcrumb-item">{{ device.parent_bay }}</li> <li class="breadcrumb-item">{{ device.parent_bay }}</li>

View File

@ -1,15 +1,15 @@
{% load i18n %} {% load i18n %}
{% if device.rack %} {% if value %}
<span> <span>
{{ device.rack|linkify }} {{ value|linkify }}
{% if device.rack and device.position %} {% if value and object.position %}
(U{{ device.position|floatformat }} / {{ device.get_face_display }}) (U{{ object.position|floatformat }} / {{ object.get_face_display }})
{% elif device.rack and device.device_type.u_height %} {% elif value and object.device_type.u_height %}
<span class="badge text-bg-warning">{% trans "Not racked" %}</span> <span class="badge text-bg-warning">{% trans "Not racked" %}</span>
{% endif %} {% endif %}
</span> </span>
{% if device.rack and device.position %} {% if value and object.position %}
<a href="{{ device.rack.get_absolute_url }}?device={{ device.pk }}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}"> <a href="{{ value.get_absolute_url }}?device={{ object.pk }}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}">
<i class="mdi mdi-view-day-outline"></i> <i class="mdi mdi-view-day-outline"></i>
</a> </a>
{% endif %} {% endif %}