From fd3a9a0c370d6b74a5255a9970b55f55827c6a9e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Oct 2025 17:14:55 -0400 Subject: [PATCH 01/37] Initial work on #20204 --- netbox/dcim/views.py | 24 ++- netbox/netbox/templates/__init__.py | 0 netbox/netbox/templates/components.py | 139 ++++++++++++++++++ .../components/attributes_panel.html | 13 ++ .../templates/components/gps_coordinates.html | 8 + .../templates/components/nested_object.html | 11 ++ netbox/templates/components/object.html | 26 ++++ netbox/templates/dcim/device.html | 1 + .../dcim/device/attrs/parent_device.html | 8 + netbox/templates/dcim/device/attrs/rack.html | 18 +++ 10 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 netbox/netbox/templates/__init__.py create mode 100644 netbox/netbox/templates/components.py create mode 100644 netbox/templates/components/attributes_panel.html create mode 100644 netbox/templates/components/gps_coordinates.html create mode 100644 netbox/templates/components/nested_object.html create mode 100644 netbox/templates/components/object.html create mode 100644 netbox/templates/dcim/device/attrs/parent_device.html create mode 100644 netbox/templates/dcim/device/attrs/rack.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a41078a112a..2439a3c06ec 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -16,6 +16,9 @@ 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 @@ -2223,9 +2226,28 @@ def get_extra_context(self, request, instance): 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}' + 'svg_extra': f'highlight=id:{instance.pk}', + 'device_attrs': device_attrs, } diff --git a/netbox/netbox/templates/__init__.py b/netbox/netbox/templates/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/netbox/templates/components.py b/netbox/netbox/templates/components.py new file mode 100644 index 00000000000..cb99b0e5a24 --- /dev/null +++ b/netbox/netbox/templates/components.py @@ -0,0 +1,139 @@ +from abc import ABC, abstractmethod + +from django.template.loader import render_to_string +from django.utils.html import escape +from django.utils.safestring import mark_safe + +from netbox.config import get_config + + +class Component(ABC): + + @abstractmethod + def render(self): + pass + + def __str__(self): + return self.render() + + +# +# Attributes +# + +class Attr(Component): + template_name = None + placeholder = mark_safe('') + + +class TextAttr(Attr): + + 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): + 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 + + 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 + + def render(self): + return render_to_string(self.template_name, { + 'title': self.title, + 'attrs': self.attrs, + }) + + +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) diff --git a/netbox/templates/components/attributes_panel.html b/netbox/templates/components/attributes_panel.html new file mode 100644 index 00000000000..90c470b0d41 --- /dev/null +++ b/netbox/templates/components/attributes_panel.html @@ -0,0 +1,13 @@ +
+

{{ title }}

+ + {% for label, attr in attrs.items %} + + + + + {% endfor %} +
{{ label }} +
{{ attr }}
+
+
diff --git a/netbox/templates/components/gps_coordinates.html b/netbox/templates/components/gps_coordinates.html new file mode 100644 index 00000000000..8e72f08bb6d --- /dev/null +++ b/netbox/templates/components/gps_coordinates.html @@ -0,0 +1,8 @@ +{% load i18n %} +{% load l10n %} +{{ latitude }}, {{ longitude }} +{% if map_url %} + + {% trans "Map" %} + +{% endif %} diff --git a/netbox/templates/components/nested_object.html b/netbox/templates/components/nested_object.html new file mode 100644 index 00000000000..8cae0818979 --- /dev/null +++ b/netbox/templates/components/nested_object.html @@ -0,0 +1,11 @@ + diff --git a/netbox/templates/components/object.html b/netbox/templates/components/object.html new file mode 100644 index 00000000000..53702adc66e --- /dev/null +++ b/netbox/templates/components/object.html @@ -0,0 +1,26 @@ +{% if group %} + {# Display an object with its parent group #} + +{% else %} + {# Display only the object #} + {% if object_url %} + {{ object }} + {% else %} + {{ object }} + {% endif %} +{% endif %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index f8b8e95c2c1..03666dee9aa 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -177,6 +177,7 @@

{% plugin_left_page object %}
+ {{ device_attrs }}

{% trans "Management" %}

diff --git a/netbox/templates/dcim/device/attrs/parent_device.html b/netbox/templates/dcim/device/attrs/parent_device.html new file mode 100644 index 00000000000..e8674e23bbc --- /dev/null +++ b/netbox/templates/dcim/device/attrs/parent_device.html @@ -0,0 +1,8 @@ +{% if device.parent_bay %} + +{% else %} + {{ ''|placeholder }} +{% endif %} diff --git a/netbox/templates/dcim/device/attrs/rack.html b/netbox/templates/dcim/device/attrs/rack.html new file mode 100644 index 00000000000..41a031bad3d --- /dev/null +++ b/netbox/templates/dcim/device/attrs/rack.html @@ -0,0 +1,18 @@ +{% load i18n %} +{% if device.rack %} + + {{ 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 %} + {% trans "Not racked" %} + {% endif %} + + {% if device.rack and device.position %} + + + + {% endif %} +{% else %} + {{ ''|placeholder }} +{% endif %} From 3890043b0616f15c5f6af78e81c3c37d0eed52df Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Oct 2025 10:46:22 -0400 Subject: [PATCH 02/37] Change approach for declaring object panels --- netbox/dcim/template_components/__init__.py | 0 .../dcim/template_components/object_panels.py | 26 +++ netbox/dcim/views.py | 24 +-- netbox/netbox/templates/components.py | 178 +++++++++++------- netbox/templates/components/object.html | 18 +- ...s_panel.html => object_details_panel.html} | 6 +- netbox/templates/dcim/device.html | 2 +- .../dcim/device/attrs/parent_device.html | 2 +- netbox/templates/dcim/device/attrs/rack.html | 14 +- 9 files changed, 155 insertions(+), 115 deletions(-) create mode 100644 netbox/dcim/template_components/__init__.py create mode 100644 netbox/dcim/template_components/object_panels.py rename netbox/templates/components/{attributes_panel.html => object_details_panel.html} (70%) diff --git a/netbox/dcim/template_components/__init__.py b/netbox/dcim/template_components/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/dcim/template_components/object_panels.py b/netbox/dcim/template_components/object_panels.py new file mode 100644 index 00000000000..b822b5b68dd --- /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 2439a3c06ec..b146e3fe90a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -12,13 +12,11 @@ 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 @@ def get_extra_context(self, request, instance): 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 cb99b0e5a24..09edf1d5997 100644 --- a/netbox/netbox/templates/components.py +++ b/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.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 - - -class Component(ABC): - - @abstractmethod - def render(self): - pass - - def __str__(self): - return self.render() +from utilities.string import title # # Attributes # -class Attr(Component): +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, value, style=None): - self.value = value + def __init__(self, *args, style=None, **kwargs): + super().__init__(*args, **kwargs) 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 if self.style: - return mark_safe(f'{escape(self.value)}') - return self.value + return mark_safe(f'{escape(value)}') + return value class ObjectAttr(Attr): 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.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, { - 'object': self.object, - 'object_url': object_url, - 'group': self.group, - 'group_url': group_url, + 'object': value, + 'group': group, + 'linkify': self.linkify, }) class NestedObjectAttr(Attr): 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 - 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 render_to_string(self.template_name, { - 'nodes': self.object.get_ancestors(include_self=True), + 'nodes': value.get_ancestors(include_self=True), 'linkify': self.linkify, }) @@ -91,9 +93,11 @@ def render(self): class GPSCoordinatesAttr(Attr): 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: self.map_url = get_config().MAPS_URL elif map_url: @@ -101,39 +105,81 @@ def __init__(self, latitude, longitude, map_url=True): else: 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 render_to_string(self.template_name, { - 'latitude': self.latitude, - 'longitude': self.longitude, + '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 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): - 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): - 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() diff --git a/netbox/templates/components/object.html b/netbox/templates/components/object.html index 53702adc66e..55263138b42 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 90c470b0d41..def52f76a2c 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 03666dee9aa..36700ccfe6f 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 e8674e23bbc..375a511c485 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 %}
- - - - - - - - - - - - - {% if object.virtual_chassis %} - - - - - {% endif %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Region" %}{% nested_tree object.site.region %}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Virtual Chassis" %}{{ object.virtual_chassis|linkify }}
{% trans "Rack" %} - {% if object.rack %} - {{ object.rack|linkify }} - - - - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Position" %} - {% if object.parent_bay %} - {% with object.parent_bay.device as parent %} - {{ parent|linkify }} / {{ object.parent_bay }} - {% if parent.position %} - (U{{ parent.position|floatformat }} / {{ parent.get_face_display }}) - {% endif %} - {% endwith %} - {% elif object.rack and object.position %} - U{{ object.position|floatformat }} / {{ object.get_face_display }} - {% elif object.rack and object.device_type.u_height %} - {% trans "Not racked" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "GPS Coordinates" %} - {% if object.latitude and object.longitude %} - {% if config.MAPS_URL %} - - {% endif %} - {{ object.latitude }}, {{ object.longitude }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Device Type" %} - {{ object.device_type|linkify:"full_name" }} ({{ object.device_type.u_height|floatformat }}U) -
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Airflow" %} - {{ object.get_airflow_display|placeholder }} -
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Config Template" %}{{ object.config_template|linkify|placeholder }}
-
+ {{ device_panel }} {% if vc_members %}

@@ -177,83 +69,7 @@

{% plugin_left_page object %}

- {{ device_panel }} -
-

{% trans "Management" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - {% if object.cluster %} - - - - - {% endif %} -
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Role" %}{{ object.role|linkify }}
{% trans "Platform" %}{{ object.platform|linkify|placeholder }}
{% trans "Primary IPv4" %} - {% if object.primary_ip4 %} - {{ object.primary_ip4.address.ip }} - {% if object.primary_ip4.nat_inside %} - ({% trans "NAT for" %} {{ object.primary_ip4.nat_inside.address.ip }}) - {% elif object.primary_ip4.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "primary_ip4" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Primary IPv6" %} - {% if object.primary_ip6 %} - {{ object.primary_ip6.address.ip }} - {% if object.primary_ip6.nat_inside %} - ({% trans "NAT for" %} {{ object.primary_ip6.nat_inside.address.ip }}) - {% elif object.primary_ip6.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "primary_ip6" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
Out-of-band IP - {% if object.oob_ip %} - {{ object.oob_ip.address.ip }} - {% if object.oob_ip.nat_inside %} - ({% trans "NAT for" %} {{ object.oob_ip.nat_inside.address.ip }}) - {% elif object.oob_ip.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.oob_ip.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "oob_ip" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Cluster" %} - {% if object.cluster.group %} - {{ object.cluster.group|linkify }} / - {% endif %} - {{ object.cluster|linkify }} -
-
+ {{ management_panel }} {% if object.powerports.exists and object.poweroutlets.exists %}

{% trans "Power Utilization" %}

diff --git a/netbox/templates/dcim/device/attrs/ipaddress.html b/netbox/templates/dcim/device/attrs/ipaddress.html new file mode 100644 index 00000000000..2af5dab6c2c --- /dev/null +++ b/netbox/templates/dcim/device/attrs/ipaddress.html @@ -0,0 +1,11 @@ +{# TODO: Add copy-to-clipboard button #} +{% load i18n %} +{{ value.address.ip }} +{% if value.nat_inside %} + ({% trans "NAT for" %} {{ value.nat_inside.address.ip }}) +{% elif value.nat_outside.exists %} + ({% trans "NAT" %}: {% for nat in value.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) +{% endif %} + + + diff --git a/netbox/templates/dcim/device/attrs/parent_device.html b/netbox/templates/dcim/device/attrs/parent_device.html index 375a511c485..6351f792a2f 100644 --- a/netbox/templates/dcim/device/attrs/parent_device.html +++ b/netbox/templates/dcim/device/attrs/parent_device.html @@ -1,8 +1,10 @@ -{% if value %} - -{% else %} - {{ ''|placeholder }} +{% load i18n %} + +{% if value.device.position %} + + + {% endif %} diff --git a/netbox/templates/dcim/device/attrs/rack.html b/netbox/templates/dcim/device/attrs/rack.html index b1078025211..d939e9ca3ba 100644 --- a/netbox/templates/dcim/device/attrs/rack.html +++ b/netbox/templates/dcim/device/attrs/rack.html @@ -1,18 +1,14 @@ {% load i18n %} -{% if value %} - - {{ value|linkify }} - {% if value and object.position %} - (U{{ object.position|floatformat }} / {{ object.get_face_display }}) - {% elif value and object.device_type.u_height %} - {% trans "Not racked" %} - {% endif %} - + + {{ value|linkify }} {% if value and object.position %} - - - + (U{{ object.position|floatformat }} / {{ object.get_face_display }}) + {% elif value and object.device_type.u_height %} + {% trans "Not racked" %} {% endif %} -{% else %} - {{ ''|placeholder }} + +{% if object.position %} + + + {% endif %} From 1acd567706eeed03fe1f6c865c113301605f903c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Oct 2025 15:29:23 -0400 Subject: [PATCH 05/37] Add site panel --- netbox/dcim/ui/panels.py | 13 +++ netbox/dcim/views.py | 1 + netbox/netbox/ui/attrs.py | 38 ++++++++ .../templates/components/attrs/address.html | 8 ++ .../templates/components/attrs/timezone.html | 6 ++ netbox/templates/dcim/site.html | 96 ++----------------- 6 files changed, 72 insertions(+), 90 deletions(-) create mode 100644 netbox/templates/components/attrs/address.html create mode 100644 netbox/templates/components/attrs/timezone.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 8f0d3b90d2e..56a557acbf2 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -44,3 +44,16 @@ class DeviceManagementPanel(ObjectPanel): label=_('Out-of-band IP'), template_name='dcim/device/attrs/ipaddress.html', ) + + +class SitePanel(ObjectPanel): + region = attrs.NestedObjectAttr('region', label=_('Region'), linkify=True) + group = attrs.NestedObjectAttr('group', label=_('Group'), linkify=True) + status = attrs.ChoiceAttr('status', label=_('Status')) + tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') + facility = attrs.TextAttr('facility', label=_('Facility')) + description = attrs.TextAttr('description', label=_('Description')) + timezone = attrs.TimezoneAttr('time_zone', label=_('Timezone')) + physical_address = attrs.AddressAttr('physical_address', label=_('Physical address'), map_url=True) + shipping_address = attrs.AddressAttr('shipping_address', label=_('Shipping address'), map_url=True) + gps_coordinates = attrs.GPSCoordinatesAttr() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d7ebeca1141..4e751d33ff7 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -465,6 +465,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): def get_extra_context(self, request, instance): return { + 'site_panel': panels.SitePanel(instance, _('Site')), 'related_models': self.get_related_models( request, instance, diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 233beef60cb..679cc5d66a9 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -123,6 +123,30 @@ def render(self, obj, context=None): }) +class AddressAttr(Attr): + template_name = 'components/attrs/address.html' + + def __init__(self, *args, map_url=True, **kwargs): + super().__init__(*args, **kwargs) + 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, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + if value in (None, ''): + return self.placeholder + return render_to_string(self.template_name, { + **context, + 'value': value, + 'map_url': self.map_url, + }) + + class GPSCoordinatesAttr(Attr): template_name = 'components/attrs/gps_coordinates.html' @@ -152,6 +176,20 @@ def render(self, obj, context=None): }) +class TimezoneAttr(Attr): + template_name = 'components/attrs/timezone.html' + + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + if value in (None, ''): + return self.placeholder + return render_to_string(self.template_name, { + **context, + 'value': value, + }) + + class TemplatedAttr(Attr): def __init__(self, *args, context=None, **kwargs): diff --git a/netbox/templates/components/attrs/address.html b/netbox/templates/components/attrs/address.html new file mode 100644 index 00000000000..08f46fc43cd --- /dev/null +++ b/netbox/templates/components/attrs/address.html @@ -0,0 +1,8 @@ +{% load i18n %} +{% load l10n %} +{{ value|linebreaksbr }} +{% if map_url %} + + {% trans "Map" %} + +{% endif %} diff --git a/netbox/templates/components/attrs/timezone.html b/netbox/templates/components/attrs/timezone.html new file mode 100644 index 00000000000..7492d72ada9 --- /dev/null +++ b/netbox/templates/components/attrs/timezone.html @@ -0,0 +1,6 @@ +{% load i18n %} +{% load tz %} +
+ {{ value }} ({% trans "UTC" %} {{ value|tzoffset }})
+ {% trans "Local time" %}: {% timezone value %}{% now 'Y-m-d H:i' %}{% endtimezone %} +
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index cf65961d966..f4e9a5d0248 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -24,100 +24,16 @@ {% block content %}
-
-

{% trans "Site" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Region" %} - {% nested_tree object.region %} -
{% trans "Group" %} - {% nested_tree object.group %} -
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Facility" %}{{ object.facility|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Time Zone" %} - {% if object.time_zone %} - {{ object.time_zone }} ({% trans "UTC" %} {{ object.time_zone|tzoffset }})
- {% trans "Site time" %}: {% timezone object.time_zone %}{% now 'Y-m-d H:i' %}{% endtimezone %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Physical Address" %} - {% if object.physical_address %} - {{ object.physical_address|linebreaksbr }} - {% if config.MAPS_URL %} - - {% trans "Map" %} - - {% endif %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Shipping Address" %}{{ object.shipping_address|linebreaksbr|placeholder }}
{% trans "GPS Coordinates" %} - {% if object.latitude and object.longitude %} - {% if config.MAPS_URL %} - - {% endif %} - {{ object.latitude }}, {{ object.longitude }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
+ {{ site_panel }} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' with filter_name='site_id' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} +
+
+ {% include 'inc/panels/related_objects.html' with filter_name='site_id' %} + {% include 'inc/panels/image_attachments.html' %} + {% plugin_right_page object %}
From 83de78419632ccb74f75730cd80677f608933b54 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Oct 2025 15:34:44 -0400 Subject: [PATCH 06/37] Add region & site group panels --- netbox/dcim/views.py | 3 +++ netbox/netbox/ui/components.py | 8 ++++++++ netbox/templates/dcim/region.html | 18 +----------------- netbox/templates/dcim/sitegroup.html | 18 +----------------- 4 files changed, 13 insertions(+), 34 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4e751d33ff7..e32a59a0b8b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -17,6 +17,7 @@ from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * +from netbox.ui.components import NestedGroupObjectPanel from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -227,6 +228,7 @@ def get_extra_context(self, request, instance): regions = instance.get_descendants(include_self=True) return { + 'region_panel': NestedGroupObjectPanel(instance, _('Region')), 'related_models': self.get_related_models( request, regions, @@ -338,6 +340,7 @@ def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) return { + 'sitegroup_panel': NestedGroupObjectPanel(instance, _('Site Group')), 'related_models': self.get_related_models( request, groups, diff --git a/netbox/netbox/ui/components.py b/netbox/netbox/ui/components.py index c3aa1ae6670..206086f17b7 100644 --- a/netbox/netbox/ui/components.py +++ b/netbox/netbox/ui/components.py @@ -2,7 +2,9 @@ from functools import cached_property from django.template.loader import render_to_string +from django.utils.translation import gettext_lazy as _ +from netbox.ui import attrs from netbox.ui.attrs import Attr from utilities.string import title @@ -52,3 +54,9 @@ def render(self): def __str__(self): return self.render() + + +class NestedGroupObjectPanel(ObjectPanel): + name = attrs.TextAttr('name', label=_('Name')) + description = attrs.TextAttr('description', label=_('Description')) + parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index f11868b0a29..28f4b6127dd 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -22,23 +22,7 @@ {% block content %}
-
-

{% trans "Region" %}

- - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
-
+ {{ region_panel }} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index dc9aca6f5ae..63e240dc64d 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -22,23 +22,7 @@ {% block content %}
-
-

{% trans "Site Group" %}

- - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
-
+ {{ sitegroup_panel }} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} From 2a629d6f745b8e6840a9926326033ed10f326c8a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Oct 2025 16:25:42 -0400 Subject: [PATCH 07/37] Enable panel inheritance; add location panel --- netbox/dcim/ui/panels.py | 16 ++++++++---- netbox/dcim/views.py | 1 + netbox/netbox/ui/components.py | 32 +++++++++++++++++------ netbox/templates/dcim/location.html | 39 +---------------------------- 4 files changed, 37 insertions(+), 51 deletions(-) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 56a557acbf2..c7aa36a2358 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -1,10 +1,16 @@ from django.utils.translation import gettext_lazy as _ -from netbox.ui import attrs -from netbox.ui.components import ObjectPanel +from netbox.ui import attrs, components -class DevicePanel(ObjectPanel): +class LocationPanel(components.NestedGroupObjectPanel): + site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') + status = attrs.ChoiceAttr('status', label=_('Status')) + tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') + facility = attrs.TextAttr('facility', label=_('Facility')) + + +class DevicePanel(components.ObjectPanel): region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) @@ -25,7 +31,7 @@ class DevicePanel(ObjectPanel): config_template = attrs.ObjectAttr('config_template', label=_('Config template'), linkify=True) -class DeviceManagementPanel(ObjectPanel): +class DeviceManagementPanel(components.ObjectPanel): status = attrs.ChoiceAttr('status', label=_('Status')) role = attrs.NestedObjectAttr('role', label=_('Role'), linkify=True, max_depth=3) platform = attrs.NestedObjectAttr('platform', label=_('Platform'), linkify=True, max_depth=3) @@ -46,7 +52,7 @@ class DeviceManagementPanel(ObjectPanel): ) -class SitePanel(ObjectPanel): +class SitePanel(components.ObjectPanel): region = attrs.NestedObjectAttr('region', label=_('Region'), linkify=True) group = attrs.NestedObjectAttr('group', label=_('Group'), linkify=True) status = attrs.ChoiceAttr('status', label=_('Status')) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e32a59a0b8b..eb0e42ee1b3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -571,6 +571,7 @@ def get_extra_context(self, request, instance): locations = instance.get_descendants(include_self=True) location_content_type = ContentType.objects.get_for_model(instance) return { + 'location_panel': panels.LocationPanel(instance, _('Location')), 'related_models': self.get_related_models( request, locations, diff --git a/netbox/netbox/ui/components.py b/netbox/netbox/ui/components.py index 206086f17b7..156eb03047a 100644 --- a/netbox/netbox/ui/components.py +++ b/netbox/netbox/ui/components.py @@ -21,13 +21,29 @@ def __str__(self): class ObjectDetailsPanelMeta(ABCMeta): - 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) + 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, Attr): + 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, Attr)] + for key in local_items: + namespace.pop(key) + + cls = super().__new__(mcls, name, bases, namespace, **kwargs) + return cls class ObjectPanel(Component, metaclass=ObjectDetailsPanelMeta): @@ -56,7 +72,7 @@ def __str__(self): return self.render() -class NestedGroupObjectPanel(ObjectPanel): +class NestedGroupObjectPanel(ObjectPanel, metaclass=ObjectDetailsPanelMeta): name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index dfd0c32b31a..861a2adefd2 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -22,44 +22,7 @@ {% block content %}
-
-

{% trans "Location" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Facility" %}{{ object.facility|placeholder }}
-
+ {{ location_panel }} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} From 90874adf148ef300dd639f5aafa9a706dfa500dd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Oct 2025 16:53:00 -0400 Subject: [PATCH 08/37] Add rack panel --- netbox/dcim/ui/panels.py | 43 +++++++++++++------ netbox/dcim/views.py | 1 + netbox/netbox/ui/attrs.py | 12 ++++++ .../components/attrs/utilization.html | 2 + netbox/templates/dcim/rack.html | 1 + 5 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 netbox/templates/components/attrs/utilization.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index c7aa36a2358..6ffd2e3d4f2 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -3,6 +3,19 @@ from netbox.ui import attrs, components +class SitePanel(components.ObjectPanel): + region = attrs.NestedObjectAttr('region', label=_('Region'), linkify=True) + group = attrs.NestedObjectAttr('group', label=_('Group'), linkify=True) + status = attrs.ChoiceAttr('status', label=_('Status')) + tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') + facility = attrs.TextAttr('facility', label=_('Facility')) + description = attrs.TextAttr('description', label=_('Description')) + timezone = attrs.TimezoneAttr('time_zone', label=_('Timezone')) + physical_address = attrs.AddressAttr('physical_address', label=_('Physical address'), map_url=True) + shipping_address = attrs.AddressAttr('shipping_address', label=_('Shipping address'), map_url=True) + gps_coordinates = attrs.GPSCoordinatesAttr() + + class LocationPanel(components.NestedGroupObjectPanel): site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') status = attrs.ChoiceAttr('status', label=_('Status')) @@ -10,6 +23,23 @@ class LocationPanel(components.NestedGroupObjectPanel): facility = attrs.TextAttr('facility', label=_('Facility')) +class RackPanel(components.ObjectPanel): + region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) + site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') + location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) + facility = attrs.TextAttr('facility', label=_('Facility')) + tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') + status = attrs.ChoiceAttr('status', label=_('Status')) + rack_type = attrs.ObjectAttr('rack_type', label=_('Rack type'), linkify=True, grouped_by='manufacturer') + role = attrs.ObjectAttr('role', label=_('Role'), linkify=True) + description = attrs.TextAttr('description', label=_('Description')) + serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) + asset_tag = attrs.TextAttr('asset_tag', label=_('Asset tag'), style='font-monospace', copy_button=True) + airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) + space_utilization = attrs.UtilizationAttr('get_utilization', label=_('Space utilization')) + power_utilization = attrs.UtilizationAttr('get_power_utilization', label=_('Power utilization')) + + class DevicePanel(components.ObjectPanel): region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') @@ -50,16 +80,3 @@ class DeviceManagementPanel(components.ObjectPanel): label=_('Out-of-band IP'), template_name='dcim/device/attrs/ipaddress.html', ) - - -class SitePanel(components.ObjectPanel): - region = attrs.NestedObjectAttr('region', label=_('Region'), linkify=True) - group = attrs.NestedObjectAttr('group', label=_('Group'), linkify=True) - status = attrs.ChoiceAttr('status', label=_('Status')) - tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') - facility = attrs.TextAttr('facility', label=_('Facility')) - description = attrs.TextAttr('description', label=_('Description')) - timezone = attrs.TimezoneAttr('time_zone', label=_('Timezone')) - physical_address = attrs.AddressAttr('physical_address', label=_('Physical address'), map_url=True) - shipping_address = attrs.AddressAttr('shipping_address', label=_('Shipping address'), map_url=True) - gps_coordinates = attrs.GPSCoordinatesAttr() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index eb0e42ee1b3..f59d8babd52 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -868,6 +868,7 @@ def get_extra_context(self, request, instance): ]) return { + 'rack_panel': panels.RackPanel(instance, _('Rack')), 'related_models': self.get_related_models( request, instance, diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 679cc5d66a9..293cfa29e48 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -210,3 +210,15 @@ def render(self, obj, context=None): 'value': value, } ) + + +class UtilizationAttr(Attr): + template_name = 'components/attrs/utilization.html' + + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + return render_to_string(self.template_name, { + **context, + 'value': value, + }) diff --git a/netbox/templates/components/attrs/utilization.html b/netbox/templates/components/attrs/utilization.html new file mode 100644 index 00000000000..6e1db73f1b2 --- /dev/null +++ b/netbox/templates/components/attrs/utilization.html @@ -0,0 +1,2 @@ +{% load helpers %} +{% utilization_graph value %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index eec4d63a583..e0f60eb509b 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -120,6 +120,7 @@

{% trans "Weight" %}

{% plugin_left_page object %}
+ {{ rack_panel }}
From 3fd4664a7605257c8268582ee2db9ddedd08d9dd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Oct 2025 13:48:24 -0400 Subject: [PATCH 10/37] Implement layout declaration under view --- netbox/dcim/ui/panels.py | 12 ++--- netbox/dcim/views.py | 15 +++++-- netbox/netbox/ui/attrs.py | 16 +++---- netbox/netbox/ui/layout.py | 44 +++++++++++++++++++ netbox/netbox/ui/{components.py => panels.py} | 40 ++++++++--------- netbox/netbox/views/generic/object_views.py | 5 +++ netbox/templates/generic/object.html | 15 ++++++- .../{components => ui}/attrs/address.html | 0 .../{components => ui}/attrs/choice.html | 0 .../attrs/gps_coordinates.html | 0 .../attrs/nested_object.html | 0 .../{components => ui}/attrs/object.html | 0 .../{components => ui}/attrs/text.html | 0 .../{components => ui}/attrs/timezone.html | 0 .../{components => ui}/attrs/utilization.html | 0 .../panels/object.html} | 0 .../utilities/templatetags/builtins/tags.py | 6 +++ 17 files changed, 113 insertions(+), 40 deletions(-) create mode 100644 netbox/netbox/ui/layout.py rename netbox/netbox/ui/{components.py => panels.py} (69%) rename netbox/templates/{components => ui}/attrs/address.html (100%) rename netbox/templates/{components => ui}/attrs/choice.html (100%) rename netbox/templates/{components => ui}/attrs/gps_coordinates.html (100%) rename netbox/templates/{components => ui}/attrs/nested_object.html (100%) rename netbox/templates/{components => ui}/attrs/object.html (100%) rename netbox/templates/{components => ui}/attrs/text.html (100%) rename netbox/templates/{components => ui}/attrs/timezone.html (100%) rename netbox/templates/{components => ui}/attrs/utilization.html (100%) rename netbox/templates/{components/object_details_panel.html => ui/panels/object.html} (100%) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 6ffd2e3d4f2..0ed917c551f 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -1,9 +1,9 @@ from django.utils.translation import gettext_lazy as _ -from netbox.ui import attrs, components +from netbox.ui import attrs, panels -class SitePanel(components.ObjectPanel): +class SitePanel(panels.ObjectPanel): region = attrs.NestedObjectAttr('region', label=_('Region'), linkify=True) group = attrs.NestedObjectAttr('group', label=_('Group'), linkify=True) status = attrs.ChoiceAttr('status', label=_('Status')) @@ -16,14 +16,14 @@ class SitePanel(components.ObjectPanel): gps_coordinates = attrs.GPSCoordinatesAttr() -class LocationPanel(components.NestedGroupObjectPanel): +class LocationPanel(panels.NestedGroupObjectPanel): site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') status = attrs.ChoiceAttr('status', label=_('Status')) tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') facility = attrs.TextAttr('facility', label=_('Facility')) -class RackPanel(components.ObjectPanel): +class RackPanel(panels.ObjectPanel): region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) @@ -40,7 +40,7 @@ class RackPanel(components.ObjectPanel): power_utilization = attrs.UtilizationAttr('get_power_utilization', label=_('Power utilization')) -class DevicePanel(components.ObjectPanel): +class DevicePanel(panels.ObjectPanel): region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) @@ -61,7 +61,7 @@ class DevicePanel(components.ObjectPanel): config_template = attrs.ObjectAttr('config_template', label=_('Config template'), linkify=True) -class DeviceManagementPanel(components.ObjectPanel): +class DeviceManagementPanel(panels.ObjectPanel): status = attrs.ChoiceAttr('status', label=_('Status')) role = attrs.NestedObjectAttr('role', label=_('Role'), linkify=True, max_depth=3) platform = attrs.NestedObjectAttr('platform', label=_('Platform'), linkify=True, max_depth=3) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f59d8babd52..d5382fa2672 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -17,7 +17,7 @@ from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * -from netbox.ui.components import NestedGroupObjectPanel +from netbox.ui import layout from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -228,7 +228,7 @@ def get_extra_context(self, request, instance): regions = instance.get_descendants(include_self=True) return { - 'region_panel': NestedGroupObjectPanel(instance, _('Region')), + 'region_panel': panels.NestedGroupObjectPanel(instance, _('Region')), 'related_models': self.get_related_models( request, regions, @@ -340,7 +340,7 @@ def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) return { - 'sitegroup_panel': NestedGroupObjectPanel(instance, _('Site Group')), + 'sitegroup_panel': panels.NestedGroupObjectPanel(instance, _('Site Group')), 'related_models': self.get_related_models( request, groups, @@ -465,10 +465,17 @@ class SiteListView(generic.ObjectListView): @register_model_view(Site) class SiteView(GetRelatedModelsMixin, generic.ObjectView): queryset = Site.objects.prefetch_related('tenant__group') + layout = layout.Layout( + layout.Row( + layout.Column( + panels.SitePanel(_('Site')) + ), + ) + ) def get_extra_context(self, request, instance): return { - 'site_panel': panels.SitePanel(instance, _('Site')), + # 'site_panel': panels.SitePanel(instance, _('Site')), 'related_models': self.get_related_models( request, instance, diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 293cfa29e48..2e931d7141b 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -35,7 +35,7 @@ def _resolve_attr(obj, path): class TextAttr(Attr): - template_name = 'components/attrs/text.html' + template_name = 'ui/attrs/text.html' def __init__(self, *args, style=None, copy_button=False, **kwargs): super().__init__(*args, **kwargs) @@ -56,7 +56,7 @@ def render(self, obj, context=None): class ChoiceAttr(Attr): - template_name = 'components/attrs/choice.html' + template_name = 'ui/attrs/choice.html' def render(self, obj, context=None): context = context or {} @@ -78,7 +78,7 @@ def render(self, obj, context=None): class ObjectAttr(Attr): - template_name = 'components/attrs/object.html' + template_name = 'ui/attrs/object.html' def __init__(self, *args, linkify=None, grouped_by=None, **kwargs): super().__init__(*args, **kwargs) @@ -101,7 +101,7 @@ def render(self, obj, context=None): class NestedObjectAttr(Attr): - template_name = 'components/attrs/nested_object.html' + template_name = 'ui/attrs/nested_object.html' def __init__(self, *args, linkify=None, max_depth=None, **kwargs): super().__init__(*args, **kwargs) @@ -124,7 +124,7 @@ def render(self, obj, context=None): class AddressAttr(Attr): - template_name = 'components/attrs/address.html' + template_name = 'ui/attrs/address.html' def __init__(self, *args, map_url=True, **kwargs): super().__init__(*args, **kwargs) @@ -148,7 +148,7 @@ def render(self, obj, context=None): class GPSCoordinatesAttr(Attr): - template_name = 'components/attrs/gps_coordinates.html' + template_name = 'ui/attrs/gps_coordinates.html' def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs): kwargs.setdefault('label', _('GPS Coordinates')) @@ -177,7 +177,7 @@ def render(self, obj, context=None): class TimezoneAttr(Attr): - template_name = 'components/attrs/timezone.html' + template_name = 'ui/attrs/timezone.html' def render(self, obj, context=None): context = context or {} @@ -213,7 +213,7 @@ def render(self, obj, context=None): class UtilizationAttr(Attr): - template_name = 'components/attrs/utilization.html' + template_name = 'ui/attrs/utilization.html' def render(self, obj, context=None): context = context or {} diff --git a/netbox/netbox/ui/layout.py b/netbox/netbox/ui/layout.py new file mode 100644 index 00000000000..1ff362e3216 --- /dev/null +++ b/netbox/netbox/ui/layout.py @@ -0,0 +1,44 @@ +from netbox.ui.panels import Panel + +__all__ = ( + 'Column', + 'Layout', + 'Row', +) + + +class Layout: + + def __init__(self, *rows): + for i, row in enumerate(rows): + if type(row) is not Row: + raise TypeError(f"Row {i} must be a Row instance, not {type(row)}.") + self.rows = rows + + def render(self, context): + return ''.join([row.render(context) for row in self.rows]) + + +class Row: + template_name = 'ui/layout/row.html' + + def __init__(self, *columns): + for i, column in enumerate(columns): + if type(column) is not Column: + raise TypeError(f"Column {i} must be a Column instance, not {type(column)}.") + self.columns = columns + + def render(self, context): + return ''.join([column.render(context) for column in self.columns]) + + +class Column: + + def __init__(self, *panels): + for i, panel in enumerate(panels): + if not isinstance(panel, Panel): + raise TypeError(f"Panel {i} must be an instance of a Panel, not {type(panel)}.") + self.panels = panels + + def render(self, context): + return ''.join([panel.render(context) for panel in self.panels]) diff --git a/netbox/netbox/ui/components.py b/netbox/netbox/ui/panels.py similarity index 69% rename from netbox/netbox/ui/components.py rename to netbox/netbox/ui/panels.py index 156eb03047a..15cad0a6491 100644 --- a/netbox/netbox/ui/components.py +++ b/netbox/netbox/ui/panels.py @@ -1,5 +1,4 @@ from abc import ABC, ABCMeta, abstractmethod -from functools import cached_property from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ @@ -8,18 +7,21 @@ from netbox.ui.attrs import Attr from utilities.string import title +__all__ = ( + 'NestedGroupObjectPanel', + 'ObjectPanel', + 'Panel', +) -class Component(ABC): + +class Panel(ABC): @abstractmethod - def render(self): + def render(self, obj): pass - def __str__(self): - return self.render() - -class ObjectDetailsPanelMeta(ABCMeta): +class ObjectPanelMeta(ABCMeta): def __new__(mcls, name, bases, namespace, **kwargs): declared = {} @@ -46,33 +48,29 @@ def __new__(mcls, name, bases, namespace, **kwargs): return cls -class ObjectPanel(Component, metaclass=ObjectDetailsPanelMeta): - template_name = 'components/object_details_panel.html' +class ObjectPanel(Panel, metaclass=ObjectPanelMeta): + template_name = 'ui/panels/object.html' - def __init__(self, obj, title=None): - self.object = obj - self.title = title or obj._meta.verbose_name + def __init__(self, title=None): + self.title = title - @cached_property - def attributes(self): + def get_attributes(self, obj): return [ { 'label': attr.label or title(name), - 'value': attr.render(self.object, {'name': name}), + 'value': attr.render(obj, {'name': name}), } for name, attr in self._attrs.items() ] - def render(self): + def render(self, context): + obj = context.get('object') return render_to_string(self.template_name, { 'title': self.title, - 'attrs': self.attributes, + 'attrs': self.get_attributes(obj), }) - def __str__(self): - return self.render() - -class NestedGroupObjectPanel(ObjectPanel, metaclass=ObjectDetailsPanelMeta): +class NestedGroupObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 89719159209..eb1a4d3a980 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -47,6 +47,7 @@ class ObjectView(ActionsMixin, BaseObjectView): tab: A ViewTab instance for the view actions: An iterable of ObjectAction subclasses (see ActionsMixin) """ + layout = None tab = None actions = (CloneObject, EditObject, DeleteObject) @@ -58,6 +59,9 @@ def get_template_name(self): Return self.template_name if defined. Otherwise, dynamically resolve the template name using the queryset model's `app_label` and `model_name`. """ + # TODO: Temporarily allow layout to override template_name + if self.layout is not None: + return 'generic/object.html' if self.template_name is not None: return self.template_name model_opts = self.queryset.model._meta @@ -81,6 +85,7 @@ def get(self, request, **kwargs): 'object': instance, 'actions': actions, 'tab': self.tab, + 'layout': self.layout, **self.get_extra_context(request, instance), }) diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index df95a4a42b4..a9783178a57 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -122,7 +122,20 @@ {% plugin_alerts object %} {% endblock alerts %} -{% block content %}{% endblock %} +{% block content %} + {# Render panel layout declared on view class #} + {% for row in layout.rows %} +
+ {% for column in row.columns %} +
+ {% for panel in column.panels %} + {% render_panel panel %} + {% endfor %} +
+ {% endfor %} +
+ {% endfor %} +{% endblock %} {% block modals %} {% include 'inc/htmx_modal.html' %} diff --git a/netbox/templates/components/attrs/address.html b/netbox/templates/ui/attrs/address.html similarity index 100% rename from netbox/templates/components/attrs/address.html rename to netbox/templates/ui/attrs/address.html diff --git a/netbox/templates/components/attrs/choice.html b/netbox/templates/ui/attrs/choice.html similarity index 100% rename from netbox/templates/components/attrs/choice.html rename to netbox/templates/ui/attrs/choice.html diff --git a/netbox/templates/components/attrs/gps_coordinates.html b/netbox/templates/ui/attrs/gps_coordinates.html similarity index 100% rename from netbox/templates/components/attrs/gps_coordinates.html rename to netbox/templates/ui/attrs/gps_coordinates.html diff --git a/netbox/templates/components/attrs/nested_object.html b/netbox/templates/ui/attrs/nested_object.html similarity index 100% rename from netbox/templates/components/attrs/nested_object.html rename to netbox/templates/ui/attrs/nested_object.html diff --git a/netbox/templates/components/attrs/object.html b/netbox/templates/ui/attrs/object.html similarity index 100% rename from netbox/templates/components/attrs/object.html rename to netbox/templates/ui/attrs/object.html diff --git a/netbox/templates/components/attrs/text.html b/netbox/templates/ui/attrs/text.html similarity index 100% rename from netbox/templates/components/attrs/text.html rename to netbox/templates/ui/attrs/text.html diff --git a/netbox/templates/components/attrs/timezone.html b/netbox/templates/ui/attrs/timezone.html similarity index 100% rename from netbox/templates/components/attrs/timezone.html rename to netbox/templates/ui/attrs/timezone.html diff --git a/netbox/templates/components/attrs/utilization.html b/netbox/templates/ui/attrs/utilization.html similarity index 100% rename from netbox/templates/components/attrs/utilization.html rename to netbox/templates/ui/attrs/utilization.html diff --git a/netbox/templates/components/object_details_panel.html b/netbox/templates/ui/panels/object.html similarity index 100% rename from netbox/templates/components/object_details_panel.html rename to netbox/templates/ui/panels/object.html diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 8a275f44bc9..92c68f05275 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -3,6 +3,7 @@ from django import template from django.templatetags.static import static +from django.utils.safestring import mark_safe from extras.choices import CustomFieldTypeChoices from utilities.querydict import dict_to_querydict @@ -179,3 +180,8 @@ def static_with_params(path, **params): # Reconstruct the URL with the new query string new_parsed = parsed._replace(query=new_query) return urlunparse(new_parsed) + + +@register.simple_tag(takes_context=True) +def render_panel(context, panel): + return mark_safe(panel.render(context)) From 77613b37b201381dc319c65b211899d92375127e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Oct 2025 14:38:33 -0400 Subject: [PATCH 11/37] Add panels for common inclusion templates --- netbox/dcim/views.py | 11 ++- netbox/netbox/ui/panels.py | 74 ++++++++++++++++++- netbox/templates/ui/panels/_base.html | 4 + netbox/templates/ui/panels/comments.html | 12 +++ netbox/templates/ui/panels/custom_fields.html | 31 ++++++++ .../ui/panels/image_attachments.html | 7 ++ netbox/templates/ui/panels/object.html | 7 +- .../templates/ui/panels/related_objects.html | 25 +++++++ netbox/templates/ui/panels/tags.html | 15 ++++ 9 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 netbox/templates/ui/panels/_base.html create mode 100644 netbox/templates/ui/panels/comments.html create mode 100644 netbox/templates/ui/panels/custom_fields.html create mode 100644 netbox/templates/ui/panels/image_attachments.html create mode 100644 netbox/templates/ui/panels/related_objects.html create mode 100644 netbox/templates/ui/panels/tags.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d5382fa2672..5b1923b08ac 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -18,6 +18,7 @@ from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * from netbox.ui import layout +from netbox.ui.panels import CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, RelatedObjectsPanel, TagsPanel from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -468,14 +469,20 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): layout = layout.Layout( layout.Row( layout.Column( - panels.SitePanel(_('Site')) + panels.SitePanel(_('Site')), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ), + layout.Column( + RelatedObjectsPanel(), + ImageAttachmentsPanel(), ), ) ) def get_extra_context(self, request, instance): return { - # 'site_panel': panels.SitePanel(instance, _('Site')), 'related_models': self.get_related_models( request, instance, diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 15cad0a6491..96061d42e26 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -8,14 +8,23 @@ from utilities.string import title __all__ = ( + 'CommentsPanel', + 'CustomFieldsPanel', + 'ImageAttachmentsPanel', 'NestedGroupObjectPanel', 'ObjectPanel', + 'RelatedObjectsPanel', 'Panel', + 'TagsPanel', ) class Panel(ABC): + def __init__(self, title=None): + if title is not None: + self.title = title + @abstractmethod def render(self, obj): pass @@ -51,9 +60,6 @@ def __new__(mcls, name, bases, namespace, **kwargs): class ObjectPanel(Panel, metaclass=ObjectPanelMeta): template_name = 'ui/panels/object.html' - def __init__(self, title=None): - self.title = title - def get_attributes(self, obj): return [ { @@ -74,3 +80,65 @@ class NestedGroupObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) + + +class CustomFieldsPanel(Panel): + template_name = 'ui/panels/custom_fields.html' + title = _('Custom Fields') + + def render(self, context): + obj = context.get('object') + custom_fields = obj.get_custom_fields_by_group() + if not custom_fields: + return '' + return render_to_string(self.template_name, { + 'title': self.title, + 'custom_fields': custom_fields, + }) + + +class TagsPanel(Panel): + template_name = 'ui/panels/tags.html' + title = _('Tags') + + def render(self, context): + return render_to_string(self.template_name, { + 'title': self.title, + 'object': context.get('object'), + }) + + +class CommentsPanel(Panel): + template_name = 'ui/panels/comments.html' + title = _('Comments') + + def render(self, context): + obj = context.get('object') + return render_to_string(self.template_name, { + 'title': self.title, + 'comments': obj.comments, + }) + + +class RelatedObjectsPanel(Panel): + template_name = 'ui/panels/related_objects.html' + title = _('Related Objects') + + def render(self, context): + return render_to_string(self.template_name, { + 'title': self.title, + 'object': context.get('object'), + 'related_models': context.get('related_models'), + }) + + +class ImageAttachmentsPanel(Panel): + template_name = 'ui/panels/image_attachments.html' + title = _('Image Attachments') + + def render(self, context): + return render_to_string(self.template_name, { + 'title': self.title, + 'request': context.get('request'), + 'object': context.get('object'), + }) diff --git a/netbox/templates/ui/panels/_base.html b/netbox/templates/ui/panels/_base.html new file mode 100644 index 00000000000..e1a6b41960b --- /dev/null +++ b/netbox/templates/ui/panels/_base.html @@ -0,0 +1,4 @@ +
+

{{ title }}

+ {% block panel_content %}{% endblock %} +
diff --git a/netbox/templates/ui/panels/comments.html b/netbox/templates/ui/panels/comments.html new file mode 100644 index 00000000000..de32162ce55 --- /dev/null +++ b/netbox/templates/ui/panels/comments.html @@ -0,0 +1,12 @@ +{% extends "ui/panels/_base.html" %} +{% load i18n %} + +{% block panel_content %} +
+ {% if comments %} + {{ comments|markdown }} + {% else %} + {% trans "None" %} + {% endif %} +
+{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/custom_fields.html b/netbox/templates/ui/panels/custom_fields.html new file mode 100644 index 00000000000..d0b1c56861f --- /dev/null +++ b/netbox/templates/ui/panels/custom_fields.html @@ -0,0 +1,31 @@ +{% extends "ui/panels/_base.html" %} +{% load i18n %} + +{% block panel_content %} + + {% for group_name, fields in custom_fields.items %} + {% if group_name %} + + + + {% endif %} + {% for field, value in fields.items %} + + + + + {% endfor %} + {% endfor %} +
{{ group_name }}
{{ field }} + {% if field.description %} + + {% endif %} + + {% customfield_value field value %} +
+{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/image_attachments.html b/netbox/templates/ui/panels/image_attachments.html new file mode 100644 index 00000000000..0b6ecdf808f --- /dev/null +++ b/netbox/templates/ui/panels/image_attachments.html @@ -0,0 +1,7 @@ +{% extends "ui/panels/_base.html" %} +{% load i18n %} + +{# TODO: Add "attach an image" button in panel header #} +{% block panel_content %} + {% htmx_table 'extras:imageattachment_list' object_type_id=object|content_type_id object_id=object.pk %} +{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/object.html b/netbox/templates/ui/panels/object.html index def52f76a2c..399a0081e8a 100644 --- a/netbox/templates/ui/panels/object.html +++ b/netbox/templates/ui/panels/object.html @@ -1,5 +1,6 @@ -
-

{{ title }}

+{% extends "ui/panels/_base.html" %} + +{% block panel_content %} {% for attr in attrs %} @@ -10,4 +11,4 @@

{{ title }}

{% endfor %}
-
+{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/related_objects.html b/netbox/templates/ui/panels/related_objects.html new file mode 100644 index 00000000000..29d6dc6c4a4 --- /dev/null +++ b/netbox/templates/ui/panels/related_objects.html @@ -0,0 +1,25 @@ +{% extends "ui/panels/_base.html" %} +{% load helpers %} +{% load i18n %} + +{% block panel_content %} + +{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/tags.html b/netbox/templates/ui/panels/tags.html new file mode 100644 index 00000000000..d505dc48de6 --- /dev/null +++ b/netbox/templates/ui/panels/tags.html @@ -0,0 +1,15 @@ +{% extends "ui/panels/_base.html" %} +{% load helpers %} +{% load i18n %} + +{% block panel_content %} +
+ {% with url=object|validated_viewname:"list" %} + {% for tag in object.tags.all %} + {% tag tag url %} + {% empty %} + {% trans "No tags assigned" %} + {% endfor %} + {% endwith %} +
+{% endblock panel_content %} From 4d5f8e946090fe00b3eda503f81757b2647b7d52 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Oct 2025 14:50:21 -0400 Subject: [PATCH 12/37] Add PluginContentPanel --- netbox/dcim/views.py | 13 +++++++++++-- netbox/netbox/ui/panels.py | 13 +++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5b1923b08ac..3d067201910 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -18,7 +18,9 @@ from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * from netbox.ui import layout -from netbox.ui.panels import CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, RelatedObjectsPanel, TagsPanel +from netbox.ui.panels import ( + CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, PluginContentPanel, RelatedObjectsPanel, TagsPanel, +) from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -473,12 +475,19 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): CustomFieldsPanel(), TagsPanel(), CommentsPanel(), + PluginContentPanel('left_page'), ), layout.Column( RelatedObjectsPanel(), ImageAttachmentsPanel(), + PluginContentPanel('right_page'), ), - ) + ), + layout.Row( + layout.Column( + PluginContentPanel('full_width_page'), + ), + ), ) def get_extra_context(self, request, instance): diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 96061d42e26..3948f0de4d5 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -6,6 +6,7 @@ from netbox.ui import attrs from netbox.ui.attrs import Attr from utilities.string import title +from utilities.templatetags.plugins import _get_registered_content __all__ = ( 'CommentsPanel', @@ -15,6 +16,7 @@ 'ObjectPanel', 'RelatedObjectsPanel', 'Panel', + 'PluginContentPanel', 'TagsPanel', ) @@ -142,3 +144,14 @@ def render(self, context): 'request': context.get('request'), 'object': context.get('object'), }) + + +class PluginContentPanel(Panel): + + 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) From e9b15436c4da24c9634add01d715e59691a57430 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Oct 2025 16:27:26 -0400 Subject: [PATCH 13/37] Add EmbeddedTablePanel --- netbox/dcim/views.py | 18 +++++++++++++- netbox/netbox/ui/panels.py | 24 +++++++++++++++++++ .../templates/ui/panels/embedded_table.html | 5 ++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 netbox/templates/ui/panels/embedded_table.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3d067201910..09a085ff787 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger @@ -19,7 +20,8 @@ from netbox.object_actions import * from netbox.ui import layout from netbox.ui.panels import ( - CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, PluginContentPanel, RelatedObjectsPanel, TagsPanel, + CommentsPanel, CustomFieldsPanel, EmbeddedTablePanel, ImageAttachmentsPanel, PluginContentPanel, + RelatedObjectsPanel, TagsPanel, ) from netbox.views import generic from utilities.forms import ConfirmationForm @@ -485,6 +487,20 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): ), layout.Row( layout.Column( + EmbeddedTablePanel( + 'dcim:location_list', + url_params={'site_id': lambda x: x.pk}, + title=_('Locations') + ), + EmbeddedTablePanel( + 'dcim:device_list', + url_params={ + 'site_id': lambda x: x.pk, + 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, + 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, + }, + title=_('Non-Racked Devices') + ), PluginContentPanel('full_width_page'), ), ), diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 3948f0de4d5..a602eaa3332 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -5,12 +5,14 @@ from netbox.ui import attrs from netbox.ui.attrs import Attr +from utilities.querydict import dict_to_querydict from utilities.string import title from utilities.templatetags.plugins import _get_registered_content __all__ = ( 'CommentsPanel', 'CustomFieldsPanel', + 'EmbeddedTablePanel', 'ImageAttachmentsPanel', 'NestedGroupObjectPanel', 'ObjectPanel', @@ -146,6 +148,28 @@ def render(self, context): }) +class EmbeddedTablePanel(Panel): + template_name = 'ui/panels/embedded_table.html' + title = None + + def __init__(self, viewname, url_params=None, **kwargs): + super().__init__(**kwargs) + self.viewname = viewname + self.url_params = url_params or {} + + def render(self, context): + obj = context.get('object') + url_params = { + k: v(obj) if callable(v) else v for k, v in self.url_params.items() + } + # url_params['return_url'] = return_url or context['request'].path + return render_to_string(self.template_name, { + 'title': self.title, + 'viewname': self.viewname, + 'url_params': dict_to_querydict(url_params), + }) + + class PluginContentPanel(Panel): def __init__(self, method, **kwargs): diff --git a/netbox/templates/ui/panels/embedded_table.html b/netbox/templates/ui/panels/embedded_table.html new file mode 100644 index 00000000000..64579705f0c --- /dev/null +++ b/netbox/templates/ui/panels/embedded_table.html @@ -0,0 +1,5 @@ +{% extends "ui/panels/_base.html" %} + +{% block panel_content %} + {% include 'builtins/htmx_table.html' %} +{% endblock panel_content %} From da68503a195258d66755dbeab2ab07b6b9338391 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Oct 2025 16:47:26 -0400 Subject: [PATCH 14/37] Remove panels from get_extra_context() --- netbox/dcim/views.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 09a085ff787..c05588a0fb4 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -233,7 +233,6 @@ def get_extra_context(self, request, instance): regions = instance.get_descendants(include_self=True) return { - 'region_panel': panels.NestedGroupObjectPanel(instance, _('Region')), 'related_models': self.get_related_models( request, regions, @@ -345,7 +344,6 @@ def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) return { - 'sitegroup_panel': panels.NestedGroupObjectPanel(instance, _('Site Group')), 'related_models': self.get_related_models( request, groups, @@ -610,7 +608,6 @@ def get_extra_context(self, request, instance): locations = instance.get_descendants(include_self=True) location_content_type = ContentType.objects.get_for_model(instance) return { - 'location_panel': panels.LocationPanel(instance, _('Location')), 'related_models': self.get_related_models( request, locations, @@ -907,7 +904,6 @@ def get_extra_context(self, request, instance): ]) return { - 'rack_panel': panels.RackPanel(instance, _('Rack')), 'related_models': self.get_related_models( request, instance, @@ -2272,8 +2268,6 @@ def get_extra_context(self, request, instance): return { 'vc_members': vc_members, 'svg_extra': f'highlight=id:{instance.pk}', - 'device_panel': panels.DevicePanel(instance, _('Device')), - 'management_panel': panels.DeviceManagementPanel(instance, _('Management')), } From 37bea1e98ebf91a41ccf8d5492b2b4776255dcf6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 09:55:56 -0500 Subject: [PATCH 15/37] Introduce panel actions --- netbox/netbox/ui/actions.py | 56 +++++++++++++ netbox/netbox/ui/panels.py | 100 +++++++++++------------ netbox/templates/ui/panels/_base.html | 16 +++- netbox/templates/ui/panels/comments.html | 4 +- 4 files changed, 121 insertions(+), 55 deletions(-) create mode 100644 netbox/netbox/ui/actions.py diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py new file mode 100644 index 00000000000..0b21b907107 --- /dev/null +++ b/netbox/netbox/ui/actions.py @@ -0,0 +1,56 @@ +from urllib.parse import urlencode + +from django.apps import apps +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from utilities.permissions import get_permission_for_model +from utilities.views import get_viewname + +__all__ = ( + 'AddObject', + 'PanelAction', +) + + +class PanelAction: + label = None + button_class = 'primary' + button_icon = None + + def __init__(self, view_name, view_kwargs=None, url_params=None, permissions=None, label=None): + self.view_name = view_name + self.view_kwargs = view_kwargs + self.url_params = url_params or {} + self.permissions = permissions + if label is not None: + self.label = label + + def get_url(self, obj): + url = reverse(self.view_name, kwargs=self.view_kwargs or {}) + if self.url_params: + url_params = { + k: v(obj) if callable(v) else v for k, v in self.url_params.items() + } + url = f'{url}?{urlencode(url_params)}' + return url + + def get_context(self, obj): + return { + 'url': self.get_url(obj), + 'label': self.label, + 'button_class': self.button_class, + 'button_icon': self.button_icon, + } + + +class AddObject(PanelAction): + label = _('Add') + button_icon = 'plus-thick' + + def __init__(self, model, label=None, url_params=None): + app_label, model_name = model.split('.') + model = apps.get_model(app_label, model_name) + view_name = get_viewname(model, 'add') + super().__init__(view_name=view_name, label=label, url_params=url_params) + self.permissions = [get_permission_for_model(model, 'add')] diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index a602eaa3332..195dcfd3c48 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -1,9 +1,10 @@ -from abc import ABC, ABCMeta, abstractmethod +from abc import ABC, ABCMeta +from django.contrib.contenttypes.models import ContentType from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ -from netbox.ui import attrs +from netbox.ui import actions, attrs from netbox.ui.attrs import Attr from utilities.querydict import dict_to_querydict from utilities.string import title @@ -24,14 +25,28 @@ class Panel(ABC): + template_name = None + title = None + actions = [] - def __init__(self, title=None): + def __init__(self, title=None, actions=None): if title is not None: self.title = title + if actions is not None: + self.actions = actions + + def get_context(self, obj): + return {} - @abstractmethod - def render(self, obj): - pass + def render(self, context): + obj = context.get('object') + return render_to_string(self.template_name, { + 'request': context.get('request'), + 'object': obj, + 'title': self.title, + 'actions': [action.get_context(obj) for action in self.actions], + **self.get_context(obj), + }) class ObjectPanelMeta(ABCMeta): @@ -64,20 +79,16 @@ def __new__(mcls, name, bases, namespace, **kwargs): class ObjectPanel(Panel, metaclass=ObjectPanelMeta): template_name = 'ui/panels/object.html' - def get_attributes(self, obj): - return [ + def get_context(self, obj): + attrs = [ { 'label': attr.label or title(name), 'value': attr.render(obj, {'name': name}), } for name, attr in self._attrs.items() ] - - def render(self, context): - obj = context.get('object') - return render_to_string(self.template_name, { - 'title': self.title, - 'attrs': self.get_attributes(obj), - }) + return { + 'attrs': attrs, + } class NestedGroupObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): @@ -90,44 +101,27 @@ class CustomFieldsPanel(Panel): template_name = 'ui/panels/custom_fields.html' title = _('Custom Fields') - def render(self, context): - obj = context.get('object') - custom_fields = obj.get_custom_fields_by_group() - if not custom_fields: - return '' - return render_to_string(self.template_name, { - 'title': self.title, - 'custom_fields': custom_fields, - }) + def get_context(self, obj): + return { + 'custom_fields': obj.get_custom_fields_by_group(), + } class TagsPanel(Panel): template_name = 'ui/panels/tags.html' title = _('Tags') - def render(self, context): - return render_to_string(self.template_name, { - 'title': self.title, - 'object': context.get('object'), - }) - class CommentsPanel(Panel): template_name = 'ui/panels/comments.html' title = _('Comments') - def render(self, context): - obj = context.get('object') - return render_to_string(self.template_name, { - 'title': self.title, - 'comments': obj.comments, - }) - class RelatedObjectsPanel(Panel): template_name = 'ui/panels/related_objects.html' title = _('Related Objects') + # TODO: Handle related_models from context def render(self, context): return render_to_string(self.template_name, { 'title': self.title, @@ -139,35 +133,37 @@ def render(self, context): class ImageAttachmentsPanel(Panel): template_name = 'ui/panels/image_attachments.html' title = _('Image Attachments') - - def render(self, context): - return render_to_string(self.template_name, { - 'title': self.title, - 'request': context.get('request'), - 'object': context.get('object'), - }) + actions = [ + actions.AddObject( + 'extras.imageattachment', + url_params={ + 'object_type': lambda obj: ContentType.objects.get_for_model(obj).pk, + 'object_id': lambda obj: obj.pk, + 'return_url': lambda obj: obj.get_absolute_url(), + }, + label=_('Attach an image'), + ), + ] class EmbeddedTablePanel(Panel): template_name = 'ui/panels/embedded_table.html' title = None - def __init__(self, viewname, url_params=None, **kwargs): + def __init__(self, view_name, url_params=None, **kwargs): super().__init__(**kwargs) - self.viewname = viewname + self.view_name = view_name self.url_params = url_params or {} - def render(self, context): - obj = context.get('object') + def get_context(self, obj): url_params = { k: v(obj) if callable(v) else v for k, v in self.url_params.items() } # url_params['return_url'] = return_url or context['request'].path - return render_to_string(self.template_name, { - 'title': self.title, - 'viewname': self.viewname, + return { + 'viewname': self.view_name, 'url_params': dict_to_querydict(url_params), - }) + } class PluginContentPanel(Panel): diff --git a/netbox/templates/ui/panels/_base.html b/netbox/templates/ui/panels/_base.html index e1a6b41960b..47ae689b79b 100644 --- a/netbox/templates/ui/panels/_base.html +++ b/netbox/templates/ui/panels/_base.html @@ -1,4 +1,18 @@
-

{{ title }}

+

+ {{ title }} + {% if actions %} +
+ {% for action in actions %} + + {% if action.button_icon %} + + {% endif %} + {{ action.label }} + + {% endfor %} +
+ {% endif %} +

{% block panel_content %}{% endblock %}
diff --git a/netbox/templates/ui/panels/comments.html b/netbox/templates/ui/panels/comments.html index de32162ce55..d5f07a8cc90 100644 --- a/netbox/templates/ui/panels/comments.html +++ b/netbox/templates/ui/panels/comments.html @@ -3,8 +3,8 @@ {% block panel_content %}
- {% if comments %} - {{ comments|markdown }} + {% if object.comments %} + {{ object.comments|markdown }} {% else %} {% trans "None" %} {% endif %} From c39298821296b1a6108c5f1e64df92600a2ee912 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 10:30:13 -0500 Subject: [PATCH 16/37] Replace EmbeddedTablePanel with ObjectsTablePanel --- netbox/dcim/views.py | 27 +++++---- netbox/netbox/ui/actions.py | 5 ++ netbox/netbox/ui/panels.py | 55 +++++++++++-------- .../ui/panels/image_attachments.html | 7 --- netbox/templates/ui/panels/objects_table.html | 5 ++ 5 files changed, 58 insertions(+), 41 deletions(-) delete mode 100644 netbox/templates/ui/panels/image_attachments.html create mode 100644 netbox/templates/ui/panels/objects_table.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c05588a0fb4..5e00a6553e9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -18,9 +18,9 @@ from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * -from netbox.ui import layout +from netbox.ui import actions, layout from netbox.ui.panels import ( - CommentsPanel, CustomFieldsPanel, EmbeddedTablePanel, ImageAttachmentsPanel, PluginContentPanel, + CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, ObjectsTablePanel, PluginContentPanel, RelatedObjectsPanel, TagsPanel, ) from netbox.views import generic @@ -485,19 +485,24 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): ), layout.Row( layout.Column( - EmbeddedTablePanel( - 'dcim:location_list', - url_params={'site_id': lambda x: x.pk}, - title=_('Locations') + ObjectsTablePanel( + model='dcim.Location', + filters={'site_id': lambda obj: obj.pk}, + actions=[ + actions.AddObject('dcim.Location', url_params={'site': lambda obj: obj.pk}), + ], ), - EmbeddedTablePanel( - 'dcim:device_list', - url_params={ - 'site_id': lambda x: x.pk, + ObjectsTablePanel( + model='dcim.Device', + title=_('Non-Racked Devices'), + filters={ + 'site_id': lambda obj: obj.pk, 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, }, - title=_('Non-Racked Devices') + actions=[ + actions.AddObject('dcim.Device', url_params={'site': lambda obj: obj.pk}), + ], ), PluginContentPanel('full_width_page'), ), diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index 0b21b907107..79fd9a5d6d2 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -32,6 +32,8 @@ def get_url(self, obj): url_params = { k: v(obj) if callable(v) else v for k, v in self.url_params.items() } + if 'return_url' not in url_params: + url_params['return_url'] = obj.get_absolute_url() url = f'{url}?{urlencode(url_params)}' return url @@ -49,8 +51,11 @@ class AddObject(PanelAction): button_icon = 'plus-thick' def __init__(self, model, label=None, url_params=None): + # Resolve the model class from its app.name label app_label, model_name = model.split('.') model = apps.get_model(app_label, model_name) view_name = get_viewname(model, 'add') super().__init__(view_name=view_name, label=label, url_params=url_params) + + # Require "add" permission on the model by default self.permissions = [get_permission_for_model(model, 'add')] diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 195dcfd3c48..2ff495c4235 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -1,5 +1,6 @@ from abc import ABC, ABCMeta +from django.apps import apps from django.contrib.contenttypes.models import ContentType from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ @@ -9,14 +10,15 @@ 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', 'CustomFieldsPanel', - 'EmbeddedTablePanel', 'ImageAttachmentsPanel', 'NestedGroupObjectPanel', 'ObjectPanel', + 'ObjectsTablePanel', 'RelatedObjectsPanel', 'Panel', 'PluginContentPanel', @@ -130,9 +132,33 @@ def render(self, context): }) -class ImageAttachmentsPanel(Panel): - template_name = 'ui/panels/image_attachments.html' - title = _('Image Attachments') +class ObjectsTablePanel(Panel): + 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 + app_label, model_name = model.split('.') + self.model = apps.get_model(app_label, model_name) + self.filters = filters or {} + if self.title is None: + self.title = title(self.model._meta.verbose_name_plural) + + def get_context(self, obj): + url_params = { + k: v(obj) if callable(v) else v for k, v in self.filters.items() + } + if 'return_url' not in url_params: + url_params['return_url'] = obj.get_absolute_url() + return { + 'viewname': get_viewname(self.model, 'list'), + 'url_params': dict_to_querydict(url_params), + } + + +class ImageAttachmentsPanel(ObjectsTablePanel): actions = [ actions.AddObject( 'extras.imageattachment', @@ -145,25 +171,8 @@ class ImageAttachmentsPanel(Panel): ), ] - -class EmbeddedTablePanel(Panel): - template_name = 'ui/panels/embedded_table.html' - title = None - - def __init__(self, view_name, url_params=None, **kwargs): - super().__init__(**kwargs) - self.view_name = view_name - self.url_params = url_params or {} - - def get_context(self, obj): - url_params = { - k: v(obj) if callable(v) else v for k, v in self.url_params.items() - } - # url_params['return_url'] = return_url or context['request'].path - return { - 'viewname': self.view_name, - 'url_params': dict_to_querydict(url_params), - } + def __init__(self, **kwargs): + super().__init__('extras.imageattachment', **kwargs) class PluginContentPanel(Panel): diff --git a/netbox/templates/ui/panels/image_attachments.html b/netbox/templates/ui/panels/image_attachments.html deleted file mode 100644 index 0b6ecdf808f..00000000000 --- a/netbox/templates/ui/panels/image_attachments.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "ui/panels/_base.html" %} -{% load i18n %} - -{# TODO: Add "attach an image" button in panel header #} -{% block panel_content %} - {% htmx_table 'extras:imageattachment_list' object_type_id=object|content_type_id object_id=object.pk %} -{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/objects_table.html b/netbox/templates/ui/panels/objects_table.html new file mode 100644 index 00000000000..64579705f0c --- /dev/null +++ b/netbox/templates/ui/panels/objects_table.html @@ -0,0 +1,5 @@ +{% extends "ui/panels/_base.html" %} + +{% block panel_content %} + {% include 'builtins/htmx_table.html' %} +{% endblock panel_content %} From 21bb734dcb98c992b696eb5611c5e6cc895e9a31 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 11:51:49 -0500 Subject: [PATCH 17/37] Define layouts for regions, site groups, locations --- netbox/dcim/views.py | 113 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5e00a6553e9..5e60f65a730 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -20,8 +20,8 @@ from netbox.object_actions import * from netbox.ui import actions, layout from netbox.ui.panels import ( - CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, ObjectsTablePanel, PluginContentPanel, - RelatedObjectsPanel, TagsPanel, + CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, + PluginContentPanel, RelatedObjectsPanel, TagsPanel, ) from netbox.views import generic from utilities.forms import ConfirmationForm @@ -228,6 +228,34 @@ class RegionListView(generic.ObjectListView): @register_model_view(Region) class RegionView(GetRelatedModelsMixin, generic.ObjectView): queryset = Region.objects.all() + layout = layout.Layout( + layout.Row( + layout.Column( + NestedGroupObjectPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + PluginContentPanel('left_page'), + ), + layout.Column( + RelatedObjectsPanel(), + PluginContentPanel('right_page'), + ), + ), + layout.Row( + layout.Column( + ObjectsTablePanel( + model='dcim.Region', + title=_('Child Regions'), + filters={'parent_id': lambda obj: obj.pk}, + actions=[ + actions.AddObject('dcim.Region', url_params={'parent': lambda obj: obj.pk}), + ], + ), + PluginContentPanel('full_width_page'), + ), + ), + ) def get_extra_context(self, request, instance): regions = instance.get_descendants(include_self=True) @@ -339,6 +367,34 @@ class SiteGroupListView(generic.ObjectListView): @register_model_view(SiteGroup) class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = SiteGroup.objects.all() + layout = layout.Layout( + layout.Row( + layout.Column( + NestedGroupObjectPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + PluginContentPanel('left_page'), + ), + layout.Column( + RelatedObjectsPanel(), + PluginContentPanel('right_page'), + ), + ), + layout.Row( + layout.Column( + ObjectsTablePanel( + model='dcim.SiteGroup', + title=_('Child Groups'), + filters={'parent_id': lambda obj: obj.pk}, + actions=[ + actions.AddObject('dcim.Region', url_params={'parent': lambda obj: obj.pk}), + ], + ), + PluginContentPanel('full_width_page'), + ), + ), + ) def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) @@ -608,6 +664,59 @@ class LocationListView(generic.ObjectListView): @register_model_view(Location) class LocationView(GetRelatedModelsMixin, generic.ObjectView): queryset = Location.objects.all() + layout = layout.Layout( + layout.Row( + layout.Column( + panels.LocationPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + PluginContentPanel('left_page'), + ), + layout.Column( + RelatedObjectsPanel(), + ImageAttachmentsPanel(), + PluginContentPanel('right_page'), + ), + ), + layout.Row( + layout.Column( + ObjectsTablePanel( + model='dcim.Location', + title=_('Child Locations'), + filters={'parent_id': lambda obj: obj.pk}, + actions=[ + actions.AddObject( + 'dcim.Location', + url_params={ + 'site': lambda obj: obj.site.pk if obj.site else None, + 'parent': lambda obj: obj.pk, + } + ), + ], + ), + ObjectsTablePanel( + model='dcim.Device', + title=_('Non-Racked Devices'), + filters={ + 'location_id': lambda obj: obj.pk, + 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, + 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, + }, + actions=[ + actions.AddObject( + 'dcim.Device', + url_params={ + 'site': lambda obj: obj.site.pk if obj.site else None, + 'parent': lambda obj: obj.pk, + } + ), + ], + ), + PluginContentPanel('full_width_page'), + ), + ), + ) def get_extra_context(self, request, instance): locations = instance.get_descendants(include_self=True) From 17cffd786075f8ba007dbd10e42ec01ead16b14a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 13:33:39 -0500 Subject: [PATCH 18/37] Add rack role & type layouts --- netbox/dcim/ui/panels.py | 31 +++++++++++++ netbox/dcim/views.py | 44 +++++++++++++++++- netbox/netbox/ui/attrs.py | 64 ++++++++++++++++++++++++-- netbox/netbox/ui/panels.py | 8 +++- netbox/templates/ui/attrs/boolean.html | 1 + netbox/templates/ui/attrs/color.html | 1 + netbox/templates/ui/attrs/numeric.html | 12 +++++ 7 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 netbox/templates/ui/attrs/boolean.html create mode 100644 netbox/templates/ui/attrs/color.html create mode 100644 netbox/templates/ui/attrs/numeric.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 0ed917c551f..4db5e958c37 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -23,6 +23,26 @@ class LocationPanel(panels.NestedGroupObjectPanel): facility = attrs.TextAttr('facility', label=_('Facility')) +class RackDimensionsPanel(panels.ObjectPanel): + form_factor = attrs.ChoiceAttr('form_factor', label=_('Form factor')) + width = attrs.ChoiceAttr('width', label=_('Width')) + u_height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) + outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display', label=_('Outer width')) + outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display', label=_('Outer height')) + outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display', label=_('Outer depth')) + mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm', label=_('Mounting depth')) + + +class RackNumberingPanel(panels.ObjectPanel): + starting_unit = attrs.TextAttr('starting_unit', label=_('Starting unit')) + desc_units = attrs.BooleanAttr('desc_units', label=_('Descending units')) + + +class RackWeightPanel(panels.ObjectPanel): + weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display', label=_('Weight')) + max_weight = attrs.NumericAttr('max_weight', unit_accessor='get_weight_unit_display', label=_('Maximum weight')) + + class RackPanel(panels.ObjectPanel): region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') @@ -40,6 +60,17 @@ class RackPanel(panels.ObjectPanel): power_utilization = attrs.UtilizationAttr('get_power_utilization', label=_('Power utilization')) +class RackRolePanel(panels.OrganizationalObjectPanel): + color = attrs.ColorAttr('color') + + +class RackTypePanel(panels.ObjectPanel): + manufacturer = attrs.ObjectAttr('manufacturer', label=_('Manufacturer'), linkify=True) + model = attrs.TextAttr('model', label=_('Model')) + description = attrs.TextAttr('description', label=_('Description')) + airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) + + class DevicePanel(panels.ObjectPanel): region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5e60f65a730..e825777f641 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -527,7 +527,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): layout = layout.Layout( layout.Row( layout.Column( - panels.SitePanel(_('Site')), + panels.SitePanel(), CustomFieldsPanel(), TagsPanel(), CommentsPanel(), @@ -817,6 +817,25 @@ class RackRoleListView(generic.ObjectListView): @register_model_view(RackRole) class RackRoleView(GetRelatedModelsMixin, generic.ObjectView): queryset = RackRole.objects.all() + layout = layout.Layout( + layout.Row( + layout.Column( + panels.RackRolePanel(), + TagsPanel(), + PluginContentPanel('left_page'), + ), + layout.Column( + RelatedObjectsPanel(), + CustomFieldsPanel(), + PluginContentPanel('right_page'), + ), + ), + layout.Row( + layout.Column( + PluginContentPanel('full_width_page'), + ), + ), + ) def get_extra_context(self, request, instance): return { @@ -884,6 +903,29 @@ class RackTypeListView(generic.ObjectListView): @register_model_view(RackType) class RackTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = RackType.objects.all() + layout = layout.Layout( + layout.Row( + layout.Column( + panels.RackTypePanel(), + panels.RackDimensionsPanel(_('Dimensions')), + TagsPanel(), + CommentsPanel(), + PluginContentPanel('left_page'), + ), + layout.Column( + panels.RackNumberingPanel(_('Numbering')), + panels.RackWeightPanel(_('Weight')), + CustomFieldsPanel(), + RelatedObjectsPanel(), + PluginContentPanel('right_page'), + ), + ), + layout.Row( + layout.Column( + PluginContentPanel('full_width_page'), + ), + ), + ) def get_extra_context(self, request, instance): return { diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 2e931d7141b..4df8f64e13c 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -13,12 +13,14 @@ class Attr(ABC): template_name = None + label = 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 + if label is not None: + self.label = label @abstractmethod def render(self, obj, context=None): @@ -37,9 +39,10 @@ def _resolve_attr(obj, path): class TextAttr(Attr): template_name = 'ui/attrs/text.html' - def __init__(self, *args, style=None, copy_button=False, **kwargs): + def __init__(self, *args, style=None, format_string=None, copy_button=False, **kwargs): super().__init__(*args, **kwargs) self.style = style + self.format_string = format_string self.copy_button = copy_button def render(self, obj, context=None): @@ -47,6 +50,8 @@ def render(self, obj, context=None): value = self._resolve_attr(obj, self.accessor) if value in (None, ''): return self.placeholder + if self.format_string: + value = self.format_string.format(value) return render_to_string(self.template_name, { **context, 'value': value, @@ -55,6 +60,28 @@ def render(self, obj, context=None): }) +class NumericAttr(Attr): + template_name = 'ui/attrs/numeric.html' + + def __init__(self, *args, unit_accessor=None, copy_button=False, **kwargs): + super().__init__(*args, **kwargs) + self.unit_accessor = unit_accessor + self.copy_button = copy_button + + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + if value in (None, ''): + return self.placeholder + unit = self._resolve_attr(obj, self.unit_accessor) if self.unit_accessor else None + return render_to_string(self.template_name, { + **context, + 'value': value, + 'unit': unit, + 'copy_button': self.copy_button, + }) + + class ChoiceAttr(Attr): template_name = 'ui/attrs/choice.html' @@ -77,6 +104,37 @@ def render(self, obj, context=None): }) +class BooleanAttr(Attr): + template_name = 'ui/attrs/boolean.html' + + def __init__(self, *args, display_false=True, **kwargs): + super().__init__(*args, **kwargs) + self.display_false = display_false + + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + if value in (None, '') and not self.display_false: + return self.placeholder + return render_to_string(self.template_name, { + **context, + 'value': value, + }) + + +class ColorAttr(Attr): + template_name = 'ui/attrs/color.html' + label = _('Color') + + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + return render_to_string(self.template_name, { + **context, + 'color': value, + }) + + class ObjectAttr(Attr): template_name = 'ui/attrs/object.html' @@ -149,9 +207,9 @@ def render(self, obj, context=None): class GPSCoordinatesAttr(Attr): template_name = 'ui/attrs/gps_coordinates.html' + label = _('GPS Coordinates') 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 diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 2ff495c4235..d65c5ae2c70 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -19,6 +19,7 @@ 'NestedGroupObjectPanel', 'ObjectPanel', 'ObjectsTablePanel', + 'OrganizationalObjectPanel', 'RelatedObjectsPanel', 'Panel', 'PluginContentPanel', @@ -45,7 +46,7 @@ def render(self, context): return render_to_string(self.template_name, { 'request': context.get('request'), 'object': obj, - 'title': self.title, + 'title': self.title or title(obj._meta.verbose_name), 'actions': [action.get_context(obj) for action in self.actions], **self.get_context(obj), }) @@ -93,9 +94,12 @@ def get_context(self, obj): } -class NestedGroupObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): +class OrganizationalObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) + + +class NestedGroupObjectPanel(OrganizationalObjectPanel, metaclass=ObjectPanelMeta): parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) diff --git a/netbox/templates/ui/attrs/boolean.html b/netbox/templates/ui/attrs/boolean.html new file mode 100644 index 00000000000..a724d687bbb --- /dev/null +++ b/netbox/templates/ui/attrs/boolean.html @@ -0,0 +1 @@ +{% checkmark object.desc_units %} diff --git a/netbox/templates/ui/attrs/color.html b/netbox/templates/ui/attrs/color.html new file mode 100644 index 00000000000..29d11207a09 --- /dev/null +++ b/netbox/templates/ui/attrs/color.html @@ -0,0 +1 @@ +  diff --git a/netbox/templates/ui/attrs/numeric.html b/netbox/templates/ui/attrs/numeric.html new file mode 100644 index 00000000000..5c54f2979dc --- /dev/null +++ b/netbox/templates/ui/attrs/numeric.html @@ -0,0 +1,12 @@ +{% load i18n %} + + {{ value }} + {% if unit %} + {{ unit|lower }} + {% endif %} + +{% if copy_button %} + + + +{% endif %} From ed3dd019a763ee01abf03475b26ad09cba30f301 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 14:59:54 -0500 Subject: [PATCH 19/37] Move some panels to extras --- netbox/dcim/views.py | 4 ++-- netbox/extras/ui/panels.py | 42 +++++++++++++++++++++++++++++++++++++ netbox/netbox/ui/panels.py | 43 +++----------------------------------- 3 files changed, 47 insertions(+), 42 deletions(-) create mode 100644 netbox/extras/ui/panels.py diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e825777f641..c0146165f8c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -14,14 +14,14 @@ from circuits.models import Circuit, CircuitTermination from dcim.ui import panels +from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel 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.ui import actions, layout from netbox.ui.panels import ( - CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, - PluginContentPanel, RelatedObjectsPanel, TagsPanel, + CommentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, PluginContentPanel, RelatedObjectsPanel, ) from netbox.views import generic from utilities.forms import ConfirmationForm diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py new file mode 100644 index 00000000000..5d789f64095 --- /dev/null +++ b/netbox/extras/ui/panels.py @@ -0,0 +1,42 @@ +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext_lazy as _ + +from netbox.ui import actions, panels + +__all__ = ( + 'CustomFieldsPanel', + 'ImageAttachmentsPanel', + 'TagsPanel', +) + + +class CustomFieldsPanel(panels.Panel): + template_name = 'ui/panels/custom_fields.html' + title = _('Custom Fields') + + def get_context(self, obj): + return { + 'custom_fields': obj.get_custom_fields_by_group(), + } + + +class ImageAttachmentsPanel(panels.ObjectsTablePanel): + actions = [ + actions.AddObject( + 'extras.imageattachment', + url_params={ + 'object_type': lambda obj: ContentType.objects.get_for_model(obj).pk, + 'object_id': lambda obj: obj.pk, + 'return_url': lambda obj: obj.get_absolute_url(), + }, + label=_('Attach an image'), + ), + ] + + def __init__(self, **kwargs): + super().__init__('extras.imageattachment', **kwargs) + + +class TagsPanel(panels.Panel): + template_name = 'ui/panels/tags.html' + title = _('Tags') diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index d65c5ae2c70..d63558b90d5 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -1,12 +1,10 @@ from abc import ABC, ABCMeta from django.apps import apps -from django.contrib.contenttypes.models import ContentType from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ -from netbox.ui import actions, attrs -from netbox.ui.attrs import Attr +from netbox.ui import attrs from utilities.querydict import dict_to_querydict from utilities.string import title from utilities.templatetags.plugins import _get_registered_content @@ -14,8 +12,6 @@ __all__ = ( 'CommentsPanel', - 'CustomFieldsPanel', - 'ImageAttachmentsPanel', 'NestedGroupObjectPanel', 'ObjectPanel', 'ObjectsTablePanel', @@ -23,7 +19,6 @@ 'RelatedObjectsPanel', 'Panel', 'PluginContentPanel', - 'TagsPanel', ) @@ -65,13 +60,13 @@ def __new__(mcls, name, bases, namespace, **kwargs): # Add local declarations in the order they appear in the class body for key, attr in namespace.items(): - if isinstance(attr, Attr): + if isinstance(attr, attrs.Attr): 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, Attr)] + local_items = [key for key, attr in namespace.items() if isinstance(attr, attrs.Attr)] for key in local_items: namespace.pop(key) @@ -103,21 +98,6 @@ class NestedGroupObjectPanel(OrganizationalObjectPanel, metaclass=ObjectPanelMet parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) -class CustomFieldsPanel(Panel): - template_name = 'ui/panels/custom_fields.html' - title = _('Custom Fields') - - def get_context(self, obj): - return { - 'custom_fields': obj.get_custom_fields_by_group(), - } - - -class TagsPanel(Panel): - template_name = 'ui/panels/tags.html' - title = _('Tags') - - class CommentsPanel(Panel): template_name = 'ui/panels/comments.html' title = _('Comments') @@ -162,23 +142,6 @@ def get_context(self, obj): } -class ImageAttachmentsPanel(ObjectsTablePanel): - actions = [ - actions.AddObject( - 'extras.imageattachment', - url_params={ - 'object_type': lambda obj: ContentType.objects.get_for_model(obj).pk, - 'object_id': lambda obj: obj.pk, - 'return_url': lambda obj: obj.get_absolute_url(), - }, - label=_('Attach an image'), - ), - ] - - def __init__(self, **kwargs): - super().__init__('extras.imageattachment', **kwargs) - - class PluginContentPanel(Panel): def __init__(self, method, **kwargs): From 1cffbb21bb8e44ff67a38db5887cfbd244e57e88 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 15:04:29 -0500 Subject: [PATCH 20/37] Restore original object templates --- netbox/templates/dcim/device.html | 187 ++++++++++++++++++++++++++- netbox/templates/dcim/location.html | 39 +++++- netbox/templates/dcim/rack.html | 67 +++++++++- netbox/templates/dcim/region.html | 18 ++- netbox/templates/dcim/site.html | 96 +++++++++++++- netbox/templates/dcim/sitegroup.html | 18 ++- 6 files changed, 413 insertions(+), 12 deletions(-) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 1ef8a406de9..f8b8e95c2c1 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -11,7 +11,115 @@ {% block content %}
- {{ device_panel }} +
+

{% trans "Device" %}

+ + + + + + + + + + + + + + {% if object.virtual_chassis %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Region" %}{% nested_tree object.site.region %}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Virtual Chassis" %}{{ object.virtual_chassis|linkify }}
{% trans "Rack" %} + {% if object.rack %} + {{ object.rack|linkify }} + + + + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Position" %} + {% if object.parent_bay %} + {% with object.parent_bay.device as parent %} + {{ parent|linkify }} / {{ object.parent_bay }} + {% if parent.position %} + (U{{ parent.position|floatformat }} / {{ parent.get_face_display }}) + {% endif %} + {% endwith %} + {% elif object.rack and object.position %} + U{{ object.position|floatformat }} / {{ object.get_face_display }} + {% elif object.rack and object.device_type.u_height %} + {% trans "Not racked" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "GPS Coordinates" %} + {% if object.latitude and object.longitude %} + {% if config.MAPS_URL %} + + {% endif %} + {{ object.latitude }}, {{ object.longitude }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
{% trans "Device Type" %} + {{ object.device_type|linkify:"full_name" }} ({{ object.device_type.u_height|floatformat }}U) +
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Airflow" %} + {{ object.get_airflow_display|placeholder }} +
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Config Template" %}{{ object.config_template|linkify|placeholder }}
+
{% if vc_members %}

@@ -69,7 +177,82 @@

{% plugin_left_page object %}

- {{ management_panel }} +
+

{% trans "Management" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + {% if object.cluster %} + + + + + {% endif %} +
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Role" %}{{ object.role|linkify }}
{% trans "Platform" %}{{ object.platform|linkify|placeholder }}
{% trans "Primary IPv4" %} + {% if object.primary_ip4 %} + {{ object.primary_ip4.address.ip }} + {% if object.primary_ip4.nat_inside %} + ({% trans "NAT for" %} {{ object.primary_ip4.nat_inside.address.ip }}) + {% elif object.primary_ip4.nat_outside.exists %} + ({% trans "NAT" %}: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + {% endif %} + {% copy_content "primary_ip4" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Primary IPv6" %} + {% if object.primary_ip6 %} + {{ object.primary_ip6.address.ip }} + {% if object.primary_ip6.nat_inside %} + ({% trans "NAT for" %} {{ object.primary_ip6.nat_inside.address.ip }}) + {% elif object.primary_ip6.nat_outside.exists %} + ({% trans "NAT" %}: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + {% endif %} + {% copy_content "primary_ip6" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Out-of-band IP + {% if object.oob_ip %} + {{ object.oob_ip.address.ip }} + {% if object.oob_ip.nat_inside %} + ({% trans "NAT for" %} {{ object.oob_ip.nat_inside.address.ip }}) + {% elif object.oob_ip.nat_outside.exists %} + ({% trans "NAT" %}: {% for nat in object.oob_ip.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + {% endif %} + {% copy_content "oob_ip" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Cluster" %} + {% if object.cluster.group %} + {{ object.cluster.group|linkify }} / + {% endif %} + {{ object.cluster|linkify }} +
+
{% if object.powerports.exists and object.poweroutlets.exists %}

{% trans "Power Utilization" %}

diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 861a2adefd2..dfd0c32b31a 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -22,7 +22,44 @@ {% block content %}
- {{ location_panel }} +
+

{% trans "Location" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
{% trans "Facility" %}{{ object.facility|placeholder }}
+
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index f5eddcd3f66..eec4d63a583 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -9,7 +9,72 @@ {% block content %}
- {{ rack_panel }} +
+

{% trans "Rack" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Region" %}{% nested_tree object.site.region %}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Facility ID" %}{{ object.facility_id|placeholder }}
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Rack Type" %}{{ object.rack_type|linkify:"full_name"|placeholder }}
{% trans "Role" %}{{ object.role|linkify|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Airflow" %}{{ object.get_airflow_display|placeholder }}
{% trans "Space Utilization" %}{% utilization_graph object.get_utilization %}
{% trans "Power Utilization" %}{% utilization_graph object.get_power_utilization %}
+
{% include 'dcim/inc/panels/racktype_dimensions.html' %} {% include 'dcim/inc/panels/racktype_numbering.html' %}
diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index 28f4b6127dd..f11868b0a29 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -22,7 +22,23 @@ {% block content %}
- {{ region_panel }} +
+

{% trans "Region" %}

+ + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
+
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index f4e9a5d0248..cf65961d966 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -24,16 +24,100 @@ {% block content %}
- {{ site_panel }} +
+

{% trans "Site" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Region" %} + {% nested_tree object.region %} +
{% trans "Group" %} + {% nested_tree object.group %} +
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
{% trans "Facility" %}{{ object.facility|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Time Zone" %} + {% if object.time_zone %} + {{ object.time_zone }} ({% trans "UTC" %} {{ object.time_zone|tzoffset }})
+ {% trans "Site time" %}: {% timezone object.time_zone %}{% now 'Y-m-d H:i' %}{% endtimezone %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Physical Address" %} + {% if object.physical_address %} + {{ object.physical_address|linebreaksbr }} + {% if config.MAPS_URL %} + + {% trans "Map" %} + + {% endif %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Shipping Address" %}{{ object.shipping_address|linebreaksbr|placeholder }}
{% trans "GPS Coordinates" %} + {% if object.latitude and object.longitude %} + {% if config.MAPS_URL %} + + {% endif %} + {{ object.latitude }}, {{ object.longitude }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
+
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' with filter_name='site_id' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} +
+
+ {% include 'inc/panels/related_objects.html' with filter_name='site_id' %} + {% include 'inc/panels/image_attachments.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 63e240dc64d..dc9aca6f5ae 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -22,7 +22,23 @@ {% block content %}
- {{ sitegroup_panel }} +
+

{% trans "Site Group" %}

+ + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
+
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} From 40b114c0bb3f6a76b88309b14d2115e7916f5e10 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 15:17:34 -0500 Subject: [PATCH 21/37] Add rack layout --- netbox/dcim/ui/panels.py | 13 ++++----- netbox/dcim/views.py | 27 ++++++++++++++++++- netbox/netbox/ui/panels.py | 8 ++++++ .../dcim/panels/rack_elevations.html | 22 +++++++++++++++ 4 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 netbox/templates/dcim/panels/rack_elevations.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 4db5e958c37..d26dfda4555 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -38,16 +38,11 @@ class RackNumberingPanel(panels.ObjectPanel): desc_units = attrs.BooleanAttr('desc_units', label=_('Descending units')) -class RackWeightPanel(panels.ObjectPanel): - weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display', label=_('Weight')) - max_weight = attrs.NumericAttr('max_weight', unit_accessor='get_weight_unit_display', label=_('Maximum weight')) - - class RackPanel(panels.ObjectPanel): region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) - facility = attrs.TextAttr('facility', label=_('Facility')) + facility = attrs.TextAttr('facility', label=_('Facility ID')) tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') status = attrs.ChoiceAttr('status', label=_('Status')) rack_type = attrs.ObjectAttr('rack_type', label=_('Rack type'), linkify=True, grouped_by='manufacturer') @@ -60,6 +55,12 @@ class RackPanel(panels.ObjectPanel): power_utilization = attrs.UtilizationAttr('get_power_utilization', label=_('Power utilization')) +class RackWeightPanel(panels.ObjectPanel): + weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display', label=_('Weight')) + 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', label=_('Total weight')) + + class RackRolePanel(panels.OrganizationalObjectPanel): color = attrs.ColorAttr('color') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c0146165f8c..facccb65a04 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -21,7 +21,7 @@ from netbox.object_actions import * from netbox.ui import actions, layout from netbox.ui.panels import ( - CommentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, PluginContentPanel, RelatedObjectsPanel, + CommentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, PluginContentPanel, RelatedObjectsPanel, TemplatePanel, ) from netbox.views import generic from utilities.forms import ConfirmationForm @@ -1043,6 +1043,31 @@ def get(self, request): @register_model_view(Rack) class RackView(GetRelatedModelsMixin, generic.ObjectView): queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') + layout = layout.Layout( + layout.Row( + layout.Column( + panels.RackPanel(), + panels.RackDimensionsPanel(_('Dimensions')), + panels.RackNumberingPanel(_('Numbering')), + panels.RackWeightPanel(_('Weight')), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ImageAttachmentsPanel(), + PluginContentPanel('left_page'), + ), + layout.Column( + TemplatePanel('dcim/panels/rack_elevations.html'), + RelatedObjectsPanel(), + PluginContentPanel('right_page'), + ), + ), + layout.Row( + layout.Column( + PluginContentPanel('full_width_page'), + ), + ), + ) def get_extra_context(self, request, instance): peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site) diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index d63558b90d5..328ef98df12 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -19,6 +19,7 @@ 'RelatedObjectsPanel', 'Panel', 'PluginContentPanel', + 'TemplatePanel', ) @@ -142,6 +143,13 @@ def get_context(self, obj): } +class TemplatePanel(Panel): + + def __init__(self, template_name, **kwargs): + super().__init__(**kwargs) + self.template_name = template_name + + class PluginContentPanel(Panel): def __init__(self, method, **kwargs): diff --git a/netbox/templates/dcim/panels/rack_elevations.html b/netbox/templates/dcim/panels/rack_elevations.html new file mode 100644 index 00000000000..550f54f3b34 --- /dev/null +++ b/netbox/templates/dcim/panels/rack_elevations.html @@ -0,0 +1,22 @@ +{% load i18n %} +
+ +
+
+
+
+

{% trans "Front" %}

+ {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %} +
+
+
+
+

{% trans "Rear" %}

+ {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %} +
+
+
From 17429c4257f92fbfeb0f102f4ac4771dc97157d1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 15:56:45 -0500 Subject: [PATCH 22/37] Clean up obsolete code --- netbox/netbox/ui/layout.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/netbox/netbox/ui/layout.py b/netbox/netbox/ui/layout.py index 1ff362e3216..a314a597bbc 100644 --- a/netbox/netbox/ui/layout.py +++ b/netbox/netbox/ui/layout.py @@ -15,12 +15,8 @@ def __init__(self, *rows): raise TypeError(f"Row {i} must be a Row instance, not {type(row)}.") self.rows = rows - def render(self, context): - return ''.join([row.render(context) for row in self.rows]) - class Row: - template_name = 'ui/layout/row.html' def __init__(self, *columns): for i, column in enumerate(columns): @@ -28,9 +24,6 @@ def __init__(self, *columns): raise TypeError(f"Column {i} must be a Column instance, not {type(column)}.") self.columns = columns - def render(self, context): - return ''.join([column.render(context) for column in self.columns]) - class Column: @@ -39,6 +32,3 @@ def __init__(self, *panels): if not isinstance(panel, Panel): raise TypeError(f"Panel {i} must be an instance of a Panel, not {type(panel)}.") self.panels = panels - - def render(self, context): - return ''.join([panel.render(context) for panel in self.panels]) From c05106f9b266aa1cee47b054c69dce50b24ae0c2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 17:04:24 -0500 Subject: [PATCH 23/37] Limit object assignment to object panels --- netbox/dcim/views.py | 62 +++++++++++++++++++---------- netbox/extras/ui/panels.py | 16 ++++++-- netbox/netbox/ui/actions.py | 12 +++--- netbox/netbox/ui/panels.py | 78 ++++++++++++++++++++++++------------- 4 files changed, 110 insertions(+), 58 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index facccb65a04..8fe7db4068a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -247,9 +247,9 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView): ObjectsTablePanel( model='dcim.Region', title=_('Child Regions'), - filters={'parent_id': lambda obj: obj.pk}, + filters={'parent_id': lambda ctx: ctx['object'].pk}, actions=[ - actions.AddObject('dcim.Region', url_params={'parent': lambda obj: obj.pk}), + actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}), ], ), PluginContentPanel('full_width_page'), @@ -386,9 +386,9 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): ObjectsTablePanel( model='dcim.SiteGroup', title=_('Child Groups'), - filters={'parent_id': lambda obj: obj.pk}, + filters={'parent_id': lambda ctx: ctx['object'].pk}, actions=[ - actions.AddObject('dcim.Region', url_params={'parent': lambda obj: obj.pk}), + actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}), ], ), PluginContentPanel('full_width_page'), @@ -543,21 +543,21 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): layout.Column( ObjectsTablePanel( model='dcim.Location', - filters={'site_id': lambda obj: obj.pk}, + filters={'site_id': lambda ctx: ctx['object'].pk}, actions=[ - actions.AddObject('dcim.Location', url_params={'site': lambda obj: obj.pk}), + actions.AddObject('dcim.Location', url_params={'site': lambda ctx: ctx['object'].pk}), ], ), ObjectsTablePanel( model='dcim.Device', title=_('Non-Racked Devices'), filters={ - 'site_id': lambda obj: obj.pk, + 'site_id': lambda ctx: ctx['object'].pk, 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, }, actions=[ - actions.AddObject('dcim.Device', url_params={'site': lambda obj: obj.pk}), + actions.AddObject('dcim.Device', url_params={'site': lambda ctx: ctx['object'].pk}), ], ), PluginContentPanel('full_width_page'), @@ -684,13 +684,13 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView): ObjectsTablePanel( model='dcim.Location', title=_('Child Locations'), - filters={'parent_id': lambda obj: obj.pk}, + filters={'parent_id': lambda ctx: ctx['object'].pk}, actions=[ actions.AddObject( 'dcim.Location', url_params={ - 'site': lambda obj: obj.site.pk if obj.site else None, - 'parent': lambda obj: obj.pk, + 'site': lambda ctx: ctx['object'].site_id, + 'parent': lambda ctx: ctx['object'].pk, } ), ], @@ -699,7 +699,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView): model='dcim.Device', title=_('Non-Racked Devices'), filters={ - 'location_id': lambda obj: obj.pk, + 'location_id': lambda ctx: ctx['object'].pk, 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, }, @@ -707,8 +707,8 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView): actions.AddObject( 'dcim.Device', url_params={ - 'site': lambda obj: obj.site.pk if obj.site else None, - 'parent': lambda obj: obj.pk, + 'site': lambda ctx: ctx['object'].site_id, + 'parent': lambda ctx: ctx['object'].pk, } ), ], @@ -907,14 +907,14 @@ class RackTypeView(GetRelatedModelsMixin, generic.ObjectView): layout.Row( layout.Column( panels.RackTypePanel(), - panels.RackDimensionsPanel(_('Dimensions')), + panels.RackDimensionsPanel(title=_('Dimensions')), TagsPanel(), CommentsPanel(), PluginContentPanel('left_page'), ), layout.Column( - panels.RackNumberingPanel(_('Numbering')), - panels.RackWeightPanel(_('Weight')), + panels.RackNumberingPanel(title=_('Numbering')), + panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']), CustomFieldsPanel(), RelatedObjectsPanel(), PluginContentPanel('right_page'), @@ -1047,9 +1047,9 @@ class RackView(GetRelatedModelsMixin, generic.ObjectView): layout.Row( layout.Column( panels.RackPanel(), - panels.RackDimensionsPanel(_('Dimensions')), - panels.RackNumberingPanel(_('Numbering')), - panels.RackWeightPanel(_('Weight')), + panels.RackDimensionsPanel(title=_('Dimensions')), + panels.RackNumberingPanel(title=_('Numbering')), + panels.RackWeightPanel(title=_('Weight')), CustomFieldsPanel(), TagsPanel(), CommentsPanel(), @@ -1199,6 +1199,28 @@ class RackReservationListView(generic.ObjectListView): @register_model_view(RackReservation) class RackReservationView(generic.ObjectView): queryset = RackReservation.objects.all() + layout = layout.Layout( + layout.Row( + layout.Column( + panels.RackPanel(accessor='rack', only=['region', 'site', 'location']), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ImageAttachmentsPanel(), + PluginContentPanel('left_page'), + ), + layout.Column( + TemplatePanel('dcim/panels/rack_elevations.html'), + RelatedObjectsPanel(), + PluginContentPanel('right_page'), + ), + ), + layout.Row( + layout.Column( + PluginContentPanel('full_width_page'), + ), + ), + ) @register_model_view(RackReservation, 'add', detail=False) diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py index 5d789f64095..991a4aa3dbb 100644 --- a/netbox/extras/ui/panels.py +++ b/netbox/extras/ui/panels.py @@ -14,8 +14,10 @@ class CustomFieldsPanel(panels.Panel): template_name = 'ui/panels/custom_fields.html' title = _('Custom Fields') - def get_context(self, obj): + def get_context(self, context): + obj = context['object'] return { + **super().get_context(context), 'custom_fields': obj.get_custom_fields_by_group(), } @@ -25,9 +27,9 @@ class ImageAttachmentsPanel(panels.ObjectsTablePanel): actions.AddObject( 'extras.imageattachment', url_params={ - 'object_type': lambda obj: ContentType.objects.get_for_model(obj).pk, - 'object_id': lambda obj: obj.pk, - 'return_url': lambda obj: obj.get_absolute_url(), + 'object_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk, + 'object_id': lambda ctx: ctx['object'].pk, + 'return_url': lambda ctx: ctx['object'].get_absolute_url(), }, label=_('Attach an image'), ), @@ -40,3 +42,9 @@ def __init__(self, **kwargs): class TagsPanel(panels.Panel): template_name = 'ui/panels/tags.html' title = _('Tags') + + def get_context(self, context): + return { + **super().get_context(context), + 'object': context['object'], + } diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index 79fd9a5d6d2..10be487c8a2 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -26,20 +26,20 @@ def __init__(self, view_name, view_kwargs=None, url_params=None, permissions=Non if label is not None: self.label = label - def get_url(self, obj): + def get_url(self, context): url = reverse(self.view_name, kwargs=self.view_kwargs or {}) if self.url_params: url_params = { - k: v(obj) if callable(v) else v for k, v in self.url_params.items() + k: v(context) if callable(v) else v for k, v in self.url_params.items() } - if 'return_url' not in url_params: - url_params['return_url'] = obj.get_absolute_url() + if 'return_url' not in url_params and 'object' in context: + url_params['return_url'] = context['object'].get_absolute_url() url = f'{url}?{urlencode(url_params)}' return url - def get_context(self, obj): + def get_context(self, context): return { - 'url': self.get_url(obj), + 'url': self.get_url(context), 'label': self.label, 'button_class': self.button_class, 'button_icon': self.button_icon, diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 328ef98df12..05eae3e36b3 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -34,18 +34,15 @@ def __init__(self, title=None, actions=None): if actions is not None: self.actions = actions - def get_context(self, obj): - return {} + def get_context(self, context): + return { + 'request': context.get('request'), + 'title': self.title, + 'actions': [action.get_context(context) for action in self.actions], + } def render(self, context): - obj = context.get('object') - return render_to_string(self.template_name, { - 'request': context.get('request'), - 'object': obj, - 'title': self.title or title(obj._meta.verbose_name), - 'actions': [action.get_context(obj) for action in self.actions], - **self.get_context(obj), - }) + return render_to_string(self.template_name, self.get_context(context)) class ObjectPanelMeta(ABCMeta): @@ -76,17 +73,39 @@ def __new__(mcls, name, bases, namespace, **kwargs): class ObjectPanel(Panel, metaclass=ObjectPanelMeta): + accessor = None template_name = 'ui/panels/object.html' - def get_context(self, obj): - attrs = [ - { - 'label': attr.label or title(name), - 'value': attr.render(obj, {'name': name}), - } for name, attr in self._attrs.items() - ] + def __init__(self, accessor=None, only=None, exclude=None, **kwargs): + 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("attrs and exclude cannot both be specified.") + self.only = only or [] + self.exclude = exclude or [] + + 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) + + obj = getattr(context['object'], self.accessor) if self.accessor else context['object'] + return { - 'attrs': attrs, + **super().get_context(context), + 'object': obj, + 'attrs': [ + { + 'label': attr.label or title(name), + 'value': attr.render(obj, {'name': name}), + } for name, attr in self._attrs.items() if name in attr_names + ], } @@ -108,13 +127,11 @@ class RelatedObjectsPanel(Panel): template_name = 'ui/panels/related_objects.html' title = _('Related Objects') - # TODO: Handle related_models from context - def render(self, context): - return render_to_string(self.template_name, { - 'title': self.title, - 'object': context.get('object'), + def get_context(self, context): + return { + **super().get_context(context), 'related_models': context.get('related_models'), - }) + } class ObjectsTablePanel(Panel): @@ -131,13 +148,14 @@ def __init__(self, model, filters=None, **kwargs): if self.title is None: self.title = title(self.model._meta.verbose_name_plural) - def get_context(self, obj): + def get_context(self, context): url_params = { - k: v(obj) if callable(v) else v for k, v in self.filters.items() + k: v(context) if callable(v) else v for k, v in self.filters.items() } - if 'return_url' not in url_params: - url_params['return_url'] = obj.get_absolute_url() + 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), } @@ -149,6 +167,10 @@ 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): From 59899d0d9a692c8fe1f93e09bed36eb0cf90e1c2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 4 Nov 2025 16:49:56 -0500 Subject: [PATCH 24/37] Lots of cleanup --- netbox/dcim/views.py | 2 +- netbox/netbox/ui/actions.py | 73 +++++++++-- netbox/netbox/ui/panels.py | 116 ++++++++++++++++-- netbox/templates/generic/object.html | 2 +- netbox/templates/ui/action.html | 6 + netbox/templates/ui/panels/_base.html | 7 +- .../utilities/templatetags/builtins/tags.py | 4 +- 7 files changed, 184 insertions(+), 26 deletions(-) create mode 100644 netbox/templates/ui/action.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 8fe7db4068a..e0274f6606d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1202,7 +1202,7 @@ class RackReservationView(generic.ObjectView): layout = layout.Layout( layout.Row( layout.Column( - panels.RackPanel(accessor='rack', only=['region', 'site', 'location']), + panels.RackPanel(title=_('Rack'), accessor='rack', only=['region', 'site', 'location']), CustomFieldsPanel(), TagsPanel(), CommentsPanel(), diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index 10be487c8a2..8a3d7ecb12b 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -1,6 +1,7 @@ from urllib.parse import urlencode from django.apps import apps +from django.template.loader import render_to_string from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -14,48 +15,102 @@ class PanelAction: + """ + A link (typically a button) within a panel to perform some associated action, such as adding an object. + + Attributes: + template_name: The name of the template to render + label: The default human-friendly button text + button_class: Bootstrap CSS class for the button + button_icon: Name of the button's MDI icon + """ + template_name = 'ui/action.html' label = None button_class = 'primary' button_icon = None def __init__(self, view_name, view_kwargs=None, url_params=None, permissions=None, label=None): + """ + Initialize a new PanelAction. + + Parameters: + view_name: Name of the view to which the action will link + view_kwargs: Additional keyword arguments to pass to the view when resolving its URL + url_params: A dictionary of arbitrary URL parameters to append to the action's URL + permissions: A list of permissions required to display the action + label: The human-friendly button text + """ self.view_name = view_name - self.view_kwargs = view_kwargs + self.view_kwargs = view_kwargs or {} self.url_params = url_params or {} self.permissions = permissions if label is not None: self.label = label def get_url(self, context): - url = reverse(self.view_name, kwargs=self.view_kwargs or {}) + """ + Resolve the URL for the action from its view name and kwargs. Append any additional URL parameters. + + Parameters: + context: The template context + """ + url = reverse(self.view_name, kwargs=self.view_kwargs) if self.url_params: + # If the param value is callable, call it with the context and save the result. url_params = { k: v(context) if callable(v) else v for k, v in self.url_params.items() } + # Set the return URL if not already set and an object is available. if 'return_url' not in url_params and 'object' in context: url_params['return_url'] = context['object'].get_absolute_url() url = f'{url}?{urlencode(url_params)}' return url - def get_context(self, context): - return { + def render(self, context): + """ + Render the action as HTML. + + Parameters: + context: The template context + """ + # Enforce permissions + user = context['request'].user + if not user.has_perms(self.permissions): + return '' + + return render_to_string(self.template_name, { 'url': self.get_url(context), 'label': self.label, 'button_class': self.button_class, 'button_icon': self.button_icon, - } + }) class AddObject(PanelAction): + """ + An action to add a new object. + """ label = _('Add') button_icon = 'plus-thick' - def __init__(self, model, label=None, url_params=None): + def __init__(self, model, url_params=None, label=None): + """ + Initialize a new AddObject action. + + Parameters: + model: The dotted label of the model to be added (e.g. "dcim.site") + url_params: A dictionary of arbitrary URL parameters to append to the resolved URL + label: The human-friendly button text + """ # Resolve the model class from its app.name label - app_label, model_name = model.split('.') - model = apps.get_model(app_label, model_name) + try: + app_label, model_name = model.split('.') + model = apps.get_model(app_label, model_name) + except (ValueError, LookupError): + raise ValueError(f"Invalid model label: {model}") view_name = get_viewname(model, 'add') + super().__init__(view_name=view_name, label=label, url_params=url_params) - # Require "add" permission on the model by default + # Require "add" permission on the model self.permissions = [get_permission_for_model(model, 'add')] diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 05eae3e36b3..53a6f0792b8 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -24,24 +24,52 @@ class Panel(ABC): + """ + A block of content rendered within an HTML template. + + Attributes: + template_name: The name of the template to render + title: The human-friendly title of the panel + actions: A list of PanelActions to include in the panel header + """ template_name = None title = None actions = [] def __init__(self, title=None, actions=None): + """ + Instantiate a new Panel. + + Parameters: + title: The human-friendly title of the panel + actions: A list of PanelActions to include in the panel header + """ if title is not None: self.title = title if actions is not None: self.actions = actions def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ return { 'request': context.get('request'), + 'object': context.get('object'), 'title': self.title, - 'actions': [action.get_context(context) for action in self.actions], + 'actions': self.actions, } def render(self, context): + """ + Render the panel as HTML. + + Parameters: + context: The template context + """ return render_to_string(self.template_name, self.get_context(context)) @@ -73,21 +101,43 @@ def __new__(mcls, name, bases, namespace, **kwargs): class ObjectPanel(Panel, metaclass=ObjectPanelMeta): - accessor = None + """ + A panel which displays selected attributes of an object. + + Attributes: + 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 def __init__(self, accessor=None, only=None, exclude=None, **kwargs): + """ + Instantiate a new ObjectPanel. + + Parameters: + accessor: The name of the attribute on the object + only: If specified, only attributes in this list will be displayed + exclude: If specified, attributes in this list will be excluded from display + """ 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("attrs and exclude cannot both be specified.") + raise ValueError("only and exclude cannot both be specified.") self.only = only or [] self.exclude = exclude or [] def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ # Determine which attributes to display in the panel based on only/exclude args attr_names = set(self._attrs.keys()) if self.only: @@ -99,7 +149,6 @@ def get_context(self, context): return { **super().get_context(context), - 'object': obj, 'attrs': [ { 'label': attr.label or title(name), @@ -110,24 +159,42 @@ def get_context(self, context): class OrganizationalObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): + """ + An ObjectPanel with attributes common to OrganizationalModels. + """ name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) class NestedGroupObjectPanel(OrganizationalObjectPanel, metaclass=ObjectPanelMeta): + """ + An ObjectPanel with attributes common to NestedGroupObjects. + """ parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) class CommentsPanel(Panel): + """ + A panel which displays comments associated with an object. + """ template_name = 'ui/panels/comments.html' title = _('Comments') 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 the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ return { **super().get_context(context), 'related_models': context.get('related_models'), @@ -135,20 +202,42 @@ def get_context(self, context): class ObjectsTablePanel(Panel): + """ + A panel which displays a table of objects (rendered via HTMX). + """ template_name = 'ui/panels/objects_table.html' title = None def __init__(self, model, filters=None, **kwargs): + """ + Instantiate a new ObjectsTablePanel. + + Parameters: + model: The dotted label of the model to be added (e.g. "dcim.site") + filters: A dictionary of arbitrary URL parameters to append to the table's URL + """ super().__init__(**kwargs) # Resolve the model class from its app.name label - app_label, model_name = model.split('.') - self.model = apps.get_model(app_label, model_name) + 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): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ url_params = { k: v(context) if callable(v) else v for k, v in self.filters.items() } @@ -162,8 +251,16 @@ def get_context(self, context): class TemplatePanel(Panel): - + """ + A panel which renders content using an HTML template. + """ def __init__(self, template_name, **kwargs): + """ + Instantiate a new TemplatePanel. + + Parameters: + template_name: The name of the template to render + """ super().__init__(**kwargs) self.template_name = template_name @@ -173,7 +270,12 @@ def render(self, context): class PluginContentPanel(Panel): + """ + A panel which displays embedded plugin content. + Parameters: + method: The name of the plugin method to render (e.g. left_page) + """ def __init__(self, method, **kwargs): super().__init__(**kwargs) self.method = method diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index a9783178a57..100d5bde775 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -129,7 +129,7 @@ {% for column in row.columns %}
{% for panel in column.panels %} - {% render_panel panel %} + {% render panel %} {% endfor %}
{% endfor %} diff --git a/netbox/templates/ui/action.html b/netbox/templates/ui/action.html new file mode 100644 index 00000000000..c61357312d9 --- /dev/null +++ b/netbox/templates/ui/action.html @@ -0,0 +1,6 @@ + + {% if button_icon %} + + {% endif %} + {{ label }} + diff --git a/netbox/templates/ui/panels/_base.html b/netbox/templates/ui/panels/_base.html index 47ae689b79b..1f11b020f05 100644 --- a/netbox/templates/ui/panels/_base.html +++ b/netbox/templates/ui/panels/_base.html @@ -4,12 +4,7 @@

{% if actions %}
{% for action in actions %} - - {% if action.button_icon %} - - {% endif %} - {{ action.label }} - + {% render action %} {% endfor %}
{% endif %} diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 92c68f05275..cab4f9f20eb 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -183,5 +183,5 @@ def static_with_params(path, **params): @register.simple_tag(takes_context=True) -def render_panel(context, panel): - return mark_safe(panel.render(context)) +def render(context, component): + return mark_safe(component.render(context)) From d5cec3723ef8dfc3bbafa88fdd28d9675377fc79 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 4 Nov 2025 17:14:24 -0500 Subject: [PATCH 25/37] Introduce SimpleLayout --- netbox/dcim/views.py | 391 ++++++++++++++++--------------------- netbox/netbox/ui/layout.py | 31 ++- netbox/netbox/ui/panels.py | 1 + 3 files changed, 197 insertions(+), 226 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e0274f6606d..3d2dad9032a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -21,7 +21,8 @@ from netbox.object_actions import * from netbox.ui import actions, layout from netbox.ui.panels import ( - CommentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, PluginContentPanel, RelatedObjectsPanel, TemplatePanel, + CommentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, OrganizationalObjectPanel, RelatedObjectsPanel, + TemplatePanel, ) from netbox.views import generic from utilities.forms import ConfirmationForm @@ -228,33 +229,26 @@ class RegionListView(generic.ObjectListView): @register_model_view(Region) class RegionView(GetRelatedModelsMixin, generic.ObjectView): queryset = Region.objects.all() - layout = layout.Layout( - layout.Row( - layout.Column( - NestedGroupObjectPanel(), - TagsPanel(), - CustomFieldsPanel(), - CommentsPanel(), - PluginContentPanel('left_page'), + layout = layout.SimpleLayout( + left_panels=[ + NestedGroupObjectPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.Region', + title=_('Child Regions'), + filters={'parent_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}), + ], ), - layout.Column( - RelatedObjectsPanel(), - PluginContentPanel('right_page'), - ), - ), - layout.Row( - layout.Column( - ObjectsTablePanel( - model='dcim.Region', - title=_('Child Regions'), - filters={'parent_id': lambda ctx: ctx['object'].pk}, - actions=[ - actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}), - ], - ), - PluginContentPanel('full_width_page'), - ), - ), + ] ) def get_extra_context(self, request, instance): @@ -367,33 +361,26 @@ class SiteGroupListView(generic.ObjectListView): @register_model_view(SiteGroup) class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = SiteGroup.objects.all() - layout = layout.Layout( - layout.Row( - layout.Column( - NestedGroupObjectPanel(), - TagsPanel(), - CustomFieldsPanel(), - CommentsPanel(), - PluginContentPanel('left_page'), - ), - layout.Column( - RelatedObjectsPanel(), - PluginContentPanel('right_page'), + layout = layout.SimpleLayout( + left_panels=[ + NestedGroupObjectPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.SiteGroup', + title=_('Child Groups'), + filters={'parent_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}), + ], ), - ), - layout.Row( - layout.Column( - ObjectsTablePanel( - model='dcim.SiteGroup', - title=_('Child Groups'), - filters={'parent_id': lambda ctx: ctx['object'].pk}, - actions=[ - actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}), - ], - ), - PluginContentPanel('full_width_page'), - ), - ), + ] ) def get_extra_context(self, request, instance): @@ -524,45 +511,38 @@ class SiteListView(generic.ObjectListView): @register_model_view(Site) class SiteView(GetRelatedModelsMixin, generic.ObjectView): queryset = Site.objects.prefetch_related('tenant__group') - layout = layout.Layout( - layout.Row( - layout.Column( - panels.SitePanel(), - CustomFieldsPanel(), - TagsPanel(), - CommentsPanel(), - PluginContentPanel('left_page'), + layout = layout.SimpleLayout( + left_panels=[ + panels.SitePanel(), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + ImageAttachmentsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.Location', + filters={'site_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject('dcim.Location', url_params={'site': lambda ctx: ctx['object'].pk}), + ], ), - layout.Column( - RelatedObjectsPanel(), - ImageAttachmentsPanel(), - PluginContentPanel('right_page'), + ObjectsTablePanel( + model='dcim.Device', + title=_('Non-Racked Devices'), + filters={ + 'site_id': lambda ctx: ctx['object'].pk, + 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, + 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, + }, + actions=[ + actions.AddObject('dcim.Device', url_params={'site': lambda ctx: ctx['object'].pk}), + ], ), - ), - layout.Row( - layout.Column( - ObjectsTablePanel( - model='dcim.Location', - filters={'site_id': lambda ctx: ctx['object'].pk}, - actions=[ - actions.AddObject('dcim.Location', url_params={'site': lambda ctx: ctx['object'].pk}), - ], - ), - ObjectsTablePanel( - model='dcim.Device', - title=_('Non-Racked Devices'), - filters={ - 'site_id': lambda ctx: ctx['object'].pk, - 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, - 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, - }, - actions=[ - actions.AddObject('dcim.Device', url_params={'site': lambda ctx: ctx['object'].pk}), - ], - ), - PluginContentPanel('full_width_page'), - ), - ), + ] ) def get_extra_context(self, request, instance): @@ -664,58 +644,51 @@ class LocationListView(generic.ObjectListView): @register_model_view(Location) class LocationView(GetRelatedModelsMixin, generic.ObjectView): queryset = Location.objects.all() - layout = layout.Layout( - layout.Row( - layout.Column( - panels.LocationPanel(), - TagsPanel(), - CustomFieldsPanel(), - CommentsPanel(), - PluginContentPanel('left_page'), - ), - layout.Column( - RelatedObjectsPanel(), - ImageAttachmentsPanel(), - PluginContentPanel('right_page'), + layout = layout.SimpleLayout( + left_panels=[ + panels.LocationPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + ImageAttachmentsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.Location', + title=_('Child Locations'), + filters={'parent_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject( + 'dcim.Location', + url_params={ + 'site': lambda ctx: ctx['object'].site_id, + 'parent': lambda ctx: ctx['object'].pk, + } + ), + ], ), - ), - layout.Row( - layout.Column( - ObjectsTablePanel( - model='dcim.Location', - title=_('Child Locations'), - filters={'parent_id': lambda ctx: ctx['object'].pk}, - actions=[ - actions.AddObject( - 'dcim.Location', - url_params={ - 'site': lambda ctx: ctx['object'].site_id, - 'parent': lambda ctx: ctx['object'].pk, - } - ), - ], - ), - ObjectsTablePanel( - model='dcim.Device', - title=_('Non-Racked Devices'), - filters={ - 'location_id': lambda ctx: ctx['object'].pk, - 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, - 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, - }, - actions=[ - actions.AddObject( - 'dcim.Device', - url_params={ - 'site': lambda ctx: ctx['object'].site_id, - 'parent': lambda ctx: ctx['object'].pk, - } - ), - ], - ), - PluginContentPanel('full_width_page'), + ObjectsTablePanel( + model='dcim.Device', + title=_('Non-Racked Devices'), + filters={ + 'location_id': lambda ctx: ctx['object'].pk, + 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, + 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, + }, + actions=[ + actions.AddObject( + 'dcim.Device', + url_params={ + 'site': lambda ctx: ctx['object'].site_id, + 'parent': lambda ctx: ctx['object'].pk, + } + ), + ], ), - ), + ] ) def get_extra_context(self, request, instance): @@ -817,24 +790,15 @@ class RackRoleListView(generic.ObjectListView): @register_model_view(RackRole) class RackRoleView(GetRelatedModelsMixin, generic.ObjectView): queryset = RackRole.objects.all() - layout = layout.Layout( - layout.Row( - layout.Column( - panels.RackRolePanel(), - TagsPanel(), - PluginContentPanel('left_page'), - ), - layout.Column( - RelatedObjectsPanel(), - CustomFieldsPanel(), - PluginContentPanel('right_page'), - ), - ), - layout.Row( - layout.Column( - PluginContentPanel('full_width_page'), - ), - ), + layout = layout.SimpleLayout( + left_panels=[ + panels.RackRolePanel(), + TagsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + CustomFieldsPanel(), + ], ) def get_extra_context(self, request, instance): @@ -903,28 +867,19 @@ class RackTypeListView(generic.ObjectListView): @register_model_view(RackType) class RackTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = RackType.objects.all() - layout = layout.Layout( - layout.Row( - layout.Column( - panels.RackTypePanel(), - panels.RackDimensionsPanel(title=_('Dimensions')), - TagsPanel(), - CommentsPanel(), - PluginContentPanel('left_page'), - ), - layout.Column( - panels.RackNumberingPanel(title=_('Numbering')), - panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']), - CustomFieldsPanel(), - RelatedObjectsPanel(), - PluginContentPanel('right_page'), - ), - ), - layout.Row( - layout.Column( - PluginContentPanel('full_width_page'), - ), - ), + layout = layout.SimpleLayout( + left_panels=[ + panels.RackTypePanel(), + panels.RackDimensionsPanel(title=_('Dimensions')), + TagsPanel(), + CommentsPanel(), + ], + right_panels=[ + panels.RackNumberingPanel(title=_('Numbering')), + panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']), + CustomFieldsPanel(), + RelatedObjectsPanel(), + ], ) def get_extra_context(self, request, instance): @@ -1043,30 +998,21 @@ def get(self, request): @register_model_view(Rack) class RackView(GetRelatedModelsMixin, generic.ObjectView): queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') - layout = layout.Layout( - layout.Row( - layout.Column( - panels.RackPanel(), - panels.RackDimensionsPanel(title=_('Dimensions')), - panels.RackNumberingPanel(title=_('Numbering')), - panels.RackWeightPanel(title=_('Weight')), - CustomFieldsPanel(), - TagsPanel(), - CommentsPanel(), - ImageAttachmentsPanel(), - PluginContentPanel('left_page'), - ), - layout.Column( - TemplatePanel('dcim/panels/rack_elevations.html'), - RelatedObjectsPanel(), - PluginContentPanel('right_page'), - ), - ), - layout.Row( - layout.Column( - PluginContentPanel('full_width_page'), - ), - ), + layout = layout.SimpleLayout( + left_panels=[ + panels.RackPanel(), + panels.RackDimensionsPanel(title=_('Dimensions')), + panels.RackNumberingPanel(title=_('Numbering')), + panels.RackWeightPanel(title=_('Weight')), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ImageAttachmentsPanel(), + ], + right_panels=[ + TemplatePanel('dcim/panels/rack_elevations.html'), + RelatedObjectsPanel(), + ], ) def get_extra_context(self, request, instance): @@ -1199,27 +1145,18 @@ class RackReservationListView(generic.ObjectListView): @register_model_view(RackReservation) class RackReservationView(generic.ObjectView): queryset = RackReservation.objects.all() - layout = layout.Layout( - layout.Row( - layout.Column( - panels.RackPanel(title=_('Rack'), accessor='rack', only=['region', 'site', 'location']), - CustomFieldsPanel(), - TagsPanel(), - CommentsPanel(), - ImageAttachmentsPanel(), - PluginContentPanel('left_page'), - ), - layout.Column( - TemplatePanel('dcim/panels/rack_elevations.html'), - RelatedObjectsPanel(), - PluginContentPanel('right_page'), - ), - ), - layout.Row( - layout.Column( - PluginContentPanel('full_width_page'), - ), - ), + layout = layout.SimpleLayout( + left_panels=[ + panels.RackPanel(accessor='rack', only=['region', 'site', 'location']), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ImageAttachmentsPanel(), + ], + right_panels=[ + TemplatePanel('dcim/panels/rack_elevations.html'), + RelatedObjectsPanel(), + ], ) @@ -1294,6 +1231,10 @@ class ManufacturerListView(generic.ObjectListView): @register_model_view(Manufacturer) class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView): queryset = Manufacturer.objects.all() + layout = layout.SimpleLayout( + left_panels=[OrganizationalObjectPanel(), TagsPanel()], + right_panels=[RelatedObjectsPanel(), CustomFieldsPanel()], + ) def get_extra_context(self, request, instance): return { diff --git a/netbox/netbox/ui/layout.py b/netbox/netbox/ui/layout.py index a314a597bbc..6612917a763 100644 --- a/netbox/netbox/ui/layout.py +++ b/netbox/netbox/ui/layout.py @@ -1,12 +1,17 @@ -from netbox.ui.panels import Panel +from netbox.ui.panels import Panel, PluginContentPanel __all__ = ( 'Column', 'Layout', 'Row', + 'SimpleLayout', ) +# +# Base classes +# + class Layout: def __init__(self, *rows): @@ -32,3 +37,27 @@ def __init__(self, *panels): if not isinstance(panel, Panel): raise TypeError(f"Panel {i} must be an instance of a Panel, not {type(panel)}.") self.panels = panels + + +# +# Standard layouts +# + +class SimpleLayout(Layout): + """ + A layout with one row of two columns and a second row with one column. Includes registered plugin content. + """ + def __init__(self, left_panels=None, right_panels=None, bottom_panels=None): + left_panels = left_panels or [] + right_panels = right_panels or [] + bottom_panels = bottom_panels or [] + rows = [ + Row( + Column(*left_panels, PluginContentPanel('left_page')), + Column(*right_panels, PluginContentPanel('right_page')), + ), + Row( + Column(*bottom_panels, PluginContentPanel('full_width_page')) + ) + ] + super().__init__(*rows) diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 53a6f0792b8..b2f7ad2ebe0 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -149,6 +149,7 @@ def get_context(self, context): return { **super().get_context(context), + 'title': self.title or title(obj._meta.verbose_name), 'attrs': [ { 'label': attr.label or title(name), From 1de41b4964909b37ac7fff9b1ba2c060574343e2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 4 Nov 2025 20:06:18 -0500 Subject: [PATCH 26/37] Add layouts for DeviceType & ModuleTypeProfile --- netbox/dcim/ui/panels.py | 21 +++++++++ netbox/dcim/views.py | 42 +++++++++++++++++- netbox/netbox/ui/actions.py | 30 ++++++++++++- netbox/netbox/ui/attrs.py | 14 ++++++ netbox/netbox/ui/panels.py | 43 +++++++++++++++++-- netbox/templates/ui/actions/copy_content.html | 7 +++ .../ui/{action.html => actions/link.html} | 2 +- netbox/templates/ui/attrs/boolean.html | 2 +- netbox/templates/ui/attrs/image.html | 3 ++ netbox/templates/ui/panels/json.html | 5 +++ 10 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 netbox/templates/ui/actions/copy_content.html rename netbox/templates/ui/{action.html => actions/link.html} (56%) create mode 100644 netbox/templates/ui/attrs/image.html create mode 100644 netbox/templates/ui/panels/json.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index d26dfda4555..d6309c6004b 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -112,3 +112,24 @@ class DeviceManagementPanel(panels.ObjectPanel): label=_('Out-of-band IP'), template_name='dcim/device/attrs/ipaddress.html', ) + + +class DeviceTypePanel(panels.ObjectPanel): + manufacturer = attrs.ObjectAttr('manufacturer', label=_('Manufacturer'), linkify=True) + model = attrs.TextAttr('model', label=_('Model')) + part_number = attrs.TextAttr('part_number', label=_('Part number')) + default_platform = attrs.ObjectAttr('default_platform', label=_('Default platform'), linkify=True) + description = attrs.TextAttr('description', label=_('Description')) + u_height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) + exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization', label=_('Exclude from utilization')) + full_depth = attrs.BooleanAttr('is_full_depth', label=_('Full depth')) + weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display', label=_('Weight')) + subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child')) + airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) + front_image = attrs.ImageAttr('front_image', label=_('Front image')) + rear_image = attrs.ImageAttr('rear_image', label=_('Rear image')) + + +class ModuleTypeProfilePanel(panels.ObjectPanel): + name = attrs.TextAttr('name', label=_('Name')) + description = attrs.TextAttr('description', label=_('Description')) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3d2dad9032a..051d2867def 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -21,7 +21,7 @@ from netbox.object_actions import * from netbox.ui import actions, layout from netbox.ui.panels import ( - CommentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, OrganizationalObjectPanel, RelatedObjectsPanel, + CommentsPanel, JSONPanel, NestedGroupObjectPanel, ObjectsTablePanel, OrganizationalObjectPanel, RelatedObjectsPanel, TemplatePanel, ) from netbox.views import generic @@ -1308,6 +1308,18 @@ class DeviceTypeListView(generic.ObjectListView): @register_model_view(DeviceType) class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = DeviceType.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.DeviceTypePanel(), + TagsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + ImageAttachmentsPanel(), + ], + ) def get_extra_context(self, request, instance): return { @@ -1559,6 +1571,34 @@ class ModuleTypeProfileListView(generic.ObjectListView): @register_model_view(ModuleTypeProfile) class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView): queryset = ModuleTypeProfile.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.ModuleTypeProfilePanel(), + TagsPanel(), + CommentsPanel(), + ], + right_panels=[ + JSONPanel(field_name='schema', title=_('Schema')), + CustomFieldsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.ModuleType', + title=_('Module Types'), + filters={ + 'profile_id': lambda ctx: ctx['object'].pk, + }, + actions=[ + actions.AddObject( + 'dcim.ModuleType', + url_params={ + 'profile': lambda ctx: ctx['object'].pk, + } + ), + ], + ), + ] + ) @register_model_view(ModuleTypeProfile, 'add', detail=False) diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index 8a3d7ecb12b..a94520bdca6 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -24,11 +24,12 @@ class PanelAction: button_class: Bootstrap CSS class for the button button_icon: Name of the button's MDI icon """ - template_name = 'ui/action.html' + template_name = 'ui/actions/link.html' label = None button_class = 'primary' button_icon = None + # TODO: Refactor URL parameters to AddObject def __init__(self, view_name, view_kwargs=None, url_params=None, permissions=None, label=None): """ Initialize a new PanelAction. @@ -114,3 +115,30 @@ def __init__(self, model, url_params=None, label=None): # Require "add" permission on the model self.permissions = [get_permission_for_model(model, 'add')] + + +class CopyContent: + """ + An action to copy the contents of a panel to the clipboard. + """ + template_name = 'ui/actions/copy_content.html' + label = _('Copy') + button_class = 'primary' + button_icon = 'content-copy' + + def __init__(self, target_id): + self.target_id = target_id + + def render(self, context): + """ + Render the action as HTML. + + Parameters: + context: The template context + """ + return render_to_string(self.template_name, { + 'target_id': self.target_id, + 'label': self.label, + 'button_class': self.button_class, + 'button_icon': self.button_icon, + }) diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 4df8f64e13c..72c5dba5f53 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -135,6 +135,20 @@ def render(self, obj, context=None): }) +class ImageAttr(Attr): + template_name = 'ui/attrs/image.html' + + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + if value in (None, ''): + return self.placeholder + return render_to_string(self.template_name, { + **context, + 'value': value, + }) + + class ObjectAttr(Attr): template_name = 'ui/attrs/object.html' diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index b2f7ad2ebe0..eefbde5b490 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.ui import attrs +from netbox.ui.actions import CopyContent from utilities.querydict import dict_to_querydict from utilities.string import title from utilities.templatetags.plugins import _get_registered_content @@ -12,6 +13,7 @@ __all__ = ( 'CommentsPanel', + 'JSONPanel', 'NestedGroupObjectPanel', 'ObjectPanel', 'ObjectsTablePanel', @@ -34,7 +36,7 @@ class Panel(ABC): """ template_name = None title = None - actions = [] + actions = None def __init__(self, title=None, actions=None): """ @@ -46,8 +48,7 @@ def __init__(self, title=None, actions=None): """ if title is not None: self.title = title - if actions is not None: - self.actions = actions + self.actions = actions or [] def get_context(self, context): """ @@ -251,6 +252,42 @@ def get_context(self, context): } +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/actions/copy_content.html b/netbox/templates/ui/actions/copy_content.html new file mode 100644 index 00000000000..67f54354b09 --- /dev/null +++ b/netbox/templates/ui/actions/copy_content.html @@ -0,0 +1,7 @@ +{% load i18n %} + + {% if button_icon %} + + {% endif %} + {{ label }} + diff --git a/netbox/templates/ui/action.html b/netbox/templates/ui/actions/link.html similarity index 56% rename from netbox/templates/ui/action.html rename to netbox/templates/ui/actions/link.html index c61357312d9..11c6b6da9ad 100644 --- a/netbox/templates/ui/action.html +++ b/netbox/templates/ui/actions/link.html @@ -1,4 +1,4 @@ - + {% if button_icon %} {% endif %} diff --git a/netbox/templates/ui/attrs/boolean.html b/netbox/templates/ui/attrs/boolean.html index a724d687bbb..a7087c94f7c 100644 --- a/netbox/templates/ui/attrs/boolean.html +++ b/netbox/templates/ui/attrs/boolean.html @@ -1 +1 @@ -{% checkmark object.desc_units %} +{% checkmark value %} diff --git a/netbox/templates/ui/attrs/image.html b/netbox/templates/ui/attrs/image.html new file mode 100644 index 00000000000..3c10113c4ed --- /dev/null +++ b/netbox/templates/ui/attrs/image.html @@ -0,0 +1,3 @@ + + {{ value.name }} + diff --git a/netbox/templates/ui/panels/json.html b/netbox/templates/ui/panels/json.html new file mode 100644 index 00000000000..36d3d4d1a42 --- /dev/null +++ b/netbox/templates/ui/panels/json.html @@ -0,0 +1,5 @@ +{% extends "ui/panels/_base.html" %} + +{% block panel_content %} +
{{ data|json }}
+{% endblock panel_content %} From 838794a5cf1d77d9b36dfcec27287cb1edc092cb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Nov 2025 10:51:18 -0500 Subject: [PATCH 27/37] Derive attribute labels from name if not passed for instance --- netbox/dcim/ui/panels.py | 138 +++++++++++++++++------------------- netbox/netbox/ui/actions.py | 1 + netbox/netbox/ui/attrs.py | 2 +- netbox/netbox/ui/panels.py | 11 ++- 4 files changed, 79 insertions(+), 73 deletions(-) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index d6309c6004b..9d6e301b2bf 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -4,61 +4,61 @@ class SitePanel(panels.ObjectPanel): - region = attrs.NestedObjectAttr('region', label=_('Region'), linkify=True) - group = attrs.NestedObjectAttr('group', label=_('Group'), linkify=True) - status = attrs.ChoiceAttr('status', label=_('Status')) - tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') - facility = attrs.TextAttr('facility', label=_('Facility')) - description = attrs.TextAttr('description', label=_('Description')) - timezone = attrs.TimezoneAttr('time_zone', label=_('Timezone')) - physical_address = attrs.AddressAttr('physical_address', label=_('Physical address'), map_url=True) - shipping_address = attrs.AddressAttr('shipping_address', label=_('Shipping address'), map_url=True) + region = attrs.NestedObjectAttr('region', linkify=True) + group = attrs.NestedObjectAttr('group', linkify=True) + status = attrs.ChoiceAttr('status') + tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') + facility = attrs.TextAttr('facility') + description = attrs.TextAttr('description') + timezone = attrs.TimezoneAttr('time_zone') + physical_address = attrs.AddressAttr('physical_address', map_url=True) + shipping_address = attrs.AddressAttr('shipping_address', map_url=True) gps_coordinates = attrs.GPSCoordinatesAttr() class LocationPanel(panels.NestedGroupObjectPanel): - site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') - status = attrs.ChoiceAttr('status', label=_('Status')) - tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') - facility = attrs.TextAttr('facility', label=_('Facility')) + site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') + status = attrs.ChoiceAttr('status') + tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') + facility = attrs.TextAttr('facility') class RackDimensionsPanel(panels.ObjectPanel): - form_factor = attrs.ChoiceAttr('form_factor', label=_('Form factor')) - width = attrs.ChoiceAttr('width', label=_('Width')) + form_factor = attrs.ChoiceAttr('form_factor') + width = attrs.ChoiceAttr('width') u_height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) - outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display', label=_('Outer width')) - outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display', label=_('Outer height')) - outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display', label=_('Outer depth')) - mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm', label=_('Mounting depth')) + outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display') + outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display') + outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display') + mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm') class RackNumberingPanel(panels.ObjectPanel): - starting_unit = attrs.TextAttr('starting_unit', label=_('Starting unit')) + starting_unit = attrs.TextAttr('starting_unit') desc_units = attrs.BooleanAttr('desc_units', label=_('Descending units')) class RackPanel(panels.ObjectPanel): - region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) - site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') - location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) - facility = attrs.TextAttr('facility', label=_('Facility ID')) - tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') - status = attrs.ChoiceAttr('status', label=_('Status')) - rack_type = attrs.ObjectAttr('rack_type', label=_('Rack type'), linkify=True, grouped_by='manufacturer') - role = attrs.ObjectAttr('role', label=_('Role'), linkify=True) - description = attrs.TextAttr('description', label=_('Description')) + region = attrs.NestedObjectAttr('site.region', linkify=True) + site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') + location = attrs.NestedObjectAttr('location', linkify=True) + facility = attrs.TextAttr('facility') + tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') + status = attrs.ChoiceAttr('status') + rack_type = attrs.ObjectAttr('rack_type', linkify=True, grouped_by='manufacturer') + role = attrs.ObjectAttr('role', linkify=True) + description = attrs.TextAttr('description') serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) - asset_tag = attrs.TextAttr('asset_tag', label=_('Asset tag'), style='font-monospace', copy_button=True) - airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) - space_utilization = attrs.UtilizationAttr('get_utilization', label=_('Space utilization')) - power_utilization = attrs.UtilizationAttr('get_power_utilization', label=_('Power utilization')) + asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) + airflow = attrs.ChoiceAttr('airflow') + space_utilization = attrs.UtilizationAttr('get_utilization') + power_utilization = attrs.UtilizationAttr('get_power_utilization') class RackWeightPanel(panels.ObjectPanel): - weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display', label=_('Weight')) + 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', label=_('Total weight')) + total_weight = attrs.NumericAttr('total_weight', unit_accessor='get_weight_unit_display') class RackRolePanel(panels.OrganizationalObjectPanel): @@ -66,37 +66,33 @@ class RackRolePanel(panels.OrganizationalObjectPanel): class RackTypePanel(panels.ObjectPanel): - manufacturer = attrs.ObjectAttr('manufacturer', label=_('Manufacturer'), linkify=True) - model = attrs.TextAttr('model', label=_('Model')) - description = attrs.TextAttr('description', label=_('Description')) - airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) + manufacturer = attrs.ObjectAttr('manufacturer', linkify=True) + model = attrs.TextAttr('model') + description = attrs.TextAttr('description') + airflow = attrs.ChoiceAttr('airflow') class DevicePanel(panels.ObjectPanel): - region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) - site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') - location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) - rack = attrs.TemplatedAttr('rack', label=_('Rack'), template_name='dcim/device/attrs/rack.html') - virtual_chassis = attrs.NestedObjectAttr('virtual_chassis', label=_('Virtual chassis'), linkify=True) - parent_device = attrs.TemplatedAttr( - 'parent_bay', - label=_('Parent device'), - template_name='dcim/device/attrs/parent_device.html', - ) + region = attrs.NestedObjectAttr('site.region', linkify=True) + site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') + location = attrs.NestedObjectAttr('location', linkify=True) + rack = attrs.TemplatedAttr('rack', template_name='dcim/device/attrs/rack.html') + virtual_chassis = attrs.NestedObjectAttr('virtual_chassis', linkify=True) + parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html') gps_coordinates = attrs.GPSCoordinatesAttr() - tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') - device_type = attrs.ObjectAttr('device_type', label=_('Device type'), linkify=True, grouped_by='manufacturer') - description = attrs.TextAttr('description', label=_('Description')) - airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) + tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') + device_type = attrs.ObjectAttr('device_type', linkify=True, grouped_by='manufacturer') + description = attrs.TextAttr('description') + airflow = attrs.ChoiceAttr('airflow') serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) - asset_tag = attrs.TextAttr('asset_tag', label=_('Asset tag'), style='font-monospace', copy_button=True) - config_template = attrs.ObjectAttr('config_template', label=_('Config template'), linkify=True) + asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) + config_template = attrs.ObjectAttr('config_template', linkify=True) class DeviceManagementPanel(panels.ObjectPanel): - status = attrs.ChoiceAttr('status', label=_('Status')) - role = attrs.NestedObjectAttr('role', label=_('Role'), linkify=True, max_depth=3) - platform = attrs.NestedObjectAttr('platform', label=_('Platform'), linkify=True, max_depth=3) + status = attrs.ChoiceAttr('status') + role = attrs.NestedObjectAttr('role', linkify=True, max_depth=3) + platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3) primary_ip4 = attrs.TemplatedAttr( 'primary_ip4', label=_('Primary IPv4'), @@ -115,21 +111,21 @@ class DeviceManagementPanel(panels.ObjectPanel): class DeviceTypePanel(panels.ObjectPanel): - manufacturer = attrs.ObjectAttr('manufacturer', label=_('Manufacturer'), linkify=True) - model = attrs.TextAttr('model', label=_('Model')) - part_number = attrs.TextAttr('part_number', label=_('Part number')) - default_platform = attrs.ObjectAttr('default_platform', label=_('Default platform'), linkify=True) - description = attrs.TextAttr('description', label=_('Description')) + manufacturer = attrs.ObjectAttr('manufacturer', linkify=True) + model = attrs.TextAttr('model') + part_number = attrs.TextAttr('part_number') + default_platform = attrs.ObjectAttr('default_platform', linkify=True) + description = attrs.TextAttr('description') u_height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) - exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization', label=_('Exclude from utilization')) - full_depth = attrs.BooleanAttr('is_full_depth', label=_('Full depth')) - weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display', label=_('Weight')) + exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization') + full_depth = attrs.BooleanAttr('is_full_depth') + weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display') subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child')) - airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) - front_image = attrs.ImageAttr('front_image', label=_('Front image')) - rear_image = attrs.ImageAttr('rear_image', label=_('Rear image')) + airflow = attrs.ChoiceAttr('airflow') + front_image = attrs.ImageAttr('front_image') + rear_image = attrs.ImageAttr('rear_image') class ModuleTypeProfilePanel(panels.ObjectPanel): - name = attrs.TextAttr('name', label=_('Name')) - description = attrs.TextAttr('description', label=_('Description')) + name = attrs.TextAttr('name') + description = attrs.TextAttr('description') diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index a94520bdca6..d0a374f587d 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -10,6 +10,7 @@ __all__ = ( 'AddObject', + 'CopyContent', 'PanelAction', ) diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 72c5dba5f53..235d11a3a64 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -221,7 +221,7 @@ def render(self, obj, context=None): class GPSCoordinatesAttr(Attr): template_name = 'ui/attrs/gps_coordinates.html' - label = _('GPS Coordinates') + label = _('GPS coordinates') def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs): super().__init__(accessor=None, **kwargs) diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index eefbde5b490..f339d77b00a 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -132,6 +132,15 @@ def __init__(self, accessor=None, only=None, exclude=None, **kwargs): 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): """ Return the context data to be used when rendering the panel. @@ -153,7 +162,7 @@ def get_context(self, context): 'title': self.title or title(obj._meta.verbose_name), 'attrs': [ { - 'label': attr.label or title(name), + 'label': attr.label or self._name_to_label(name), 'value': attr.render(obj, {'name': name}), } for name, attr in self._attrs.items() if name in attr_names ], From 281cb4f586455191934eb0b1d85040f690575191 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Nov 2025 13:21:37 -0500 Subject: [PATCH 28/37] Split ObjectPanel into a base class and ObjectAttrsPanel; use base class for e.g. CommentsPanels, JSONPanel, etc. --- netbox/dcim/ui/panels.py | 20 +-- netbox/netbox/ui/attrs.py | 40 ++--- netbox/netbox/ui/panels.py | 166 ++++++++++++------ netbox/templates/ui/panels/comments.html | 4 +- .../{object.html => object_attributes.html} | 0 netbox/utilities/data.py | 21 +++ 6 files changed, 164 insertions(+), 87 deletions(-) rename netbox/templates/ui/panels/{object.html => object_attributes.html} (100%) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 9d6e301b2bf..93043a0056d 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -3,7 +3,7 @@ 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 235d11a3a64..1300b470244 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -5,6 +5,7 @@ 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 @@ def __init__(self, accessor, label=None, template_name=None): 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 @@ def __init__(self, *args, style=None, format_string=None, copy_button=False, **k 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 @@ def __init__(self, *args, unit_accessor=None, copy_button=False, **kwargs): 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 @@ def render(self, obj, context=None): 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 @@ def __init__(self, *args, display_false=True, **kwargs): 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 @@ def __init__(self, *args, linkify=None, grouped_by=None, **kwargs): 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 @@ def __init__(self, *args, linkify=None, max_depth=None, **kwargs): 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 @@ def __init__(self, *args, map_url=True, **kwargs): 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 @@ def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url 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 @@ def __init__(self, *args, context=None, **kwargs): 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 f339d77b00a..970a4fd7367 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -6,6 +6,7 @@ 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 @@ 'CommentsPanel', 'JSONPanel', 'NestedGroupObjectPanel', + 'ObjectAttributesPanel', 'ObjectPanel', 'ObjectsTablePanel', 'OrganizationalObjectPanel', @@ -25,6 +27,10 @@ ) +# +# Base classes +# + class Panel(ABC): """ A block of content rendered within an HTML template. @@ -74,7 +80,44 @@ def render(self, context): 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 @@ def __new__(mcls, name, bases, namespace, **kwargs): 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 @@ def __init__(self, accessor=None, only=None, exclude=None, **kwargs): """ 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 @@ def get_context(self, context): 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 @@ def get_context(self, context): } -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 d5f07a8cc90..de32162ce55 100644 --- a/netbox/templates/ui/panels/comments.html +++ b/netbox/templates/ui/panels/comments.html @@ -3,8 +3,8 @@ {% block panel_content %}
- {% if object.comments %} - {{ object.comments|markdown }} + {% if comments %} + {{ comments|markdown }} {% else %} {% trans "None" %} {% endif %} diff --git a/netbox/templates/ui/panels/object.html b/netbox/templates/ui/panels/object_attributes.html similarity index 100% rename from netbox/templates/ui/panels/object.html rename to netbox/templates/ui/panels/object_attributes.html diff --git a/netbox/utilities/data.py b/netbox/utilities/data.py index 617a31cd6e6..8bd5dcbc677 100644 --- a/netbox/utilities/data.py +++ b/netbox/utilities/data.py @@ -12,6 +12,7 @@ '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 From 9d6522c11ebe08184776ec21e3940fcb0df993f5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Nov 2025 14:49:36 -0500 Subject: [PATCH 29/37] RackType has no airflow attribute --- netbox/dcim/ui/panels.py | 1 - netbox/utilities/data.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 93043a0056d..ffa85e90d42 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -69,7 +69,6 @@ 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.ObjectAttributesPanel): diff --git a/netbox/utilities/data.py b/netbox/utilities/data.py index 8bd5dcbc677..36fd0f7fc4f 100644 --- a/netbox/utilities/data.py +++ b/netbox/utilities/data.py @@ -232,5 +232,8 @@ def resolve_attr_path(obj, path): for part in path.split('.'): if cur is None: return None - cur = getattr(cur, part) if hasattr(cur, part) else cur.get(part) + try: + cur = getattr(cur, part) if hasattr(cur, part) else cur.get(part) + except AttributeError: + cur = None return cur From dfb08ff521402f4a9e120f9635e85fb5bcce662f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Nov 2025 15:08:51 -0500 Subject: [PATCH 30/37] Split PanelAction into a base class and LinkAction; CopyContent should inherit from base class --- netbox/extras/ui/panels.py | 4 +- netbox/netbox/ui/actions.py | 98 ++++++++++++++++++++++++++----------- netbox/netbox/ui/panels.py | 2 +- 3 files changed, 73 insertions(+), 31 deletions(-) diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py index 991a4aa3dbb..4ab50ded710 100644 --- a/netbox/extras/ui/panels.py +++ b/netbox/extras/ui/panels.py @@ -10,7 +10,7 @@ ) -class CustomFieldsPanel(panels.Panel): +class CustomFieldsPanel(panels.ObjectPanel): template_name = 'ui/panels/custom_fields.html' title = _('Custom Fields') @@ -39,7 +39,7 @@ def __init__(self, **kwargs): super().__init__('extras.imageattachment', **kwargs) -class TagsPanel(panels.Panel): +class TagsPanel(panels.ObjectPanel): template_name = 'ui/panels/tags.html' title = _('Tags') diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index d0a374f587d..2dd8482b2f0 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -25,13 +25,63 @@ class PanelAction: button_class: Bootstrap CSS class for the button button_icon: Name of the button's MDI icon """ - template_name = 'ui/actions/link.html' + template_name = None label = None button_class = 'primary' button_icon = None - # TODO: Refactor URL parameters to AddObject - def __init__(self, view_name, view_kwargs=None, url_params=None, permissions=None, label=None): + def __init__(self, label=None, permissions=None): + """ + Initialize a new PanelAction. + + Parameters: + label: The human-friendly button text + permissions: A list of permissions required to display the action + """ + if label is not None: + self.label = label + self.permissions = permissions + + def get_context(self, context): + """ + Return the template context used to render the action element. + + Parameters: + context: The template context + """ + return { + 'label': self.label, + 'button_class': self.button_class, + 'button_icon': self.button_icon, + } + + def render(self, context): + """ + Render the action as HTML. + + Parameters: + context: The template context + """ + # Enforce permissions + user = context['request'].user + if not user.has_perms(self.permissions): + return '' + + return render_to_string(self.template_name, self.get_context(context)) + + +class LinkAction(PanelAction): + """ + A hyperlink (typically a button) within a panel to perform some associated action, such as adding an object. + + Attributes: + label: The default human-friendly button text + button_class: Bootstrap CSS class for the button + button_icon: Name of the button's MDI icon + """ + template_name = 'ui/actions/link.html' + + def __init__(self, view_name, view_kwargs=None, url_params=None, **kwargs): """ Initialize a new PanelAction. @@ -42,12 +92,11 @@ def __init__(self, view_name, view_kwargs=None, url_params=None, permissions=Non permissions: A list of permissions required to display the action label: The human-friendly button text """ + super().__init__(**kwargs) + self.view_name = view_name self.view_kwargs = view_kwargs or {} self.url_params = url_params or {} - self.permissions = permissions - if label is not None: - self.label = label def get_url(self, context): """ @@ -68,27 +117,14 @@ def get_url(self, context): url = f'{url}?{urlencode(url_params)}' return url - def render(self, context): - """ - Render the action as HTML. - - Parameters: - context: The template context - """ - # Enforce permissions - user = context['request'].user - if not user.has_perms(self.permissions): - return '' - - return render_to_string(self.template_name, { + def get_context(self, context): + return { + **super().get_context(context), 'url': self.get_url(context), - 'label': self.label, - 'button_class': self.button_class, - 'button_icon': self.button_icon, - }) + } -class AddObject(PanelAction): +class AddObject(LinkAction): """ An action to add a new object. """ @@ -112,22 +148,28 @@ def __init__(self, model, url_params=None, label=None): raise ValueError(f"Invalid model label: {model}") view_name = get_viewname(model, 'add') - super().__init__(view_name=view_name, label=label, url_params=url_params) + super().__init__(view_name=view_name, url_params=url_params, label=label) # Require "add" permission on the model self.permissions = [get_permission_for_model(model, 'add')] -class CopyContent: +class CopyContent(PanelAction): """ An action to copy the contents of a panel to the clipboard. """ template_name = 'ui/actions/copy_content.html' label = _('Copy') - button_class = 'primary' button_icon = 'content-copy' - def __init__(self, target_id): + def __init__(self, target_id, **kwargs): + """ + Instantiate a new CopyContent action. + + Parameters: + target_id: The ID of the target element containing the content to be copied + """ + super().__init__(**kwargs) self.target_id = target_id def render(self, context): diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 970a4fd7367..b20d8fed5da 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -54,7 +54,7 @@ def __init__(self, title=None, actions=None): """ if title is not None: self.title = title - self.actions = actions or [] + self.actions = actions or self.actions or [] def get_context(self, context): """ From 4edaa48aa75b58f393900aa783ae09bef24db886 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Nov 2025 15:51:36 -0500 Subject: [PATCH 31/37] Refactor render() on Attr to split out context and reduce boilerplate --- netbox/netbox/ui/attrs.py | 239 +++++++++++--------------- netbox/netbox/ui/panels.py | 4 +- netbox/templates/ui/attrs/color.html | 2 +- netbox/templates/ui/attrs/object.html | 4 +- 4 files changed, 107 insertions(+), 142 deletions(-) diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 1300b470244..7c683b0f7d0 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -1,5 +1,3 @@ -from abc import ABC, abstractmethod - from django.template.loader import render_to_string from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -12,23 +10,65 @@ # Attributes # -class Attr(ABC): +class ObjectAttribute: + """ + Base class for representing an attribute of an object. + + Attributes: + template_name: The name of the template to render + label: Human-friendly label for the rendered attribute + placeholder: HTML to render for empty/null values + """ template_name = None label = None placeholder = mark_safe('') def __init__(self, accessor, label=None, template_name=None): + """ + Instantiate a new ObjectAttribute. + + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ self.accessor = accessor - self.template_name = template_name or self.template_name + if template_name is not None: + self.template_name = template_name if label is not None: self.label = label - @abstractmethod - def render(self, obj, context=None): - pass + def get_value(self, obj): + """ + Return the value of the attribute. + + Parameters: + obj: The object for which the attribute is being rendered + """ + return resolve_attr_path(obj, self.accessor) + def get_context(self, obj, context): + """ + Return any additional template context used to render the attribute value. -class TextAttr(Attr): + Parameters: + obj: The object for which the attribute is being rendered + context: The template context + """ + return {} + + def render(self, obj, context): + value = self.get_value(obj) + if value in (None, ''): + return self.placeholder + context = self.get_context(obj, context) + return render_to_string(self.template_name, { + **context, + 'value': value, + }) + + +class TextAttr(ObjectAttribute): template_name = 'ui/attrs/text.html' def __init__(self, *args, style=None, format_string=None, copy_button=False, **kwargs): @@ -37,22 +77,21 @@ def __init__(self, *args, style=None, format_string=None, copy_button=False, **k self.format_string = format_string self.copy_button = copy_button - def render(self, obj, context=None): - context = context or {} + def get_value(self, obj): value = resolve_attr_path(obj, self.accessor) - if value in (None, ''): - return self.placeholder - if self.format_string: + # Apply format string (if any) + if value and self.format_string: value = self.format_string.format(value) - return render_to_string(self.template_name, { - **context, - 'value': value, + return value + + def get_context(self, obj, context): + return { 'style': self.style, 'copy_button': self.copy_button, - }) + } -class NumericAttr(Attr): +class NumericAttr(ObjectAttribute): template_name = 'ui/attrs/numeric.html' def __init__(self, *args, unit_accessor=None, copy_button=False, **kwargs): @@ -60,88 +99,57 @@ def __init__(self, *args, unit_accessor=None, copy_button=False, **kwargs): self.unit_accessor = unit_accessor self.copy_button = copy_button - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value in (None, ''): - return self.placeholder + def get_context(self, obj, context): unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None - return render_to_string(self.template_name, { - **context, - 'value': value, + return { 'unit': unit, 'copy_button': self.copy_button, - }) + } -class ChoiceAttr(Attr): +class ChoiceAttr(ObjectAttribute): template_name = 'ui/attrs/choice.html' - def render(self, obj, context=None): - context = context or {} + def get_value(self, obj): try: - value = getattr(obj, f'get_{self.accessor}_display')() + return getattr(obj, f'get_{self.accessor}_display')() except AttributeError: - value = resolve_attr_path(obj, self.accessor) - if value in (None, ''): - return self.placeholder + return resolve_attr_path(obj, self.accessor) + + def get_context(self, obj, context): try: bg_color = getattr(obj, f'get_{self.accessor}_color')() except AttributeError: bg_color = None - return render_to_string(self.template_name, { - **context, - 'value': value, + return { 'bg_color': bg_color, - }) + } -class BooleanAttr(Attr): +class BooleanAttr(ObjectAttribute): template_name = 'ui/attrs/boolean.html' def __init__(self, *args, display_false=True, **kwargs): super().__init__(*args, **kwargs) self.display_false = display_false - def render(self, obj, context=None): - context = context or {} - 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, { - **context, - 'value': value, - }) + def get_value(self, obj): + value = super().get_value(obj) + if value is False and self.display_false is False: + return None + return value -class ColorAttr(Attr): +class ColorAttr(ObjectAttribute): template_name = 'ui/attrs/color.html' label = _('Color') - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - return render_to_string(self.template_name, { - **context, - 'color': value, - }) - -class ImageAttr(Attr): +class ImageAttr(ObjectAttribute): template_name = 'ui/attrs/image.html' - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value in (None, ''): - return self.placeholder - return render_to_string(self.template_name, { - **context, - 'value': value, - }) - -class ObjectAttr(Attr): +class ObjectAttr(ObjectAttribute): template_name = 'ui/attrs/object.html' def __init__(self, *args, linkify=None, grouped_by=None, **kwargs): @@ -149,22 +157,16 @@ def __init__(self, *args, linkify=None, grouped_by=None, **kwargs): self.linkify = linkify self.grouped_by = grouped_by - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value is None: - return self.placeholder + def get_context(self, obj, context): + value = self.get_value(obj) group = getattr(value, self.grouped_by, None) if self.grouped_by else None - - return render_to_string(self.template_name, { - **context, - 'object': value, - 'group': group, + return { 'linkify': self.linkify, - }) + 'group': group, + } -class NestedObjectAttr(Attr): +class NestedObjectAttr(ObjectAttribute): template_name = 'ui/attrs/nested_object.html' def __init__(self, *args, linkify=None, max_depth=None, **kwargs): @@ -172,22 +174,18 @@ def __init__(self, *args, linkify=None, max_depth=None, **kwargs): self.linkify = linkify self.max_depth = max_depth - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value is None: - return self.placeholder + def get_context(self, obj, context): + value = self.get_value(obj) nodes = value.get_ancestors(include_self=True) if self.max_depth: nodes = list(nodes)[-self.max_depth:] - return render_to_string(self.template_name, { - **context, + return { 'nodes': nodes, 'linkify': self.linkify, - }) + } -class AddressAttr(Attr): +class AddressAttr(ObjectAttribute): template_name = 'ui/attrs/address.html' def __init__(self, *args, map_url=True, **kwargs): @@ -199,19 +197,13 @@ def __init__(self, *args, map_url=True, **kwargs): else: self.map_url = None - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value in (None, ''): - return self.placeholder - return render_to_string(self.template_name, { - **context, - 'value': value, + def get_context(self, obj, context): + return { 'map_url': self.map_url, - }) + } -class GPSCoordinatesAttr(Attr): +class GPSCoordinatesAttr(ObjectAttribute): template_name = 'ui/attrs/gps_coordinates.html' label = _('GPS coordinates') @@ -240,49 +232,22 @@ def render(self, obj, context=None): }) -class TimezoneAttr(Attr): +class TimezoneAttr(ObjectAttribute): template_name = 'ui/attrs/timezone.html' - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value in (None, ''): - return self.placeholder - return render_to_string(self.template_name, { - **context, - 'value': value, - }) - -class TemplatedAttr(Attr): +class TemplatedAttr(ObjectAttribute): def __init__(self, *args, context=None, **kwargs): super().__init__(*args, **kwargs) self.context = context or {} - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value is None: - return self.placeholder - return render_to_string( - self.template_name, - { - **context, - **self.context, - 'object': obj, - 'value': value, - } - ) - - -class UtilizationAttr(Attr): - template_name = 'ui/attrs/utilization.html' + def get_context(self, obj, context): + return { + **self.context, + 'object': obj, + } - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - return render_to_string(self.template_name, { - **context, - 'value': value, - }) + +class UtilizationAttr(ObjectAttribute): + template_name = 'ui/attrs/utilization.html' diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index b20d8fed5da..5827bb6b039 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -130,13 +130,13 @@ def __new__(mcls, name, bases, namespace, **kwargs): # Add local declarations in the order they appear in the class body for key, attr in namespace.items(): - if isinstance(attr, attrs.Attr): + 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.Attr)] + local_items = [key for key, attr in namespace.items() if isinstance(attr, attrs.ObjectAttribute)] for key in local_items: namespace.pop(key) diff --git a/netbox/templates/ui/attrs/color.html b/netbox/templates/ui/attrs/color.html index 29d11207a09..78e1cfff3cc 100644 --- a/netbox/templates/ui/attrs/color.html +++ b/netbox/templates/ui/attrs/color.html @@ -1 +1 @@ -  +  diff --git a/netbox/templates/ui/attrs/object.html b/netbox/templates/ui/attrs/object.html index 55263138b42..58fce231652 100644 --- a/netbox/templates/ui/attrs/object.html +++ b/netbox/templates/ui/attrs/object.html @@ -5,10 +5,10 @@ {% if linkify %}{{ group|linkify }}{% else %}{{ group }}{% endif %} {% else %} {# Display only the object #} - {% if linkify %}{{ object|linkify }}{% else %}{{ object }}{% endif %} + {% if linkify %}{{ value|linkify }}{% else %}{{ value }}{% endif %} {% endif %} From 1d2aef71b22f7cefd4db109001d4798a774e8237 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Nov 2025 15:56:12 -0500 Subject: [PATCH 32/37] Hide custom fields panels if no custom fields exist on the model --- netbox/extras/ui/panels.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py index 4ab50ded710..4cad3c76c6e 100644 --- a/netbox/extras/ui/panels.py +++ b/netbox/extras/ui/panels.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from netbox.ui import actions, panels @@ -11,6 +12,9 @@ class CustomFieldsPanel(panels.ObjectPanel): + """ + Render a panel showing the value of all custom fields defined on the object. + """ template_name = 'ui/panels/custom_fields.html' title = _('Custom Fields') @@ -21,8 +25,18 @@ def get_context(self, context): 'custom_fields': obj.get_custom_fields_by_group(), } + def render(self, context): + ctx = self.get_context(context) + # Hide the panel if no custom fields exist + if not ctx['custom_fields']: + return '' + return render_to_string(self.template_name, self.get_context(context)) + class ImageAttachmentsPanel(panels.ObjectsTablePanel): + """ + Render a table listing all images attached to the object. + """ actions = [ actions.AddObject( 'extras.imageattachment', @@ -40,6 +54,9 @@ def __init__(self, **kwargs): class TagsPanel(panels.ObjectPanel): + """ + Render a panel showing the tags assigned to the object. + """ template_name = 'ui/panels/tags.html' title = _('Tags') From e9777d3193d46148c7ebf47ac817354e39d52f7e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Nov 2025 16:56:53 -0500 Subject: [PATCH 33/37] Flesh out device layout --- netbox/dcim/ui/panels.py | 32 ++++++++++++++-- netbox/dcim/views.py | 38 +++++++++++++++++++ .../dcim/device/attrs/total_weight.html | 3 ++ .../dcim/panels/virtual_chassis_members.html | 31 +++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 netbox/templates/dcim/device/attrs/total_weight.html create mode 100644 netbox/templates/dcim/panels/virtual_chassis_members.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index ffa85e90d42..e4845bfc0ed 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -26,7 +26,7 @@ class LocationPanel(panels.NestedGroupObjectPanel): 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')) + height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display') outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display') outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display') @@ -76,7 +76,7 @@ class DevicePanel(panels.ObjectAttributesPanel): site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', linkify=True) rack = attrs.TemplatedAttr('rack', template_name='dcim/device/attrs/rack.html') - virtual_chassis = attrs.NestedObjectAttr('virtual_chassis', linkify=True) + virtual_chassis = attrs.ObjectAttr('virtual_chassis', linkify=True) parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html') gps_coordinates = attrs.GPSCoordinatesAttr() tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') @@ -107,6 +107,12 @@ class DeviceManagementPanel(panels.ObjectAttributesPanel): label=_('Out-of-band IP'), template_name='dcim/device/attrs/ipaddress.html', ) + cluster = attrs.ObjectAttr('cluster', linkify=True) + + +class DeviceDimensionsPanel(panels.ObjectAttributesPanel): + height = attrs.TextAttr('device_type.u_height', format_string='{}U') + total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html') class DeviceTypePanel(panels.ObjectAttributesPanel): @@ -115,7 +121,7 @@ class DeviceTypePanel(panels.ObjectAttributesPanel): part_number = attrs.TextAttr('part_number') default_platform = attrs.ObjectAttr('default_platform', linkify=True) description = attrs.TextAttr('description') - u_height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) + height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization') full_depth = attrs.BooleanAttr('is_full_depth') weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display') @@ -128,3 +134,23 @@ class DeviceTypePanel(panels.ObjectAttributesPanel): class ModuleTypeProfilePanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') description = attrs.TextAttr('description') + + +class VirtualChassisMembersPanel(panels.ObjectPanel): + """ + A panel which lists all members of a virtual chassis. + """ + template_name = 'dcim/panels/virtual_chassis_members.html' + title = _('Virtual Chassis Members') + + 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), + 'vc_members': context.get('vc_members'), + } diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 051d2867def..9f7b3f06a30 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2439,6 +2439,44 @@ class DeviceListView(generic.ObjectListView): @register_model_view(Device) class DeviceView(generic.ObjectView): queryset = Device.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.DevicePanel(), + panels.VirtualChassisMembersPanel(), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ObjectsTablePanel( + model='dcim.VirtualDeviceContext', + filters={'device_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject('dcim.VirtualDeviceContext', url_params={'device': lambda ctx: ctx['object'].pk}), + ], + ), + ], + right_panels=[ + panels.DeviceManagementPanel(), + # TODO: Power utilization + ObjectsTablePanel( + model='ipam.Service', + title=_('Application Services'), + filters={'device_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject( + 'ipam.Service', + url_params={ + 'parent_object_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk, + 'parent': lambda ctx: ctx['object'].pk + } + ), + ], + ), + ImageAttachmentsPanel(), + panels.DeviceDimensionsPanel(title=_('Dimensions')), + # TODO: Rack elevations + # TemplatePanel('dcim/panels/rack_elevations.html'), + ], + ) def get_extra_context(self, request, instance): # VirtualChassis members diff --git a/netbox/templates/dcim/device/attrs/total_weight.html b/netbox/templates/dcim/device/attrs/total_weight.html new file mode 100644 index 00000000000..73ac54ef551 --- /dev/null +++ b/netbox/templates/dcim/device/attrs/total_weight.html @@ -0,0 +1,3 @@ +{% load helpers i18n %} +{{ value|floatformat }} {% trans "Kilograms" %} +({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %}) diff --git a/netbox/templates/dcim/panels/virtual_chassis_members.html b/netbox/templates/dcim/panels/virtual_chassis_members.html new file mode 100644 index 00000000000..29e422ea64e --- /dev/null +++ b/netbox/templates/dcim/panels/virtual_chassis_members.html @@ -0,0 +1,31 @@ +{% extends "ui/panels/_base.html" %} +{% load i18n %} + +{% block panel_content %} + + + + + + + + + + + {% for vc_member in vc_members %} + + + + + + + {% endfor %} + +
{% trans "Device" %}{% trans "Position" %}{% trans "Master" %}{% trans "Priority" %}
{{ vc_member|linkify }}{% badge vc_member.vc_position show_empty=True %} + {% if object.virtual_chassis.master == vc_member %} + {% checkmark True %} + {% else %} + {{ ''|placeholder }} + {% endif %} + {{ vc_member.vc_priority|placeholder }}
+{% endblock panel_content %} From 60cc009d6b47afd45dbc722f7c47b87f7a07a657 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Nov 2025 12:04:15 -0500 Subject: [PATCH 34/37] Move templates for extras panels --- netbox/extras/ui/panels.py | 4 ++-- netbox/templates/{ui => extras}/panels/custom_fields.html | 0 netbox/templates/{ui => extras}/panels/tags.html | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename netbox/templates/{ui => extras}/panels/custom_fields.html (100%) rename netbox/templates/{ui => extras}/panels/tags.html (100%) diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py index 4cad3c76c6e..2ab55ecd852 100644 --- a/netbox/extras/ui/panels.py +++ b/netbox/extras/ui/panels.py @@ -15,7 +15,7 @@ class CustomFieldsPanel(panels.ObjectPanel): """ Render a panel showing the value of all custom fields defined on the object. """ - template_name = 'ui/panels/custom_fields.html' + template_name = 'extras/panels/custom_fields.html' title = _('Custom Fields') def get_context(self, context): @@ -57,7 +57,7 @@ class TagsPanel(panels.ObjectPanel): """ Render a panel showing the tags assigned to the object. """ - template_name = 'ui/panels/tags.html' + template_name = 'extras/panels/tags.html' title = _('Tags') def get_context(self, context): diff --git a/netbox/templates/ui/panels/custom_fields.html b/netbox/templates/extras/panels/custom_fields.html similarity index 100% rename from netbox/templates/ui/panels/custom_fields.html rename to netbox/templates/extras/panels/custom_fields.html diff --git a/netbox/templates/ui/panels/tags.html b/netbox/templates/extras/panels/tags.html similarity index 100% rename from netbox/templates/ui/panels/tags.html rename to netbox/templates/extras/panels/tags.html From e55a4ae603f193b96fb844b4f2ab69deb595aca5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Nov 2025 12:31:20 -0500 Subject: [PATCH 35/37] Finish layout for device view --- netbox/dcim/ui/panels.py | 33 ++++++++++-- netbox/dcim/views.py | 7 ++- .../dcim/panels/device_rack_elevations.html | 26 ++++++++++ .../dcim/panels/power_utilization.html | 50 +++++++++++++++++++ 4 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 netbox/templates/dcim/panels/device_rack_elevations.html create mode 100644 netbox/templates/dcim/panels/power_utilization.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index e4845bfc0ed..62435bedfd1 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -89,6 +89,8 @@ class DevicePanel(panels.ObjectAttributesPanel): class DeviceManagementPanel(panels.ObjectAttributesPanel): + title = _('Management') + status = attrs.ChoiceAttr('status') role = attrs.NestedObjectAttr('role', linkify=True, max_depth=3) platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3) @@ -111,6 +113,8 @@ class DeviceManagementPanel(panels.ObjectAttributesPanel): class DeviceDimensionsPanel(panels.ObjectAttributesPanel): + title = _('Dimensions') + height = attrs.TextAttr('device_type.u_height', format_string='{}U') total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html') @@ -144,13 +148,32 @@ class VirtualChassisMembersPanel(panels.ObjectPanel): title = _('Virtual Chassis Members') def get_context(self, context): - """ - Return the context data to be used when rendering the panel. + return { + **super().get_context(context), + 'vc_members': context.get('vc_members'), + } + + def render(self, context): + if not context.get('vc_members'): + return '' + return super().render(context) - Parameters: - context: The template context - """ + +class PowerUtilizationPanel(panels.ObjectPanel): + """ + A panel which displays the power utilization statistics for a device. + """ + template_name = 'dcim/panels/power_utilization.html' + title = _('Power Utilization') + + def get_context(self, context): return { **super().get_context(context), 'vc_members': context.get('vc_members'), } + + def render(self, context): + obj = context['object'] + if not obj.powerports.exists() or not obj.poweroutlets.exists(): + return '' + return super().render(context) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9f7b3f06a30..b8a9e134a74 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2456,7 +2456,7 @@ class DeviceView(generic.ObjectView): ], right_panels=[ panels.DeviceManagementPanel(), - # TODO: Power utilization + panels.PowerUtilizationPanel(), ObjectsTablePanel( model='ipam.Service', title=_('Application Services'), @@ -2472,9 +2472,8 @@ class DeviceView(generic.ObjectView): ], ), ImageAttachmentsPanel(), - panels.DeviceDimensionsPanel(title=_('Dimensions')), - # TODO: Rack elevations - # TemplatePanel('dcim/panels/rack_elevations.html'), + panels.DeviceDimensionsPanel(), + TemplatePanel('dcim/panels/device_rack_elevations.html'), ], ) diff --git a/netbox/templates/dcim/panels/device_rack_elevations.html b/netbox/templates/dcim/panels/device_rack_elevations.html new file mode 100644 index 00000000000..1816be5c986 --- /dev/null +++ b/netbox/templates/dcim/panels/device_rack_elevations.html @@ -0,0 +1,26 @@ +{% load i18n %} +{% if object.rack and object.position %} +
+
+ {{ object.rack.name }} + {% if object.rack.role %} +
{{ object.rack.role }} + {% endif %} + {% if object.rack.facility_id %} +
{{ object.rack.facility_id }} + {% endif %} +
+
+
+

{% trans "Front" %}

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %} +
+
+
+
+

{% trans "Rear" %}

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %} +
+
+
+{% endif %} diff --git a/netbox/templates/dcim/panels/power_utilization.html b/netbox/templates/dcim/panels/power_utilization.html new file mode 100644 index 00000000000..b716ed2c98d --- /dev/null +++ b/netbox/templates/dcim/panels/power_utilization.html @@ -0,0 +1,50 @@ +{% extends "ui/panels/_base.html" %} +{% load helpers i18n %} + +{% block panel_content %} + + + + + + + + + + + {% for powerport in object.powerports.all %} + {% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoints.0 %} + + + + + {% if powerfeed.available_power %} + + + {% else %} + + + {% endif %} + + {% for leg in utilization.legs %} + + + + + {% if powerfeed.available_power %} + {% with phase_available=powerfeed.available_power|divide:3 %} + + + {% endwith %} + {% else %} + + + {% endif %} + + {% endfor %} + {% endwith %} + {% endfor %} +
{% trans "Input" %}{% trans "Outlets" %}{% trans "Allocated" %}{% trans "Available" %}{% trans "Utilization" %}
{{ powerport }}{{ utilization.outlet_count }}{{ utilization.allocated }}{% trans "VA" %}{{ powerfeed.available_power }}{% trans "VA" %}{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
+ {% trans "Leg" context "Leg of a power feed" %} {{ leg.name }} + {{ leg.outlet_count }}{{ leg.allocated }}{{ phase_available }}{% trans "VA" %}{% utilization_graph leg.allocated|percentage:phase_available %}
+{% endblock panel_content %} From 6fc04bd1fe823958b0d4949cdf86c5704575f139 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Nov 2025 12:40:33 -0500 Subject: [PATCH 36/37] Fix accessor --- netbox/dcim/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b8a9e134a74..4cb5233e18f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1147,7 +1147,7 @@ class RackReservationView(generic.ObjectView): queryset = RackReservation.objects.all() layout = layout.SimpleLayout( left_panels=[ - panels.RackPanel(accessor='rack', only=['region', 'site', 'location']), + panels.RackPanel(accessor='object.rack', only=['region', 'site', 'location']), CustomFieldsPanel(), TagsPanel(), CommentsPanel(), From a024012abd3c440a32cd0cf9c9d80d67979ac354 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Nov 2025 14:54:40 -0500 Subject: [PATCH 37/37] Misc cleanup --- netbox/dcim/ui/panels.py | 32 ++-- netbox/extras/ui/panels.py | 11 +- netbox/netbox/ui/attrs.py | 164 ++++++++++++++++-- netbox/netbox/ui/layout.py | 20 ++- netbox/netbox/ui/panels.py | 75 ++------ .../dcim/device/attrs/ipaddress.html | 1 - .../utilities/templatetags/builtins/tags.py | 3 + 7 files changed, 210 insertions(+), 96 deletions(-) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 62435bedfd1..4661e3151e0 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -7,7 +7,7 @@ class SitePanel(panels.ObjectAttributesPanel): region = attrs.NestedObjectAttr('region', linkify=True) group = attrs.NestedObjectAttr('group', linkify=True) status = attrs.ChoiceAttr('status') - tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') facility = attrs.TextAttr('facility') description = attrs.TextAttr('description') timezone = attrs.TimezoneAttr('time_zone') @@ -17,9 +17,9 @@ class SitePanel(panels.ObjectAttributesPanel): class LocationPanel(panels.NestedGroupObjectPanel): - site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') + site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group') status = attrs.ChoiceAttr('status') - tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') facility = attrs.TextAttr('facility') @@ -40,13 +40,13 @@ class RackNumberingPanel(panels.ObjectAttributesPanel): class RackPanel(panels.ObjectAttributesPanel): region = attrs.NestedObjectAttr('site.region', linkify=True) - site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') + site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', linkify=True) facility = attrs.TextAttr('facility') - tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') status = attrs.ChoiceAttr('status') - rack_type = attrs.ObjectAttr('rack_type', linkify=True, grouped_by='manufacturer') - role = attrs.ObjectAttr('role', linkify=True) + rack_type = attrs.RelatedObjectAttr('rack_type', linkify=True, grouped_by='manufacturer') + role = attrs.RelatedObjectAttr('role', linkify=True) description = attrs.TextAttr('description') serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) @@ -66,26 +66,26 @@ class RackRolePanel(panels.OrganizationalObjectPanel): class RackTypePanel(panels.ObjectAttributesPanel): - manufacturer = attrs.ObjectAttr('manufacturer', linkify=True) + manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True) model = attrs.TextAttr('model') description = attrs.TextAttr('description') class DevicePanel(panels.ObjectAttributesPanel): region = attrs.NestedObjectAttr('site.region', linkify=True) - site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') + site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', linkify=True) rack = attrs.TemplatedAttr('rack', template_name='dcim/device/attrs/rack.html') - virtual_chassis = attrs.ObjectAttr('virtual_chassis', linkify=True) + virtual_chassis = attrs.RelatedObjectAttr('virtual_chassis', linkify=True) parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html') gps_coordinates = attrs.GPSCoordinatesAttr() - tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') - device_type = attrs.ObjectAttr('device_type', linkify=True, grouped_by='manufacturer') + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') + device_type = attrs.RelatedObjectAttr('device_type', linkify=True, grouped_by='manufacturer') description = attrs.TextAttr('description') airflow = attrs.ChoiceAttr('airflow') serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) - config_template = attrs.ObjectAttr('config_template', linkify=True) + config_template = attrs.RelatedObjectAttr('config_template', linkify=True) class DeviceManagementPanel(panels.ObjectAttributesPanel): @@ -109,7 +109,7 @@ class DeviceManagementPanel(panels.ObjectAttributesPanel): label=_('Out-of-band IP'), template_name='dcim/device/attrs/ipaddress.html', ) - cluster = attrs.ObjectAttr('cluster', linkify=True) + cluster = attrs.RelatedObjectAttr('cluster', linkify=True) class DeviceDimensionsPanel(panels.ObjectAttributesPanel): @@ -120,10 +120,10 @@ class DeviceDimensionsPanel(panels.ObjectAttributesPanel): class DeviceTypePanel(panels.ObjectAttributesPanel): - manufacturer = attrs.ObjectAttr('manufacturer', linkify=True) + manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True) model = attrs.TextAttr('model') part_number = attrs.TextAttr('part_number') - default_platform = attrs.ObjectAttr('default_platform', linkify=True) + default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True) description = attrs.TextAttr('description') height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization') diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py index 2ab55ecd852..f2f9a5c9af3 100644 --- a/netbox/extras/ui/panels.py +++ b/netbox/extras/ui/panels.py @@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.ui import actions, panels +from utilities.data import resolve_attr_path __all__ = ( 'CustomFieldsPanel', @@ -13,13 +14,13 @@ class CustomFieldsPanel(panels.ObjectPanel): """ - Render a panel showing the value of all custom fields defined on the object. + A panel showing the value of all custom fields defined on an object. """ template_name = 'extras/panels/custom_fields.html' title = _('Custom Fields') def get_context(self, context): - obj = context['object'] + obj = resolve_attr_path(context, self.accessor) return { **super().get_context(context), 'custom_fields': obj.get_custom_fields_by_group(), @@ -35,7 +36,7 @@ def render(self, context): class ImageAttachmentsPanel(panels.ObjectsTablePanel): """ - Render a table listing all images attached to the object. + A panel showing all images attached to the object. """ actions = [ actions.AddObject( @@ -55,7 +56,7 @@ def __init__(self, **kwargs): class TagsPanel(panels.ObjectPanel): """ - Render a panel showing the tags assigned to the object. + A panel showing the tags assigned to the object. """ template_name = 'extras/panels/tags.html' title = _('Tags') @@ -63,5 +64,5 @@ class TagsPanel(panels.ObjectPanel): def get_context(self, context): return { **super().get_context(context), - 'object': context['object'], + 'object': resolve_attr_path(context, self.accessor), } diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 7c683b0f7d0..53e29524678 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -5,6 +5,25 @@ from netbox.config import get_config from utilities.data import resolve_attr_path +__all__ = ( + 'AddressAttr', + 'BooleanAttr', + 'ColorAttr', + 'ChoiceAttr', + 'GPSCoordinatesAttr', + 'ImageAttr', + 'NestedObjectAttr', + 'NumericAttr', + 'ObjectAttribute', + 'RelatedObjectAttr', + 'TemplatedAttr', + 'TextAttr', + 'TimezoneAttr', + 'UtilizationAttr', +) + +PLACEHOLDER_HTML = '' + # # Attributes @@ -21,20 +40,17 @@ class ObjectAttribute: """ template_name = None label = None - placeholder = mark_safe('') + placeholder = mark_safe(PLACEHOLDER_HTML) - def __init__(self, accessor, label=None, template_name=None): + def __init__(self, accessor, label=None): """ Instantiate a new ObjectAttribute. Parameters: accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") label: Human-friendly label for the rendered attribute - template_name: The name of the template to render """ self.accessor = accessor - if template_name is not None: - self.template_name = template_name if label is not None: self.label = label @@ -53,25 +69,42 @@ def get_context(self, obj, context): Parameters: obj: The object for which the attribute is being rendered - context: The template context + context: The root template context """ return {} def render(self, obj, context): value = self.get_value(obj) + + # If the value is empty, render a placeholder if value in (None, ''): return self.placeholder - context = self.get_context(obj, context) + return render_to_string(self.template_name, { - **context, + **self.get_context(obj, context), + 'name': context['name'], 'value': value, }) class TextAttr(ObjectAttribute): + """ + A text attribute. + """ template_name = 'ui/attrs/text.html' def __init__(self, *args, style=None, format_string=None, copy_button=False, **kwargs): + """ + Instantiate a new TextAttr. + + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + style: CSS class to apply to the rendered attribute + format_string: If specified, the value will be formatted using this string when rendering + copy_button: Set to True to include a copy-to-clipboard button + """ super().__init__(*args, **kwargs) self.style = style self.format_string = format_string @@ -81,7 +114,7 @@ def get_value(self, obj): value = resolve_attr_path(obj, self.accessor) # Apply format string (if any) if value and self.format_string: - value = self.format_string.format(value) + return self.format_string.format(value) return value def get_context(self, obj, context): @@ -92,9 +125,22 @@ def get_context(self, obj, context): class NumericAttr(ObjectAttribute): + """ + An integer or float attribute. + """ template_name = 'ui/attrs/numeric.html' def __init__(self, *args, unit_accessor=None, copy_button=False, **kwargs): + """ + Instantiate a new NumericAttr. + + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + unit_accessor: Accessor for the unit of measurement to display alongside the value (if any) + copy_button: Set to True to include a copy-to-clipboard button + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ super().__init__(*args, **kwargs) self.unit_accessor = unit_accessor self.copy_button = copy_button @@ -108,6 +154,12 @@ def get_context(self, obj, context): class ChoiceAttr(ObjectAttribute): + """ + A selection from a set of choices. + + The class calls get_FOO_display() on the object to retrieve the human-friendly choice label. If a get_FOO_color() + method exists on the object, it will be used to render a background color for the attribute value. + """ template_name = 'ui/attrs/choice.html' def get_value(self, obj): @@ -127,9 +179,21 @@ def get_context(self, obj, context): class BooleanAttr(ObjectAttribute): + """ + A boolean attribute. + """ template_name = 'ui/attrs/boolean.html' def __init__(self, *args, display_false=True, **kwargs): + """ + Instantiate a new BooleanAttr. + + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + display_false: If False, a placeholder will be rendered instead of the "False" indication + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ super().__init__(*args, **kwargs) self.display_false = display_false @@ -141,18 +205,38 @@ def get_value(self, obj): class ColorAttr(ObjectAttribute): + """ + An RGB color value. + """ template_name = 'ui/attrs/color.html' label = _('Color') class ImageAttr(ObjectAttribute): + """ + An attribute representing an image field on the model. Displays the uploaded image. + """ template_name = 'ui/attrs/image.html' -class ObjectAttr(ObjectAttribute): +class RelatedObjectAttr(ObjectAttribute): + """ + An attribute representing a related object. + """ template_name = 'ui/attrs/object.html' def __init__(self, *args, linkify=None, grouped_by=None, **kwargs): + """ + Instantiate a new RelatedObjectAttr. + + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + linkify: If True, the rendered value will be hyperlinked to the related object's detail view + grouped_by: A second-order object to annotate alongside the related object; for example, an attribute + representing the dcim.Site model might specify grouped_by="region" + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ super().__init__(*args, **kwargs) self.linkify = linkify self.grouped_by = grouped_by @@ -167,9 +251,23 @@ def get_context(self, obj, context): class NestedObjectAttr(ObjectAttribute): + """ + An attribute representing a related nested object. Similar to `RelatedObjectAttr`, but includes the ancestors of the + related object in the rendered output. + """ template_name = 'ui/attrs/nested_object.html' def __init__(self, *args, linkify=None, max_depth=None, **kwargs): + """ + Instantiate a new NestedObjectAttr. Shows a related object as well as its ancestors. + + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + linkify: If True, the rendered value will be hyperlinked to the related object's detail view + max_depth: Maximum number of ancestors to display (default: all) + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ super().__init__(*args, **kwargs) self.linkify = linkify self.max_depth = max_depth @@ -186,9 +284,21 @@ def get_context(self, obj, context): class AddressAttr(ObjectAttribute): + """ + A physical or mailing address. + """ template_name = 'ui/attrs/address.html' def __init__(self, *args, map_url=True, **kwargs): + """ + Instantiate a new AddressAttr. + + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + map_url: If true, the address will render as a hyperlink using settings.MAPS_URL + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ super().__init__(*args, **kwargs) if map_url is True: self.map_url = get_config().MAPS_URL @@ -204,10 +314,23 @@ def get_context(self, obj, context): class GPSCoordinatesAttr(ObjectAttribute): + """ + A GPS coordinates pair comprising latitude and longitude values. + """ template_name = 'ui/attrs/gps_coordinates.html' label = _('GPS coordinates') def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs): + """ + Instantiate a new GPSCoordinatesAttr. + + Parameters: + latitude_attr: The name of the field containing the latitude value + longitude_attr: The name of the field containing the longitude value + map_url: If true, the address will render as a hyperlink using settings.MAPS_URL + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ super().__init__(accessor=None, **kwargs) self.latitude_attr = latitude_attr self.longitude_attr = longitude_attr @@ -233,13 +356,29 @@ def render(self, obj, context=None): class TimezoneAttr(ObjectAttribute): + """ + A timezone value. Includes the numeric offset from UTC. + """ template_name = 'ui/attrs/timezone.html' class TemplatedAttr(ObjectAttribute): + """ + Renders an attribute using a custom template. + """ + def __init__(self, *args, template_name, context=None, **kwargs): + """ + Instantiate a new TemplatedAttr. - def __init__(self, *args, context=None, **kwargs): + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + template_name: The name of the template to render + context: Additional context to pass to the template when rendering + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ super().__init__(*args, **kwargs) + self.template_name = template_name self.context = context or {} def get_context(self, obj, context): @@ -250,4 +389,7 @@ def get_context(self, obj, context): class UtilizationAttr(ObjectAttribute): + """ + Renders the value of an attribute as a utilization graph. + """ template_name = 'ui/attrs/utilization.html' diff --git a/netbox/netbox/ui/layout.py b/netbox/netbox/ui/layout.py index 6612917a763..d3fc69535b0 100644 --- a/netbox/netbox/ui/layout.py +++ b/netbox/netbox/ui/layout.py @@ -13,7 +13,9 @@ # class Layout: - + """ + A collection of rows and columns comprising the layout of content within the user interface. + """ def __init__(self, *rows): for i, row in enumerate(rows): if type(row) is not Row: @@ -22,7 +24,9 @@ def __init__(self, *rows): class Row: - + """ + A collection of columns arranged horizontally. + """ def __init__(self, *columns): for i, column in enumerate(columns): if type(column) is not Column: @@ -31,7 +35,9 @@ def __init__(self, *columns): class Column: - + """ + A collection of panels arranged vertically. + """ def __init__(self, *panels): for i, panel in enumerate(panels): if not isinstance(panel, Panel): @@ -40,12 +46,18 @@ def __init__(self, *panels): # -# Standard layouts +# Common layouts # class SimpleLayout(Layout): """ A layout with one row of two columns and a second row with one column. Includes registered plugin content. + + +------+------+ + | col1 | col2 | + +------+------+ + | col3 | + +-------------+ """ def __init__(self, left_panels=None, right_panels=None, bottom_panels=None): left_panels = left_panels or [] diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 5827bb6b039..149b48563fd 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -1,5 +1,3 @@ -from abc import ABC, ABCMeta - from django.apps import apps from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ @@ -20,9 +18,9 @@ 'ObjectPanel', 'ObjectsTablePanel', 'OrganizationalObjectPanel', - 'RelatedObjectsPanel', 'Panel', 'PluginContentPanel', + 'RelatedObjectsPanel', 'TemplatePanel', ) @@ -31,14 +29,13 @@ # Base classes # -class Panel(ABC): +class Panel: """ A block of content rendered within an HTML template. - Attributes: - template_name: The name of the template to render - title: The human-friendly title of the panel - actions: A list of PanelActions to include in the panel header + 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. """ template_name = None title = None @@ -50,7 +47,7 @@ def __init__(self, title=None, actions=None): Parameters: title: The human-friendly title of the panel - actions: A list of PanelActions to include in the panel header + actions: An iterable of PanelActions to include in the panel header """ if title is not None: self.title = title @@ -95,7 +92,7 @@ def __init__(self, accessor=None, **kwargs): Instantiate a new ObjectPanel. Parameters: - accessor: The name of the attribute on the object (default: "object") + accessor: The dotted path in context data to the object being rendered (default: "object") """ super().__init__(**kwargs) @@ -103,12 +100,6 @@ def __init__(self, accessor=None, **kwargs): 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), @@ -117,7 +108,7 @@ def get_context(self, context): } -class ObjectAttributesPanelMeta(ABCMeta): +class ObjectAttributesPanelMeta(type): def __new__(mcls, name, bases, namespace, **kwargs): declared = {} @@ -148,9 +139,8 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta): """ A panel which displays selected attributes of an object. - Attributes: - template_name: The name of the template to render - accessor: The name of the attribute on the 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. """ template_name = 'ui/panels/object_attributes.html' @@ -159,7 +149,6 @@ def __init__(self, only=None, exclude=None, **kwargs): Instantiate a new ObjectPanel. Parameters: - accessor: The name of the attribute on the object only: If specified, only attributes in this list will be displayed exclude: If specified, attributes in this list will be excluded from display """ @@ -181,12 +170,6 @@ def _name_to_label(name): return label def get_context(self, context): - """ - Return the context data to be used when rendering the panel. - - Parameters: - context: The template context - """ # Determine which attributes to display in the panel based on only/exclude args attr_names = set(self._attrs.keys()) if self.only: @@ -209,7 +192,7 @@ def get_context(self, context): class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta): """ - An ObjectPanel with attributes common to OrganizationalModels. + An ObjectPanel with attributes common to OrganizationalModels. Includes name and description. """ name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) @@ -217,7 +200,7 @@ class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttribute class NestedGroupObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta): """ - An ObjectPanel with attributes common to NestedGroupObjects. + An ObjectPanel with attributes common to NestedGroupObjects. Includes the parent object. """ parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) @@ -234,18 +217,12 @@ def __init__(self, field_name='comments', **kwargs): Instantiate a new CommentsPanel. Parameters: - field_name: The name of the comment field on the object + field_name: The name of the comment field on the object (default: "comments") """ 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), @@ -270,17 +247,9 @@ def __init__(self, field_name, copy_button=True, **kwargs): self.field_name = field_name if copy_button: - self.actions.append( - CopyContent(f'panel_{field_name}'), - ) + 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), @@ -300,12 +269,6 @@ class RelatedObjectsPanel(Panel): title = _('Related Objects') 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), 'related_models': context.get('related_models'), @@ -343,12 +306,6 @@ def __init__(self, model, filters=None, **kwargs): self.title = title(self.model._meta.verbose_name_plural) def get_context(self, context): - """ - Return the context data to be used when rendering the panel. - - Parameters: - context: The template context - """ url_params = { k: v(context) if callable(v) else v for k, v in self.filters.items() } @@ -363,7 +320,7 @@ def get_context(self, context): class TemplatePanel(Panel): """ - A panel which renders content using an HTML template. + A panel which renders custom content using an HTML template. """ def __init__(self, template_name, **kwargs): """ @@ -385,7 +342,7 @@ class PluginContentPanel(Panel): A panel which displays embedded plugin content. Parameters: - method: The name of the plugin method to render (e.g. left_page) + method: The name of the plugin method to render (e.g. "left_page") """ def __init__(self, method, **kwargs): super().__init__(**kwargs) diff --git a/netbox/templates/dcim/device/attrs/ipaddress.html b/netbox/templates/dcim/device/attrs/ipaddress.html index 2af5dab6c2c..7b434565760 100644 --- a/netbox/templates/dcim/device/attrs/ipaddress.html +++ b/netbox/templates/dcim/device/attrs/ipaddress.html @@ -1,4 +1,3 @@ -{# TODO: Add copy-to-clipboard button #} {% load i18n %} {{ value.address.ip }} {% if value.nat_inside %} diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index cab4f9f20eb..663bf564702 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -184,4 +184,7 @@ def static_with_params(path, **params): @register.simple_tag(takes_context=True) def render(context, component): + """ + Render a UI component (e.g. a Panel) by calling its render() method and passing the current template context. + """ return mark_safe(component.render(context))