Просмотр исходного кода

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

Jeremy Stretch 3 месяцев назад
Родитель
Сommit
281cb4f586

+ 10 - 10
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')

+ 16 - 24
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,

+ 115 - 51
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.

+ 2 - 2
netbox/templates/ui/panels/comments.html

@@ -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 %}

+ 0 - 0
netbox/templates/ui/panels/object.html → netbox/templates/ui/panels/object_attributes.html


+ 21 - 0
netbox/utilities/data.py

@@ -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