diff --git a/netbox/dcim/template_components/__init__.py b/netbox/dcim/template_components/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/dcim/template_components/object_panels.py b/netbox/dcim/template_components/object_panels.py new file mode 100644 index 000000000..b822b5b68 --- /dev/null +++ b/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) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2439a3c06..b146e3fe9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -12,13 +12,11 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import View from circuits.models import Circuit, CircuitTermination +from dcim.template_components.object_panels import DevicePanel from extras.views import ObjectConfigContextView, ObjectRenderConfigView from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * -from netbox.templates.components import ( - AttributesPanel, EmbeddedTemplate, GPSCoordinatesAttr, NestedObjectAttr, ObjectAttr, TextAttr, -) from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -2226,28 +2224,10 @@ class DeviceView(generic.ObjectView): else: 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 { 'vc_members': vc_members, 'svg_extra': f'highlight=id:{instance.pk}', - 'device_attrs': device_attrs, + 'device_panel': DevicePanel(instance, _('Device')), } diff --git a/netbox/netbox/templates/components.py b/netbox/netbox/templates/components.py index cb99b0e5a..09edf1d59 100644 --- a/netbox/netbox/templates/components.py +++ b/netbox/netbox/templates/components.py @@ -1,12 +1,143 @@ -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.utils.html import escape from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ from netbox.config import get_config +from utilities.string import title +# +# Attributes +# + +class Attr: + template_name = None + placeholder = mark_safe('') + + 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): + + def __init__(self, *args, style=None, **kwargs): + super().__init__(*args, **kwargs) + self.style = style + + def render(self, obj): + value = self._resolve_attr(obj, self.accessor) + if value in (None, ''): + return self.placeholder + if self.style: + return mark_safe(f'{escape(value)}') + return value + + +class ObjectAttr(Attr): + template_name = 'components/object.html' + + def __init__(self, *args, linkify=None, grouped_by=None, **kwargs): + super().__init__(*args, **kwargs) + self.linkify = linkify + self.grouped_by = grouped_by + + # Derive label from related object if not explicitly set + if self.label is None: + self.label = title(self.accessor) + + 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, { + 'object': value, + 'group': group, + 'linkify': self.linkify, + }) + + +class NestedObjectAttr(Attr): + template_name = 'components/nested_object.html' + + def __init__(self, *args, linkify=None, **kwargs): + super().__init__(*args, **kwargs) + self.linkify = linkify + + def render(self, obj): + value = self._resolve_attr(obj, self.accessor) + if value is None: + return self.placeholder + return render_to_string(self.template_name, { + 'nodes': value.get_ancestors(include_self=True), + 'linkify': self.linkify, + }) + + +class GPSCoordinatesAttr(Attr): + template_name = 'components/gps_coordinates.html' + + 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: + self.map_url = get_config().MAPS_URL + elif map_url: + self.map_url = map_url + else: + self.map_url = None + + 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 render_to_string(self.template_name, { + 'latitude': latitude, + 'longitude': longitude, + '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 +# + class Component(ABC): @abstractmethod @@ -17,123 +148,38 @@ class Component(ABC): return self.render() -# -# Attributes -# +class ObjectDetailsPanelMeta(ABCMeta): -class Attr(Component): - template_name = None - placeholder = mark_safe('') + 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 TextAttr(Attr): +class ObjectDetailsPanel(Component, metaclass=ObjectDetailsPanelMeta): + template_name = 'components/object_details_panel.html' - def __init__(self, value, style=None): - self.value = value - self.style = style - - def render(self): - if self.value in (None, ''): - return self.placeholder - if self.style: - return mark_safe(f'{escape(self.value)}') - return self.value - - -class ObjectAttr(Attr): - template_name = 'components/object.html' - - def __init__(self, obj, linkify=None, grouped_by=None, template_name=None): + def __init__(self, obj, title=None): self.object = obj - 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.title = title or obj._meta.verbose_name - def render(self): - if self.object is None: - return self.placeholder - - # 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 - - return render_to_string(self.template_name, { - 'object': self.object, - 'object_url': object_url, - 'group': self.group, - 'group_url': group_url, - }) - - -class NestedObjectAttr(Attr): - template_name = 'components/nested_object.html' - - def __init__(self, obj, linkify=None): - self.object = obj - self.linkify = linkify - - def render(self): - if not self.object: - return self.placeholder - return render_to_string(self.template_name, { - 'nodes': self.object.get_ancestors(include_self=True), - 'linkify': self.linkify, - }) - - -class GPSCoordinatesAttr(Attr): - template_name = 'components/gps_coordinates.html' - - def __init__(self, latitude, longitude, map_url=True): - self.latitude = latitude - self.longitude = longitude - if map_url is True: - self.map_url = get_config().MAPS_URL - elif map_url: - self.map_url = map_url - else: - self.map_url = None - - def render(self): - if not (self.latitude and self.longitude): - return self.placeholder - return render_to_string(self.template_name, { - 'latitude': self.latitude, - 'longitude': self.longitude, - 'map_url': self.map_url, - }) - - -# -# Components -# - -class AttributesPanel(Component): - template_name = 'components/attributes_panel.html' - - def __init__(self, title, attrs): - self.title = title - self.attrs = attrs + @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): return render_to_string(self.template_name, { 'title': self.title, - 'attrs': self.attrs, + 'attrs': self.attributes, }) - -class EmbeddedTemplate(Component): - - def __init__(self, template_name, context=None): - self.template_name = template_name - self.context = context or {} - - def render(self): - return render_to_string(self.template_name, self.context) + def __str__(self): + return self.render() diff --git a/netbox/templates/components/object.html b/netbox/templates/components/object.html index 53702adc6..55263138b 100644 --- a/netbox/templates/components/object.html +++ b/netbox/templates/components/object.html @@ -2,25 +2,13 @@ {# Display an object with its parent group #} {% else %} {# Display only the object #} - {% if object_url %} - {{ object }} - {% else %} - {{ object }} - {% endif %} + {% if linkify %}{{ object|linkify }}{% else %}{{ object }}{% endif %} {% endif %} diff --git a/netbox/templates/components/attributes_panel.html b/netbox/templates/components/object_details_panel.html similarity index 70% rename from netbox/templates/components/attributes_panel.html rename to netbox/templates/components/object_details_panel.html index 90c470b0d..def52f76a 100644 --- a/netbox/templates/components/attributes_panel.html +++ b/netbox/templates/components/object_details_panel.html @@ -1,11 +1,11 @@

{{ title }}

- {% for label, attr in attrs.items %} + {% for attr in attrs %} - + {% endfor %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 03666dee9..36700ccfe 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -177,7 +177,7 @@ {% plugin_left_page object %}
- {{ device_attrs }} + {{ device_panel }}

{% trans "Management" %}

{{ label }}{{ attr.label }} -
{{ attr }}
+
{{ attr.value }}
diff --git a/netbox/templates/dcim/device/attrs/parent_device.html b/netbox/templates/dcim/device/attrs/parent_device.html index e8674e23b..375a511c4 100644 --- a/netbox/templates/dcim/device/attrs/parent_device.html +++ b/netbox/templates/dcim/device/attrs/parent_device.html @@ -1,4 +1,4 @@ -{% if device.parent_bay %} +{% if value %}