Browse Source

Change approach for declaring object panels

Jeremy Stretch 4 months ago
parent
commit
3890043b06

+ 0 - 0
netbox/dcim/template_components/__init__.py


+ 26 - 0
netbox/dcim/template_components/object_panels.py

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

+ 2 - 22
netbox/dcim/views.py

@@ -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')),
         }
         }
 
 
 
 

+ 112 - 66
netbox/netbox/templates/components.py

@@ -1,89 +1,91 @@
-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
-
-
-class Component(ABC):
-
-    @abstractmethod
-    def render(self):
-        pass
-
-    def __str__(self):
-        return self.render()
+from utilities.string import title
 
 
 
 
 #
 #
 # Attributes
 # Attributes
 #
 #
 
 
-class Attr(Component):
+class Attr:
     template_name = None
     template_name = None
     placeholder = mark_safe('<span class="text-muted">&mdash;</span>')
     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):
 class TextAttr(Attr):
 
 
-    def __init__(self, value, style=None):
-        self.value = value
+    def __init__(self, *args, style=None, **kwargs):
+        super().__init__(*args, **kwargs)
         self.style = style
         self.style = style
 
 
-    def render(self):
-        if self.value in (None, ''):
+    def render(self, obj):
+        value = self._resolve_attr(obj, self.accessor)
+        if value in (None, ''):
             return self.placeholder
             return self.placeholder
         if self.style:
         if self.style:
-            return mark_safe(f'<span class="{self.style}">{escape(self.value)}</span>')
-        return self.value
+            return mark_safe(f'<span class="{self.style}">{escape(value)}</span>')
+        return value
 
 
 
 
 class ObjectAttr(Attr):
 class ObjectAttr(Attr):
     template_name = 'components/object.html'
     template_name = 'components/object.html'
 
 
-    def __init__(self, obj, linkify=None, grouped_by=None, template_name=None):
-        self.object = obj
+    def __init__(self, *args, linkify=None, grouped_by=None, **kwargs):
+        super().__init__(*args, **kwargs)
         self.linkify = linkify
         self.linkify = linkify
-        self.group = getattr(obj, grouped_by, None) if grouped_by else None
-        self.template_name = template_name or self.template_name
+        self.grouped_by = grouped_by
 
 
-    def render(self):
-        if self.object is None:
-            return self.placeholder
+        # Derive label from related object if not explicitly set
+        if self.label is None:
+            self.label = title(self.accessor)
 
 
-        # Determine object & group URLs
-        # TODO: Add support for reverse() lookups
-        if self.linkify and hasattr(self.object, 'get_absolute_url'):
-            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
+    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, {
         return render_to_string(self.template_name, {
-            'object': self.object,
-            'object_url': object_url,
-            'group': self.group,
-            'group_url': group_url,
+            'object': value,
+            'group': group,
+            'linkify': self.linkify,
         })
         })
 
 
 
 
 class NestedObjectAttr(Attr):
 class NestedObjectAttr(Attr):
     template_name = 'components/nested_object.html'
     template_name = 'components/nested_object.html'
 
 
-    def __init__(self, obj, linkify=None):
-        self.object = obj
+    def __init__(self, *args, linkify=None, **kwargs):
+        super().__init__(*args, **kwargs)
         self.linkify = linkify
         self.linkify = linkify
 
 
-    def render(self):
-        if not self.object:
+    def render(self, obj):
+        value = self._resolve_attr(obj, self.accessor)
+        if value is None:
             return self.placeholder
             return self.placeholder
         return render_to_string(self.template_name, {
         return render_to_string(self.template_name, {
-            'nodes': self.object.get_ancestors(include_self=True),
+            'nodes': value.get_ancestors(include_self=True),
             'linkify': self.linkify,
             'linkify': self.linkify,
         })
         })
 
 
@@ -91,9 +93,11 @@ class NestedObjectAttr(Attr):
 class GPSCoordinatesAttr(Attr):
 class GPSCoordinatesAttr(Attr):
     template_name = 'components/gps_coordinates.html'
     template_name = 'components/gps_coordinates.html'
 
 
-    def __init__(self, latitude, longitude, map_url=True):
-        self.latitude = latitude
-        self.longitude = longitude
+    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:
         if map_url is True:
             self.map_url = get_config().MAPS_URL
             self.map_url = get_config().MAPS_URL
         elif map_url:
         elif map_url:
@@ -101,39 +105,81 @@ class GPSCoordinatesAttr(Attr):
         else:
         else:
             self.map_url = None
             self.map_url = None
 
 
-    def render(self):
-        if not (self.latitude and self.longitude):
+    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 self.placeholder
         return render_to_string(self.template_name, {
         return render_to_string(self.template_name, {
-            'latitude': self.latitude,
-            'longitude': self.longitude,
+            'latitude': latitude,
+            'longitude': longitude,
             'map_url': self.map_url,
             '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
 # Components
 #
 #
 
 
-class AttributesPanel(Component):
-    template_name = 'components/attributes_panel.html'
-
-    def __init__(self, title, attrs):
-        self.title = title
-        self.attrs = attrs
+class Component(ABC):
 
 
+    @abstractmethod
     def render(self):
     def render(self):
-        return render_to_string(self.template_name, {
-            'title': self.title,
-            'attrs': self.attrs,
-        })
+        pass
+
+    def __str__(self):
+        return self.render()
 
 
 
 
-class EmbeddedTemplate(Component):
+class ObjectDetailsPanelMeta(ABCMeta):
 
 
-    def __init__(self, template_name, context=None):
-        self.template_name = template_name
-        self.context = context or {}
+    def __new__(mcls, name, bases, attrs):
+        # Collect all declared attributes
+        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 ObjectDetailsPanel(Component, metaclass=ObjectDetailsPanelMeta):
+    template_name = 'components/object_details_panel.html'
+
+    def __init__(self, obj, title=None):
+        self.object = obj
+        self.title = title or obj._meta.verbose_name
+
+    @cached_property
+    def attributes(self):
+        return [
+            {
+                'label': attr.label or title(name),
+                'value': attr.render(self.object),
+            } for name, attr in self._attrs.items()
+        ]
 
 
     def render(self):
     def render(self):
-        return render_to_string(self.template_name, self.context)
+        return render_to_string(self.template_name, {
+            'title': self.title,
+            'attrs': self.attributes,
+        })
+
+    def __str__(self):
+        return self.render()

+ 3 - 15
netbox/templates/components/object.html

@@ -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 %}
-        <a href="{{ group_url }}">{{ group }}</a>
-      {% else %}
-        {{ object.group }}
-      {% endif %}
+      {% if linkify %}{{ group|linkify }}{% else %}{{ group }}{% endif %}
     </li>
     </li>
     <li class="breadcrumb-item">
     <li class="breadcrumb-item">
-      {% if object_url %}
-        <a href="{{ object_url }}">{{ object }}</a>
-      {% else %}
-        {{ object }}
-      {% endif %}
+      {% if linkify %}{{ object|linkify }}{% else %}{{ object }}{% endif %}
     </li>
     </li>
   </ol>
   </ol>
 {% else %}
 {% else %}
   {# Display only the object #}
   {# Display only the object #}
-  {% if object_url %}
-    <a href="{{ object_url }}">{{ object }}</a>
-  {% else %}
-    {{ object }}
-  {% endif %}
+  {% if linkify %}{{ object|linkify }}{% else %}{{ object }}{% endif %}
 {% endif %}
 {% endif %}

+ 3 - 3
netbox/templates/components/attributes_panel.html → netbox/templates/components/object_details_panel.html

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

+ 1 - 1
netbox/templates/dcim/device.html

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

+ 1 - 1
netbox/templates/dcim/device/attrs/parent_device.html

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

+ 7 - 7
netbox/templates/dcim/device/attrs/rack.html

@@ -1,15 +1,15 @@
 {% load i18n %}
 {% load i18n %}
-{% if device.rack %}
+{% if value %}
   <span>
   <span>
-    {{ device.rack|linkify }}
-    {% if device.rack and device.position %}
-      (U{{ device.position|floatformat }} / {{ device.get_face_display }})
-    {% elif device.rack and device.device_type.u_height %}
+    {{ value|linkify }}
+    {% if value and object.position %}
+      (U{{ object.position|floatformat }} / {{ object.get_face_display }})
+    {% 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 %}
-    <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" %}">
+  {% if value and object.position %}
+    <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 %}