Merge pull request #20737 from netbox-community/20204-template-components
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled

Closes #20204: Introduce modular template components
This commit is contained in:
bctiemann
2025-11-10 09:07:23 -05:00
committed by GitHub
60 changed files with 2060 additions and 1279 deletions

View File

189
netbox/dcim/ui/panels.py Normal file
View File

@@ -0,0 +1,189 @@
from django.utils.translation import gettext_lazy as _
from netbox.ui import attrs, panels
class SitePanel(panels.ObjectAttributesPanel):
region = attrs.NestedObjectAttr('region', linkify=True)
group = attrs.NestedObjectAttr('group', linkify=True)
name = attrs.TextAttr('name')
status = attrs.ChoiceAttr('status')
tenant = attrs.RelatedObjectAttr('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.RelatedObjectAttr('site', linkify=True, grouped_by='group')
status = attrs.ChoiceAttr('status')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
facility = attrs.TextAttr('facility')
class RackDimensionsPanel(panels.ObjectAttributesPanel):
form_factor = attrs.ChoiceAttr('form_factor')
width = attrs.ChoiceAttr('width')
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')
mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm')
class RackNumberingPanel(panels.ObjectAttributesPanel):
starting_unit = attrs.TextAttr('starting_unit')
desc_units = attrs.BooleanAttr('desc_units', label=_('Descending units'))
class RackPanel(panels.ObjectAttributesPanel):
region = attrs.NestedObjectAttr('site.region', linkify=True)
site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
location = attrs.NestedObjectAttr('location', linkify=True)
name = attrs.TextAttr('name')
facility = attrs.TextAttr('facility', label=_('Facility ID'))
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
status = attrs.ChoiceAttr('status')
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)
airflow = attrs.ChoiceAttr('airflow')
space_utilization = attrs.UtilizationAttr('get_utilization')
power_utilization = attrs.UtilizationAttr('get_power_utilization')
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.TemplatedAttr('total_weight', template_name='dcim/rack/attrs/total_weight.html')
class RackRolePanel(panels.OrganizationalObjectPanel):
color = attrs.ColorAttr('color')
class RackReservationPanel(panels.ObjectAttributesPanel):
units = attrs.TextAttr('unit_list')
status = attrs.ChoiceAttr('status')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
user = attrs.RelatedObjectAttr('user')
description = attrs.TextAttr('description')
class RackTypePanel(panels.ObjectAttributesPanel):
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.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.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.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.RelatedObjectAttr('config_template', linkify=True)
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)
primary_ip4 = attrs.TemplatedAttr(
'primary_ip4',
label=_('Primary IPv4'),
template_name='dcim/device/attrs/ipaddress.html',
)
primary_ip6 = attrs.TemplatedAttr(
'primary_ip6',
label=_('Primary IPv6'),
template_name='dcim/device/attrs/ipaddress.html',
)
oob_ip = attrs.TemplatedAttr(
'oob_ip',
label=_('Out-of-band IP'),
template_name='dcim/device/attrs/ipaddress.html',
)
cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
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')
class DeviceTypePanel(panels.ObjectAttributesPanel):
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
model = attrs.TextAttr('model')
part_number = attrs.TextAttr('part_number')
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')
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')
front_image = attrs.ImageAttr('front_image')
rear_image = attrs.ImageAttr('rear_image')
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 {
**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)
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)

View File

@@ -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
@@ -12,10 +13,17 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import View
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, JSONPanel, NestedGroupObjectPanel, ObjectsTablePanel, OrganizationalObjectPanel, RelatedObjectsPanel,
TemplatePanel,
)
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -221,6 +229,27 @@ class RegionListView(generic.ObjectListView):
@register_model_view(Region)
class RegionView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Region.objects.all()
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}),
],
),
]
)
def get_extra_context(self, request, instance):
regions = instance.get_descendants(include_self=True)
@@ -332,6 +361,27 @@ class SiteGroupListView(generic.ObjectListView):
@register_model_view(SiteGroup)
class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = SiteGroup.objects.all()
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}),
],
),
]
)
def get_extra_context(self, request, instance):
groups = instance.get_descendants(include_self=True)
@@ -461,6 +511,39 @@ class SiteListView(generic.ObjectListView):
@register_model_view(Site)
class SiteView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Site.objects.prefetch_related('tenant__group')
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}),
],
),
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}),
],
),
]
)
def get_extra_context(self, request, instance):
return {
@@ -561,6 +644,52 @@ class LocationListView(generic.ObjectListView):
@register_model_view(Location)
class LocationView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Location.objects.all()
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,
}
),
],
),
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):
locations = instance.get_descendants(include_self=True)
@@ -661,6 +790,16 @@ class RackRoleListView(generic.ObjectListView):
@register_model_view(RackRole)
class RackRoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = RackRole.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.RackRolePanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -727,7 +866,22 @@ class RackTypeListView(generic.ObjectListView):
@register_model_view(RackType)
class RackTypeView(GetRelatedModelsMixin, generic.ObjectView):
template_name = 'generic/object.html'
queryset = RackType.objects.all()
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):
return {
@@ -845,6 +999,22 @@ class RackElevationListView(generic.ObjectListView):
@register_model_view(Rack)
class RackView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
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):
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
@@ -976,6 +1146,19 @@ class RackReservationListView(generic.ObjectListView):
@register_model_view(RackReservation)
class RackReservationView(generic.ObjectView):
queryset = RackReservation.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.RackPanel(accessor='object.rack', only=['region', 'site', 'location', 'name']),
panels.RackReservationPanel(title=_('Reservation')),
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
TemplatePanel(template_name='dcim/panels/rack_reservation_elevations.html'),
RelatedObjectsPanel(),
],
)
@register_model_view(RackReservation, 'add', detail=False)
@@ -1049,6 +1232,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 {
@@ -1122,6 +1309,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 {
@@ -1372,7 +1571,36 @@ class ModuleTypeProfileListView(generic.ObjectListView):
@register_model_view(ModuleTypeProfile)
class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView):
template_name = 'generic/object.html'
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)
@@ -2213,6 +2441,43 @@ 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(),
panels.PowerUtilizationPanel(),
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(),
TemplatePanel('dcim/panels/device_rack_elevations.html'),
],
)
def get_extra_context(self, request, instance):
# VirtualChassis members
@@ -2225,7 +2490,7 @@ class DeviceView(generic.ObjectView):
return {
'vc_members': vc_members,
'svg_extra': f'highlight=id:{instance.pk}'
'svg_extra': f'highlight=id:{instance.pk}',
}