Files
netbox/netbox/netbox/ui/panels.py

342 lines
11 KiB
Python

from django.apps import apps
from django.template.loader import render_to_string
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
from utilities.views import get_viewname
__all__ = (
'CommentsPanel',
'JSONPanel',
'NestedGroupObjectPanel',
'ObjectAttributesPanel',
'ObjectPanel',
'ObjectsTablePanel',
'OrganizationalObjectPanel',
'Panel',
'PluginContentPanel',
'RelatedObjectsPanel',
'TemplatePanel',
)
#
# Base classes
#
class Panel:
"""
A block of content rendered within an HTML template.
Panels are arranged within rows and columns, (generally) render as discrete "cards" within the user interface. Each
panel has a title and may have one or more actions associated with it, which will be rendered as hyperlinks in the
top right corner of the card.
Attributes:
template_name (str): The name of the template used to render the panel
Parameters:
title (str): The human-friendly title of the panel
actions (list): An iterable of PanelActions to include in the panel header
"""
template_name = None
title = None
actions = None
def __init__(self, title=None, actions=None):
if title is not None:
self.title = title
self.actions = actions or self.actions or []
def get_context(self, context):
"""
Return the context data to be used when rendering the panel.
Parameters:
context (dict): The template context
"""
return {
'request': context.get('request'),
'object': context.get('object'),
'title': self.title,
'actions': self.actions,
'panel_class': self.__class__.__name__,
}
def render(self, context):
"""
Render the panel as HTML.
Parameters:
context (dict): The template context
"""
return render_to_string(self.template_name, self.get_context(context))
#
# Object-specific panels
#
class ObjectPanel(Panel):
"""
Base class for object-specific panels.
Parameters:
accessor (str): The dotted path in context data to the object being rendered (default: "object")
"""
accessor = 'object'
def __init__(self, accessor=None, **kwargs):
super().__init__(**kwargs)
if accessor is not None:
self.accessor = accessor
def get_context(self, 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(type):
def __new__(mcls, name, bases, namespace, **kwargs):
declared = {}
# Walk MRO parents (excluding `object`) for declared attributes
for base in reversed([b for b in bases if hasattr(b, "_attrs")]):
for key, attr in getattr(base, '_attrs', {}).items():
if key not in declared:
declared[key] = attr
# Add local declarations in the order they appear in the class body
for key, attr in namespace.items():
if isinstance(attr, attrs.ObjectAttribute):
declared[key] = attr
namespace['_attrs'] = declared
# Remove Attrs from the class namespace to keep things tidy
local_items = [key for key, attr in namespace.items() if isinstance(attr, attrs.ObjectAttribute)]
for key in local_items:
namespace.pop(key)
cls = super().__new__(mcls, name, bases, namespace, **kwargs)
return cls
class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
"""
A panel which displays selected attributes of an object.
Attributes are added to the panel by declaring ObjectAttribute instances in the class body (similar to fields on
a Django form). Attributes are displayed in the order they are declared.
Note that the `only` and `exclude` parameters are mutually exclusive.
Parameters:
only (list): If specified, only attributes in this list will be displayed
exclude (list): If specified, attributes in this list will be excluded from display
"""
template_name = 'ui/panels/object_attributes.html'
def __init__(self, only=None, exclude=None, **kwargs):
super().__init__(**kwargs)
# Set included/excluded attributes
if only is not None and exclude is not None:
raise ValueError("only and exclude cannot both be specified.")
self.only = only or []
self.exclude = exclude or []
@staticmethod
def _name_to_label(name):
"""
Format an attribute's name to be presented as a human-friendly label.
"""
label = name[:1].upper() + name[1:]
label = label.replace('_', ' ')
return _(label)
def get_context(self, context):
# Determine which attributes to display in the panel based on only/exclude args
attr_names = set(self._attrs.keys())
if self.only:
attr_names &= set(self.only)
elif self.exclude:
attr_names -= set(self.exclude)
ctx = super().get_context(context)
return {
**ctx,
'attrs': [
{
'label': attr.label or self._name_to_label(name),
'value': attr.render(ctx['object'], {'name': name}),
} for name, attr in self._attrs.items() if name in attr_names
],
}
class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta):
"""
An ObjectPanel with attributes common to OrganizationalModels. Includes `name` and `description` attributes.
"""
name = attrs.TextAttr('name', label=_('Name'))
description = attrs.TextAttr('description', label=_('Description'))
class NestedGroupObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta):
"""
An ObjectPanel with attributes common to NestedGroupObjects. Includes the `parent` attribute.
"""
parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True)
name = attrs.TextAttr('name', label=_('Name'))
description = attrs.TextAttr('description', label=_('Description'))
class CommentsPanel(ObjectPanel):
"""
A panel which displays comments associated with an object.
Parameters:
field_name (str): The name of the comment field on the object (default: "comments")
"""
template_name = 'ui/panels/comments.html'
title = _('Comments')
def __init__(self, field_name='comments', **kwargs):
super().__init__(**kwargs)
self.field_name = field_name
def get_context(self, 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.
Parameters:
field_name (str): The name of the JSON field on the object
copy_button (bool): Set to True (default) to include a copy-to-clipboard button
"""
template_name = 'ui/panels/json.html'
def __init__(self, field_name, copy_button=True, **kwargs):
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 {
**super().get_context(context),
'data': getattr(context['object'], self.field_name),
'field_name': self.field_name,
}
#
# Miscellaneous panels
#
class RelatedObjectsPanel(Panel):
"""
A panel which displays the types and counts of related objects.
"""
template_name = 'ui/panels/related_objects.html'
title = _('Related Objects')
def get_context(self, context):
return {
**super().get_context(context),
'related_models': context.get('related_models'),
}
class ObjectsTablePanel(Panel):
"""
A panel which displays a table of objects (rendered via HTMX).
Parameters:
model (str): The dotted label of the model to be added (e.g. "dcim.site")
filters (dict): A dictionary of arbitrary URL parameters to append to the table's URL. If the value of a key is
a callable, it will be passed the current template context.
"""
template_name = 'ui/panels/objects_table.html'
title = None
def __init__(self, model, filters=None, **kwargs):
super().__init__(**kwargs)
# Resolve the model class from its app.name label
try:
app_label, model_name = model.split('.')
self.model = apps.get_model(app_label, model_name)
except (ValueError, LookupError):
raise ValueError(f"Invalid model label: {model}")
self.filters = filters or {}
# If no title is specified, derive one from the model name
if self.title is None:
self.title = title(self.model._meta.verbose_name_plural)
def get_context(self, context):
url_params = {
k: v(context) if callable(v) else v for k, v in self.filters.items()
}
if 'return_url' not in url_params and 'object' in context:
url_params['return_url'] = context['object'].get_absolute_url()
return {
**super().get_context(context),
'viewname': get_viewname(self.model, 'list'),
'url_params': dict_to_querydict(url_params),
}
class TemplatePanel(Panel):
"""
A panel which renders custom content using an HTML template.
Parameters:
template_name (str): The name of the template to render
"""
def __init__(self, template_name, **kwargs):
super().__init__(**kwargs)
self.template_name = template_name
def render(self, context):
# Pass the entire context to the template
return render_to_string(self.template_name, context.flatten())
class PluginContentPanel(Panel):
"""
A panel which displays embedded plugin content.
Parameters:
method (str): The name of the plugin method to render (e.g. "left_page")
"""
def __init__(self, method, **kwargs):
super().__init__(**kwargs)
self.method = method
def render(self, context):
obj = context.get('object')
return _get_registered_content(obj, self.method, context)