Implement layout declaration under view

This commit is contained in:
Jeremy Stretch 2025-10-31 13:48:24 -04:00
parent eef9db5e5a
commit 3fd4664a76
17 changed files with 113 additions and 40 deletions

View File

@ -1,9 +1,9 @@
from django.utils.translation import gettext_lazy as _ 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) region = attrs.NestedObjectAttr('region', label=_('Region'), linkify=True)
group = attrs.NestedObjectAttr('group', label=_('Group'), linkify=True) group = attrs.NestedObjectAttr('group', label=_('Group'), linkify=True)
status = attrs.ChoiceAttr('status', label=_('Status')) status = attrs.ChoiceAttr('status', label=_('Status'))
@ -16,14 +16,14 @@ class SitePanel(components.ObjectPanel):
gps_coordinates = attrs.GPSCoordinatesAttr() gps_coordinates = attrs.GPSCoordinatesAttr()
class LocationPanel(components.NestedGroupObjectPanel): class LocationPanel(panels.NestedGroupObjectPanel):
site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group')
status = attrs.ChoiceAttr('status', label=_('Status')) status = attrs.ChoiceAttr('status', label=_('Status'))
tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group')
facility = attrs.TextAttr('facility', label=_('Facility')) facility = attrs.TextAttr('facility', label=_('Facility'))
class RackPanel(components.ObjectPanel): class RackPanel(panels.ObjectPanel):
region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True)
site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group')
location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) 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')) 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) region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True)
site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group')
location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) 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) 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')) status = attrs.ChoiceAttr('status', label=_('Status'))
role = attrs.NestedObjectAttr('role', label=_('Role'), linkify=True, max_depth=3) role = attrs.NestedObjectAttr('role', label=_('Role'), linkify=True, max_depth=3)
platform = attrs.NestedObjectAttr('platform', label=_('Platform'), linkify=True, max_depth=3) platform = attrs.NestedObjectAttr('platform', label=_('Platform'), linkify=True, max_depth=3)

View File

@ -17,7 +17,7 @@ from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.object_actions import * from netbox.object_actions import *
from netbox.ui.components import NestedGroupObjectPanel from netbox.ui import layout
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
@ -228,7 +228,7 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
regions = instance.get_descendants(include_self=True) regions = instance.get_descendants(include_self=True)
return { return {
'region_panel': NestedGroupObjectPanel(instance, _('Region')), 'region_panel': panels.NestedGroupObjectPanel(instance, _('Region')),
'related_models': self.get_related_models( 'related_models': self.get_related_models(
request, request,
regions, regions,
@ -340,7 +340,7 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
groups = instance.get_descendants(include_self=True) groups = instance.get_descendants(include_self=True)
return { return {
'sitegroup_panel': NestedGroupObjectPanel(instance, _('Site Group')), 'sitegroup_panel': panels.NestedGroupObjectPanel(instance, _('Site Group')),
'related_models': self.get_related_models( 'related_models': self.get_related_models(
request, request,
groups, groups,
@ -465,10 +465,17 @@ class SiteListView(generic.ObjectListView):
@register_model_view(Site) @register_model_view(Site)
class SiteView(GetRelatedModelsMixin, generic.ObjectView): class SiteView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Site.objects.prefetch_related('tenant__group') queryset = Site.objects.prefetch_related('tenant__group')
layout = layout.Layout(
layout.Row(
layout.Column(
panels.SitePanel(_('Site'))
),
)
)
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
return { return {
'site_panel': panels.SitePanel(instance, _('Site')), # 'site_panel': panels.SitePanel(instance, _('Site')),
'related_models': self.get_related_models( 'related_models': self.get_related_models(
request, request,
instance, instance,

View File

@ -35,7 +35,7 @@ class Attr(ABC):
class TextAttr(Attr): 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): def __init__(self, *args, style=None, copy_button=False, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -56,7 +56,7 @@ class TextAttr(Attr):
class ChoiceAttr(Attr): class ChoiceAttr(Attr):
template_name = 'components/attrs/choice.html' template_name = 'ui/attrs/choice.html'
def render(self, obj, context=None): def render(self, obj, context=None):
context = context or {} context = context or {}
@ -78,7 +78,7 @@ class ChoiceAttr(Attr):
class ObjectAttr(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): def __init__(self, *args, linkify=None, grouped_by=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -101,7 +101,7 @@ class ObjectAttr(Attr):
class NestedObjectAttr(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): def __init__(self, *args, linkify=None, max_depth=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -124,7 +124,7 @@ class NestedObjectAttr(Attr):
class AddressAttr(Attr): class AddressAttr(Attr):
template_name = 'components/attrs/address.html' template_name = 'ui/attrs/address.html'
def __init__(self, *args, map_url=True, **kwargs): def __init__(self, *args, map_url=True, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -148,7 +148,7 @@ class AddressAttr(Attr):
class GPSCoordinatesAttr(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): def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs):
kwargs.setdefault('label', _('GPS Coordinates')) kwargs.setdefault('label', _('GPS Coordinates'))
@ -177,7 +177,7 @@ class GPSCoordinatesAttr(Attr):
class TimezoneAttr(Attr): class TimezoneAttr(Attr):
template_name = 'components/attrs/timezone.html' template_name = 'ui/attrs/timezone.html'
def render(self, obj, context=None): def render(self, obj, context=None):
context = context or {} context = context or {}
@ -213,7 +213,7 @@ class TemplatedAttr(Attr):
class UtilizationAttr(Attr): class UtilizationAttr(Attr):
template_name = 'components/attrs/utilization.html' template_name = 'ui/attrs/utilization.html'
def render(self, obj, context=None): def render(self, obj, context=None):
context = context or {} context = context or {}

View File

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

View File

@ -1,5 +1,4 @@
from abc import ABC, ABCMeta, abstractmethod from abc import ABC, ABCMeta, abstractmethod
from functools import cached_property
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -8,18 +7,21 @@ from netbox.ui import attrs
from netbox.ui.attrs import Attr from netbox.ui.attrs import Attr
from utilities.string import title from utilities.string import title
__all__ = (
'NestedGroupObjectPanel',
'ObjectPanel',
'Panel',
)
class Component(ABC):
class Panel(ABC):
@abstractmethod @abstractmethod
def render(self): def render(self, obj):
pass pass
def __str__(self):
return self.render()
class ObjectPanelMeta(ABCMeta):
class ObjectDetailsPanelMeta(ABCMeta):
def __new__(mcls, name, bases, namespace, **kwargs): def __new__(mcls, name, bases, namespace, **kwargs):
declared = {} declared = {}
@ -46,33 +48,29 @@ class ObjectDetailsPanelMeta(ABCMeta):
return cls return cls
class ObjectPanel(Component, metaclass=ObjectDetailsPanelMeta): class ObjectPanel(Panel, metaclass=ObjectPanelMeta):
template_name = 'components/object_details_panel.html' template_name = 'ui/panels/object.html'
def __init__(self, obj, title=None): def __init__(self, title=None):
self.object = obj self.title = title
self.title = title or obj._meta.verbose_name
@cached_property def get_attributes(self, obj):
def attributes(self):
return [ return [
{ {
'label': attr.label or title(name), '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() } 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, { return render_to_string(self.template_name, {
'title': self.title, 'title': self.title,
'attrs': self.attributes, 'attrs': self.get_attributes(obj),
}) })
def __str__(self):
return self.render()
class NestedGroupObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta):
class NestedGroupObjectPanel(ObjectPanel, metaclass=ObjectDetailsPanelMeta):
name = attrs.TextAttr('name', label=_('Name')) name = attrs.TextAttr('name', label=_('Name'))
description = attrs.TextAttr('description', label=_('Description')) description = attrs.TextAttr('description', label=_('Description'))
parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True)

View File

@ -47,6 +47,7 @@ class ObjectView(ActionsMixin, BaseObjectView):
tab: A ViewTab instance for the view tab: A ViewTab instance for the view
actions: An iterable of ObjectAction subclasses (see ActionsMixin) actions: An iterable of ObjectAction subclasses (see ActionsMixin)
""" """
layout = None
tab = None tab = None
actions = (CloneObject, EditObject, DeleteObject) 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 Return self.template_name if defined. Otherwise, dynamically resolve the template name using the queryset
model's `app_label` and `model_name`. 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: if self.template_name is not None:
return self.template_name return self.template_name
model_opts = self.queryset.model._meta model_opts = self.queryset.model._meta
@ -81,6 +85,7 @@ class ObjectView(ActionsMixin, BaseObjectView):
'object': instance, 'object': instance,
'actions': actions, 'actions': actions,
'tab': self.tab, 'tab': self.tab,
'layout': self.layout,
**self.get_extra_context(request, instance), **self.get_extra_context(request, instance),
}) })

View File

@ -122,7 +122,20 @@ Context:
{% plugin_alerts object %} {% plugin_alerts object %}
{% endblock alerts %} {% endblock alerts %}
{% block content %}{% endblock %} {% block content %}
{# Render panel layout declared on view class #}
{% for row in layout.rows %}
<div class="row">
{% for column in row.columns %}
<div class="col">
{% for panel in column.panels %}
{% render_panel panel %}
{% endfor %}
</div>
{% endfor %}
</div>
{% endfor %}
{% endblock %}
{% block modals %} {% block modals %}
{% include 'inc/htmx_modal.html' %} {% include 'inc/htmx_modal.html' %}

View File

@ -3,6 +3,7 @@ from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
from django import template from django import template
from django.templatetags.static import static from django.templatetags.static import static
from django.utils.safestring import mark_safe
from extras.choices import CustomFieldTypeChoices from extras.choices import CustomFieldTypeChoices
from utilities.querydict import dict_to_querydict 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 # Reconstruct the URL with the new query string
new_parsed = parsed._replace(query=new_query) new_parsed = parsed._replace(query=new_query)
return urlunparse(new_parsed) return urlunparse(new_parsed)
@register.simple_tag(takes_context=True)
def render_panel(context, panel):
return mark_safe(panel.render(context))