mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-26 15:17:45 -06:00
Split ObjectPanel into a base class and ObjectAttrsPanel; use base class for e.g. CommentsPanels, JSONPanel, etc.
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user