diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 9d6e301b2..93043a005 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -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') diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 235d11a3a..1300b4702 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -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, diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index f339d77b0..970a4fd73 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -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. diff --git a/netbox/templates/ui/panels/comments.html b/netbox/templates/ui/panels/comments.html index d5f07a8cc..de32162ce 100644 --- a/netbox/templates/ui/panels/comments.html +++ b/netbox/templates/ui/panels/comments.html @@ -3,8 +3,8 @@ {% block panel_content %}