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

View File

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

View File

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

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

View File

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

View File

@ -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 %}
<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 %}
{% 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.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))