From 3fd4664a7605257c8268582ee2db9ddedd08d9dd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Oct 2025 13:48:24 -0400 Subject: [PATCH] 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 6ffd2e3d4..0ed917c55 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 f59d8babd..d5382fa26 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -17,7 +17,7 @@ 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.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 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView): 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 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): 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 293cfa29e..2e931d714 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -35,7 +35,7 @@ class Attr(ABC): 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 @@ class TextAttr(Attr): 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 @@ class ChoiceAttr(Attr): 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 @@ class ObjectAttr(Attr): 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 @@ class NestedObjectAttr(Attr): 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 @@ class AddressAttr(Attr): 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 @@ class GPSCoordinatesAttr(Attr): 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 @@ class TemplatedAttr(Attr): 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 000000000..1ff362e32 --- /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 156eb0304..15cad0a64 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 import attrs 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 @@ class ObjectDetailsPanelMeta(ABCMeta): 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 897191592..eb1a4d3a9 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 @@ class ObjectView(ActionsMixin, BaseObjectView): 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 @@ class ObjectView(ActionsMixin, BaseObjectView): '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 df95a4a42..a9783178a 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -122,7 +122,20 @@ Context: {% 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 8a275f44b..92c68f052 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -3,6 +3,7 @@ from urllib.parse import urlparse, urlunparse, parse_qs, urlencode 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))