diff --git a/docs/plugins/development/ui-components.md b/docs/plugins/development/ui-components.md
index a8fe2eff0..45766b201 100644
--- a/docs/plugins/development/ui-components.md
+++ b/docs/plugins/development/ui-components.md
@@ -1,12 +1,9 @@
# UI Components
-!!! note "New in NetBox v4.5"
- All UI components described here were introduced in NetBox v4.5. Be sure to set the minimum NetBox version to 4.5.0 for your plugin before incorporating any of these resources.
+!!! note "New in NetBox v4.6"
+ All UI components described here were introduced in NetBox v4.6. Be sure to set the minimum NetBox version to 4.6.0 for your plugin before incorporating any of these resources.
-!!! danger "Beta Feature"
- UI components are considered a beta feature, and are still under active development. Please be aware that the API for resources on this page is subject to change in future releases.
-
-To simply the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
+To simplify the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
## Page Layout
@@ -75,9 +72,12 @@ class RecentChangesPanel(Panel):
**super().get_context(context),
'changes': get_changes()[:10],
}
+
+ def should_render(self, context):
+ return len(context['changes']) > 0
```
-NetBox also includes a set of panels suite for specific uses, such as display object details or embedding a table of related objects. These are listed below.
+NetBox also includes a set of panels suited for specific uses, such as displaying object details or embedding a table of related objects. These are listed below.
::: netbox.ui.panels.Panel
@@ -85,26 +85,6 @@ NetBox also includes a set of panels suite for specific uses, such as display ob
::: netbox.ui.panels.ObjectAttributesPanel
-#### Object Attributes
-
-The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
-
-| Class | Description |
-|--------------------------------------|--------------------------------------------------|
-| `netbox.ui.attrs.AddressAttr` | A physical or mailing address. |
-| `netbox.ui.attrs.BooleanAttr` | A boolean value |
-| `netbox.ui.attrs.ColorAttr` | A color expressed in RGB |
-| `netbox.ui.attrs.ChoiceAttr` | A selection from a set of choices |
-| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude) |
-| `netbox.ui.attrs.ImageAttr` | An attached image (displays the image) |
-| `netbox.ui.attrs.NestedObjectAttr` | A related nested object |
-| `netbox.ui.attrs.NumericAttr` | An integer or float value |
-| `netbox.ui.attrs.RelatedObjectAttr` | A related object |
-| `netbox.ui.attrs.TemplatedAttr` | Renders an attribute using a custom template |
-| `netbox.ui.attrs.TextAttr` | A string (text) value |
-| `netbox.ui.attrs.TimezoneAttr` | A timezone with annotated offset |
-| `netbox.ui.attrs.UtilizationAttr` | A numeric value expressed as a utilization graph |
-
::: netbox.ui.panels.OrganizationalObjectPanel
::: netbox.ui.panels.NestedGroupObjectPanel
@@ -119,9 +99,13 @@ The following classes are available to represent object attributes within an Obj
::: netbox.ui.panels.TemplatePanel
+::: netbox.ui.panels.TextCodePanel
+
+::: netbox.ui.panels.ContextTablePanel
+
::: netbox.ui.panels.PluginContentPanel
-## Panel Actions
+### Panel Actions
Each panel may have actions associated with it. These render as links or buttons within the panel header, opposite the panel's title. For example, a common use case is to include an "Add" action on a panel which displays a list of objects. Below is an example of this.
@@ -146,3 +130,60 @@ panels.ObjectsTablePanel(
::: netbox.ui.actions.AddObject
::: netbox.ui.actions.CopyContent
+
+## Object Attributes
+
+The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
+
+| Class | Description |
+|------------------------------------------|--------------------------------------------------|
+| `netbox.ui.attrs.AddressAttr` | A physical or mailing address. |
+| `netbox.ui.attrs.BooleanAttr` | A boolean value |
+| `netbox.ui.attrs.ChoiceAttr` | A selection from a set of choices |
+| `netbox.ui.attrs.ColorAttr` | A color expressed in RGB |
+| `netbox.ui.attrs.DateTimeAttr` | A date or datetime value |
+| `netbox.ui.attrs.GenericForeignKeyAttr` | A related object via a generic foreign key |
+| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude) |
+| `netbox.ui.attrs.ImageAttr` | An attached image (displays the image) |
+| `netbox.ui.attrs.NestedObjectAttr` | A related nested object (includes ancestors) |
+| `netbox.ui.attrs.NumericAttr` | An integer or float value |
+| `netbox.ui.attrs.RelatedObjectAttr` | A related object |
+| `netbox.ui.attrs.RelatedObjectListAttr` | A list of related objects |
+| `netbox.ui.attrs.TemplatedAttr` | Renders an attribute using a custom template |
+| `netbox.ui.attrs.TextAttr` | A string (text) value |
+| `netbox.ui.attrs.TimezoneAttr` | A timezone with annotated offset |
+| `netbox.ui.attrs.UtilizationAttr` | A numeric value expressed as a utilization graph |
+
+::: netbox.ui.attrs.ObjectAttribute
+
+::: netbox.ui.attrs.AddressAttr
+
+::: netbox.ui.attrs.BooleanAttr
+
+::: netbox.ui.attrs.ChoiceAttr
+
+::: netbox.ui.attrs.ColorAttr
+
+::: netbox.ui.attrs.DateTimeAttr
+
+::: netbox.ui.attrs.GenericForeignKeyAttr
+
+::: netbox.ui.attrs.GPSCoordinatesAttr
+
+::: netbox.ui.attrs.ImageAttr
+
+::: netbox.ui.attrs.NestedObjectAttr
+
+::: netbox.ui.attrs.NumericAttr
+
+::: netbox.ui.attrs.RelatedObjectAttr
+
+::: netbox.ui.attrs.RelatedObjectListAttr
+
+::: netbox.ui.attrs.TemplatedAttr
+
+::: netbox.ui.attrs.TextAttr
+
+::: netbox.ui.attrs.TimezoneAttr
+
+::: netbox.ui.attrs.UtilizationAttr
diff --git a/netbox/circuits/ui/panels.py b/netbox/circuits/ui/panels.py
index e8d6cf9bb..7f86db88f 100644
--- a/netbox/circuits/ui/panels.py
+++ b/netbox/circuits/ui/panels.py
@@ -13,13 +13,9 @@ class CircuitCircuitTerminationPanel(panels.ObjectPanel):
template_name = 'circuits/panels/circuit_circuit_termination.html'
title = _('Termination')
- def __init__(self, accessor=None, side=None, **kwargs):
- super().__init__(**kwargs)
-
- if accessor is not None:
- self.accessor = accessor
- if side is not None:
- self.side = side
+ def __init__(self, side, accessor=None, **kwargs):
+ super().__init__(accessor=accessor, **kwargs)
+ self.side = side
def get_context(self, context):
return {
@@ -58,6 +54,26 @@ class CircuitGroupAssignmentsPanel(panels.ObjectsTablePanel):
)
+class CircuitTerminationPanel(panels.ObjectAttributesPanel):
+ title = _('Circuit Termination')
+ circuit = attrs.RelatedObjectAttr('circuit', linkify=True)
+ provider = attrs.RelatedObjectAttr('circuit.provider', linkify=True)
+ termination = attrs.GenericForeignKeyAttr('termination', linkify=True, label=_('Termination point'))
+ connection = attrs.TemplatedAttr(
+ 'pk',
+ template_name='circuits/circuit_termination/attrs/connection.html',
+ label=_('Connection'),
+ )
+ speed = attrs.TemplatedAttr(
+ 'port_speed',
+ template_name='circuits/circuit_termination/attrs/speed.html',
+ label=_('Speed'),
+ )
+ xconnect_id = attrs.TextAttr('xconnect_id', label=_('Cross-Connect'), style='font-monospace')
+ pp_info = attrs.TextAttr('pp_info', label=_('Patch Panel/Port'))
+ description = attrs.TextAttr('description')
+
+
class CircuitGroupPanel(panels.OrganizationalObjectPanel):
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index d6dad2e46..85912e00b 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -8,7 +8,6 @@ from netbox.ui import actions, layout
from netbox.ui.panels import (
CommentsPanel,
ObjectsTablePanel,
- Panel,
RelatedObjectsPanel,
)
from netbox.views import generic
@@ -512,10 +511,7 @@ class CircuitTerminationView(generic.ObjectView):
queryset = CircuitTermination.objects.all()
layout = layout.SimpleLayout(
left_panels=[
- Panel(
- template_name='circuits/panels/circuit_termination.html',
- title=_('Circuit Termination'),
- )
+ panels.CircuitTerminationPanel(),
],
right_panels=[
CustomFieldsPanel(),
diff --git a/netbox/core/views.py b/netbox/core/views.py
index 328679fa9..006fdc03c 100644
--- a/netbox/core/views.py
+++ b/netbox/core/views.py
@@ -193,8 +193,14 @@ class DataFileView(generic.ObjectView):
layout.Column(
panels.DataFilePanel(),
panels.DataFileContentPanel(),
+ PluginContentPanel('left_page'),
),
),
+ layout.Row(
+ layout.Column(
+ PluginContentPanel('full_width_page'),
+ )
+ ),
)
diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py
index 0434d37ff..217a6b68f 100644
--- a/netbox/dcim/ui/panels.py
+++ b/netbox/dcim/ui/panels.py
@@ -1,5 +1,4 @@
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, panels
@@ -75,7 +74,7 @@ class RackReservationPanel(panels.ObjectAttributesPanel):
unit_count = attrs.TextAttr('unit_count', label=_("Total U's"))
status = attrs.ChoiceAttr('status')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
- user = attrs.RelatedObjectAttr('user')
+ user = attrs.RelatedObjectAttr('user', linkify=True)
description = attrs.TextAttr('description')
@@ -220,8 +219,8 @@ class PowerPortPanel(panels.ObjectAttributesPanel):
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
description = attrs.TextAttr('description')
- maximum_draw = attrs.TextAttr('maximum_draw')
- allocated_draw = attrs.TextAttr('allocated_draw')
+ maximum_draw = attrs.TextAttr('maximum_draw', format_string='{}W')
+ allocated_draw = attrs.TextAttr('allocated_draw', format_string='{}W')
class PowerOutletPanel(panels.ObjectAttributesPanel):
@@ -244,7 +243,7 @@ class FrontPortPanel(panels.ObjectAttributesPanel):
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
color = attrs.ColorAttr('color')
- positions = attrs.TextAttr('positions')
+ positions = attrs.NumericAttr('positions')
description = attrs.TextAttr('description')
@@ -255,7 +254,7 @@ class RearPortPanel(panels.ObjectAttributesPanel):
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
color = attrs.ColorAttr('color')
- positions = attrs.TextAttr('positions')
+ positions = attrs.NumericAttr('positions')
description = attrs.TextAttr('description')
@@ -268,6 +267,15 @@ class ModuleBayPanel(panels.ObjectAttributesPanel):
description = attrs.TextAttr('description')
+class InstalledModulePanel(panels.ObjectAttributesPanel):
+ title = _('Installed Module')
+ module = attrs.RelatedObjectAttr('installed_module', linkify=True)
+ manufacturer = attrs.RelatedObjectAttr('installed_module.module_type.manufacturer', linkify=True)
+ module_type = attrs.RelatedObjectAttr('installed_module.module_type', linkify=True)
+ serial = attrs.TextAttr('installed_module.serial', label=_('Serial number'), style='font-monospace')
+ asset_tag = attrs.TextAttr('installed_module.asset_tag', style='font-monospace')
+
+
class DeviceBayPanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
name = attrs.TextAttr('name')
@@ -275,6 +283,12 @@ class DeviceBayPanel(panels.ObjectAttributesPanel):
description = attrs.TextAttr('description')
+class InstalledDevicePanel(panels.ObjectAttributesPanel):
+ title = _('Installed Device')
+ device = attrs.RelatedObjectAttr('installed_device', linkify=True)
+ device_type = attrs.RelatedObjectAttr('installed_device.device_type')
+
+
class InventoryItemPanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
parent = attrs.RelatedObjectAttr('parent', linkify=True, label=_('Parent item'))
@@ -393,10 +407,6 @@ class ConnectionPanel(panels.ObjectPanel):
'show_endpoints': self.show_endpoints,
}
- def render(self, context):
- ctx = self.get_context(context)
- return render_to_string(self.template_name, ctx, request=ctx.get('request'))
-
class InventoryItemsPanel(panels.ObjectPanel):
"""
@@ -414,10 +424,6 @@ class InventoryItemsPanel(panels.ObjectPanel):
),
]
- def render(self, context):
- ctx = self.get_context(context)
- return render_to_string(self.template_name, ctx, request=ctx.get('request'))
-
class VirtualChassisMembersPanel(panels.ObjectPanel):
"""
@@ -451,10 +457,8 @@ class VirtualChassisMembersPanel(panels.ObjectPanel):
'vc_members': context.get('vc_members'),
}
- def render(self, context):
- if not context.get('vc_members'):
- return ''
- return super().render(context)
+ def should_render(self, context):
+ return bool(context.get('vc_members'))
class PowerUtilizationPanel(panels.ObjectPanel):
@@ -470,11 +474,9 @@ class PowerUtilizationPanel(panels.ObjectPanel):
'vc_members': context.get('vc_members'),
}
- def render(self, context):
+ def should_render(self, context):
obj = context['object']
- if not obj.powerports.exists() or not obj.poweroutlets.exists():
- return ''
- return super().render(context)
+ return obj.powerports.exists() and obj.poweroutlets.exists()
class InterfacePanel(panels.ObjectAttributesPanel):
@@ -485,7 +487,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
type = attrs.ChoiceAttr('type')
speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
duplex = attrs.ChoiceAttr('duplex')
- mtu = attrs.TextAttr('mtu', label=_('MTU'))
+ mtu = attrs.NumericAttr('mtu', label=_('MTU'))
enabled = attrs.BooleanAttr('enabled')
mgmt_only = attrs.BooleanAttr('mgmt_only', label=_('Management only'))
description = attrs.TextAttr('description')
@@ -494,7 +496,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
mode = attrs.ChoiceAttr('mode', label=_('802.1Q mode'))
qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
untagged_vlan = attrs.RelatedObjectAttr('untagged_vlan', linkify=True, label=_('Untagged VLAN'))
- tx_power = attrs.TextAttr('tx_power', label=_('Transmit power (dBm)'))
+ tx_power = attrs.TextAttr('tx_power', label=_('Transmit power'), format_string='{} dBm')
tunnel = attrs.RelatedObjectAttr('tunnel_termination.tunnel', linkify=True, label=_('Tunnel'))
l2vpn = attrs.RelatedObjectAttr('l2vpn_termination.l2vpn', linkify=True, label=_('L2VPN'))
@@ -527,12 +529,9 @@ class InterfaceConnectionPanel(panels.ObjectPanel):
template_name = 'dcim/panels/interface_connection.html'
title = _('Connection')
- def render(self, context):
+ def should_render(self, context):
obj = context.get('object')
- if obj and obj.is_virtual:
- return ''
- ctx = self.get_context(context)
- return render_to_string(self.template_name, ctx, request=ctx.get('request'))
+ return False if (obj is None or obj.is_virtual) else True
class VirtualCircuitPanel(panels.ObjectPanel):
@@ -542,12 +541,11 @@ class VirtualCircuitPanel(panels.ObjectPanel):
template_name = 'dcim/panels/interface_virtual_circuit.html'
title = _('Virtual Circuit')
- def render(self, context):
+ def should_render(self, context):
obj = context.get('object')
if not obj or not obj.is_virtual or not hasattr(obj, 'virtual_circuit_termination'):
- return ''
- ctx = self.get_context(context)
- return render_to_string(self.template_name, ctx, request=ctx.get('request'))
+ return False
+ return True
class InterfaceWirelessPanel(panels.ObjectPanel):
@@ -557,12 +555,9 @@ class InterfaceWirelessPanel(panels.ObjectPanel):
template_name = 'dcim/panels/interface_wireless.html'
title = _('Wireless')
- def render(self, context):
+ def should_render(self, context):
obj = context.get('object')
- if not obj or not obj.is_wireless:
- return ''
- ctx = self.get_context(context)
- return render_to_string(self.template_name, ctx, request=ctx.get('request'))
+ return False if (obj is None or not obj.is_wireless) else True
class WirelessLANsPanel(panels.ObjectPanel):
@@ -572,9 +567,6 @@ class WirelessLANsPanel(panels.ObjectPanel):
template_name = 'dcim/panels/interface_wireless_lans.html'
title = _('Wireless LANs')
- def render(self, context):
+ def should_render(self, context):
obj = context.get('object')
- if not obj or not obj.is_wireless:
- return ''
- ctx = self.get_context(context)
- return render_to_string(self.template_name, ctx, request=ctx.get('request'))
+ return False if (obj is None or not obj.is_wireless) else True
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 5ebb22c6b..847210267 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -27,7 +27,6 @@ from netbox.ui.panels import (
NestedGroupObjectPanel,
ObjectsTablePanel,
OrganizationalObjectPanel,
- Panel,
RelatedObjectsPanel,
TemplatePanel,
)
@@ -1771,7 +1770,7 @@ class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
CommentsPanel(),
],
right_panels=[
- Panel(
+ TemplatePanel(
title=_('Attributes'),
template_name='dcim/panels/module_type_attributes.html',
),
@@ -2945,7 +2944,7 @@ class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
CommentsPanel(),
],
right_panels=[
- Panel(
+ TemplatePanel(
title=_('Module Type'),
template_name='dcim/panels/module_type.html',
),
@@ -3753,10 +3752,7 @@ class ModuleBayView(generic.ObjectView):
],
right_panels=[
CustomFieldsPanel(),
- Panel(
- title=_('Installed Module'),
- template_name='dcim/panels/installed_module.html',
- ),
+ panels.InstalledModulePanel(),
],
)
@@ -3828,10 +3824,7 @@ class DeviceBayView(generic.ObjectView):
TagsPanel(),
],
right_panels=[
- Panel(
- title=_('Installed Device'),
- template_name='dcim/panels/installed_device.html',
- ),
+ panels.InstalledDevicePanel(),
],
)
@@ -4323,11 +4316,11 @@ class CableView(generic.ObjectView):
CommentsPanel(),
],
right_panels=[
- Panel(
+ TemplatePanel(
title=_('Termination A'),
template_name='dcim/panels/cable_termination_a.html',
),
- Panel(
+ TemplatePanel(
title=_('Termination B'),
template_name='dcim/panels/cable_termination_b.html',
),
diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py
index 51b59c57b..23a3da37b 100644
--- a/netbox/extras/ui/panels.py
+++ b/netbox/extras/ui/panels.py
@@ -1,5 +1,4 @@
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, panels
@@ -65,12 +64,8 @@ class CustomFieldsPanel(panels.ObjectPanel):
'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))
+ def should_render(self, context):
+ return bool(context['custom_fields'])
class ImageAttachmentsPanel(panels.ObjectsTablePanel):
diff --git a/netbox/ipam/ui/attrs.py b/netbox/ipam/ui/attrs.py
index cd5cba0f2..aab23648b 100644
--- a/netbox/ipam/ui/attrs.py
+++ b/netbox/ipam/ui/attrs.py
@@ -7,6 +7,9 @@ class VRFDisplayAttr(attrs.ObjectAttribute):
"""
Renders a VRF reference, displaying 'Global' when no VRF is assigned. Optionally includes
the route distinguisher (RD).
+
+ Parameters:
+ show_rd (bool): If true, the VRF's RD will be included. (Default: False)
"""
template_name = 'ipam/attrs/vrf.html'
@@ -14,11 +17,17 @@ class VRFDisplayAttr(attrs.ObjectAttribute):
super().__init__(*args, **kwargs)
self.show_rd = show_rd
- def render(self, obj, context):
- value = self.get_value(obj)
- return render_to_string(self.template_name, {
- **self.get_context(obj, context),
- 'name': context['name'],
- 'value': value,
+ def get_context(self, obj, attr, value, context):
+ return {
'show_rd': self.show_rd,
+ }
+
+ def render(self, obj, context):
+ name = context['name']
+ value = self.get_value(obj)
+
+ return render_to_string(self.template_name, {
+ **self.get_context(obj, name, value, context),
+ 'name': name,
+ 'value': value,
})
diff --git a/netbox/ipam/ui/panels.py b/netbox/ipam/ui/panels.py
index 06e429967..c8984352a 100644
--- a/netbox/ipam/ui/panels.py
+++ b/netbox/ipam/ui/panels.py
@@ -229,11 +229,9 @@ class VLANCustomerVLANsPanel(panels.ObjectsTablePanel):
],
)
- def render(self, context):
+ def should_render(self, context):
obj = context.get('object')
- if not obj or obj.qinq_role != 'svlan':
- return ''
- return super().render(context)
+ return False if (obj is None or obj.qinq_role != 'svlan') else True
class ServiceTemplatePanel(panels.ObjectAttributesPanel):
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 4fc30342b..3b1ce9b10 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -16,6 +16,7 @@ from netbox.ui.panels import (
CommentsPanel,
ContextTablePanel,
ObjectsTablePanel,
+ PluginContentPanel,
RelatedObjectsPanel,
TemplatePanel,
)
@@ -55,11 +56,13 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView):
layout.Column(
panels.VRFPanel(),
TagsPanel(),
+ PluginContentPanel('left_page'),
),
layout.Column(
RelatedObjectsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
+ PluginContentPanel('right_page'),
),
),
layout.Row(
@@ -70,6 +73,11 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView):
ContextTablePanel('export_targets_table', title=_('Export route targets')),
),
),
+ layout.Row(
+ layout.Column(
+ PluginContentPanel('full_width_page'),
+ ),
+ ),
)
def get_extra_context(self, request, instance):
@@ -169,10 +177,12 @@ class RouteTargetView(generic.ObjectView):
layout.Column(
panels.RouteTargetPanel(),
TagsPanel(),
+ PluginContentPanel('left_page'),
),
layout.Column(
CustomFieldsPanel(),
CommentsPanel(),
+ PluginContentPanel('right_page'),
),
),
layout.Row(
@@ -207,6 +217,11 @@ class RouteTargetView(generic.ObjectView):
),
),
),
+ layout.Row(
+ layout.Column(
+ PluginContentPanel('full_width_page'),
+ ),
+ ),
)
diff --git a/netbox/netbox/tests/test_ui.py b/netbox/netbox/tests/test_ui.py
index cb76517c1..4d0a05415 100644
--- a/netbox/netbox/tests/test_ui.py
+++ b/netbox/netbox/tests/test_ui.py
@@ -1,3 +1,5 @@
+from types import SimpleNamespace
+
from django.test import TestCase
from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
@@ -76,7 +78,7 @@ class ChoiceAttrTest(TestCase):
self.termination.get_role_display(),
)
self.assertEqual(
- attr.get_context(self.termination, {}),
+ attr.get_context(self.termination, 'role', attr.get_value(self.termination), {}),
{'bg_color': self.termination.get_role_color()},
)
@@ -88,7 +90,7 @@ class ChoiceAttrTest(TestCase):
self.termination.interface.get_type_display(),
)
self.assertEqual(
- attr.get_context(self.termination, {}),
+ attr.get_context(self.termination, 'interface.type', attr.get_value(self.termination), {}),
{'bg_color': None},
)
@@ -100,7 +102,9 @@ class ChoiceAttrTest(TestCase):
self.termination.virtual_circuit.get_status_display(),
)
self.assertEqual(
- attr.get_context(self.termination, {}),
+ attr.get_context(
+ self.termination, 'virtual_circuit.status', attr.get_value(self.termination), {}
+ ),
{'bg_color': self.termination.virtual_circuit.get_status_color()},
)
@@ -213,3 +217,176 @@ class RelatedObjectListAttrTest(TestCase):
self.assertInHTML('
IKE Proposal 2', rendered)
self.assertNotIn('IKE Proposal 3', rendered)
self.assertIn('…', rendered)
+
+
+class TextAttrTest(TestCase):
+
+ def test_get_value_with_format_string(self):
+ attr = attrs.TextAttr('asn', format_string='AS{}')
+ obj = SimpleNamespace(asn=65000)
+ self.assertEqual(attr.get_value(obj), 'AS65000')
+
+ def test_get_value_without_format_string(self):
+ attr = attrs.TextAttr('name')
+ obj = SimpleNamespace(name='foo')
+ self.assertEqual(attr.get_value(obj), 'foo')
+
+ def test_get_value_none_skips_format_string(self):
+ attr = attrs.TextAttr('name', format_string='prefix-{}')
+ obj = SimpleNamespace(name=None)
+ self.assertIsNone(attr.get_value(obj))
+
+ def test_get_context(self):
+ attr = attrs.TextAttr('name', style='text-monospace', copy_button=True)
+ obj = SimpleNamespace(name='bar')
+ context = attr.get_context(obj, 'name', 'bar', {})
+ self.assertEqual(context['style'], 'text-monospace')
+ self.assertTrue(context['copy_button'])
+
+
+class NumericAttrTest(TestCase):
+
+ def test_get_context_with_unit_accessor(self):
+ attr = attrs.NumericAttr('speed', unit_accessor='speed_unit')
+ obj = SimpleNamespace(speed=1000, speed_unit='Mbps')
+ context = attr.get_context(obj, 'speed', 1000, {})
+ self.assertEqual(context['unit'], 'Mbps')
+
+ def test_get_context_without_unit_accessor(self):
+ attr = attrs.NumericAttr('speed')
+ obj = SimpleNamespace(speed=1000)
+ context = attr.get_context(obj, 'speed', 1000, {})
+ self.assertIsNone(context['unit'])
+
+ def test_get_context_copy_button(self):
+ attr = attrs.NumericAttr('speed', copy_button=True)
+ obj = SimpleNamespace(speed=1000)
+ context = attr.get_context(obj, 'speed', 1000, {})
+ self.assertTrue(context['copy_button'])
+
+
+class BooleanAttrTest(TestCase):
+
+ def test_false_value_shown_by_default(self):
+ attr = attrs.BooleanAttr('enabled')
+ obj = SimpleNamespace(enabled=False)
+ self.assertIs(attr.get_value(obj), False)
+
+ def test_false_value_hidden_when_display_false_disabled(self):
+ attr = attrs.BooleanAttr('enabled', display_false=False)
+ obj = SimpleNamespace(enabled=False)
+ self.assertIsNone(attr.get_value(obj))
+
+ def test_true_value_always_shown(self):
+ attr = attrs.BooleanAttr('enabled', display_false=False)
+ obj = SimpleNamespace(enabled=True)
+ self.assertIs(attr.get_value(obj), True)
+
+
+class ImageAttrTest(TestCase):
+
+ def test_invalid_decoding_raises_value_error(self):
+ with self.assertRaises(ValueError):
+ attrs.ImageAttr('image', decoding='invalid')
+
+ def test_default_decoding_for_lazy_image(self):
+ attr = attrs.ImageAttr('image')
+ self.assertTrue(attr.load_lazy)
+ self.assertEqual(attr.decoding, 'async')
+
+ def test_default_decoding_for_non_lazy_image(self):
+ attr = attrs.ImageAttr('image', load_lazy=False)
+ self.assertFalse(attr.load_lazy)
+ self.assertIsNone(attr.decoding)
+
+ def test_explicit_decoding_value(self):
+ attr = attrs.ImageAttr('image', load_lazy=False, decoding='sync')
+ self.assertEqual(attr.decoding, 'sync')
+
+ def test_get_context(self):
+ attr = attrs.ImageAttr('image', load_lazy=False, decoding='async')
+ obj = SimpleNamespace(image='test.png')
+ context = attr.get_context(obj, 'image', 'test.png', {})
+ self.assertEqual(context['decoding'], 'async')
+ self.assertFalse(context['load_lazy'])
+
+
+class RelatedObjectAttrTest(TestCase):
+
+ def test_get_context_with_grouped_by(self):
+ region = SimpleNamespace(name='Region 1')
+ site = SimpleNamespace(name='Site 1', region=region)
+ obj = SimpleNamespace(site=site)
+ attr = attrs.RelatedObjectAttr('site', grouped_by='region')
+ context = attr.get_context(obj, 'site', site, {})
+ self.assertEqual(context['group'], region)
+
+ def test_get_context_without_grouped_by(self):
+ site = SimpleNamespace(name='Site 1')
+ obj = SimpleNamespace(site=site)
+ attr = attrs.RelatedObjectAttr('site')
+ context = attr.get_context(obj, 'site', site, {})
+ self.assertIsNone(context['group'])
+
+ def test_get_context_linkify(self):
+ site = SimpleNamespace(name='Site 1')
+ obj = SimpleNamespace(site=site)
+ attr = attrs.RelatedObjectAttr('site', linkify=True)
+ context = attr.get_context(obj, 'site', site, {})
+ self.assertTrue(context['linkify'])
+
+
+class GenericForeignKeyAttrTest(TestCase):
+
+ def test_get_context_content_type(self):
+ value = SimpleNamespace(_meta=SimpleNamespace(verbose_name='provider'))
+ obj = SimpleNamespace()
+ attr = attrs.GenericForeignKeyAttr('assigned_object')
+ context = attr.get_context(obj, 'assigned_object', value, {})
+ self.assertEqual(context['content_type'], 'provider')
+
+ def test_get_context_linkify(self):
+ value = SimpleNamespace(_meta=SimpleNamespace(verbose_name='provider'))
+ obj = SimpleNamespace()
+ attr = attrs.GenericForeignKeyAttr('assigned_object', linkify=True)
+ context = attr.get_context(obj, 'assigned_object', value, {})
+ self.assertTrue(context['linkify'])
+
+
+class GPSCoordinatesAttrTest(TestCase):
+
+ def test_missing_latitude_returns_placeholder(self):
+ attr = attrs.GPSCoordinatesAttr()
+ obj = SimpleNamespace(latitude=None, longitude=-74.006)
+ self.assertEqual(attr.render(obj, {'name': 'coordinates'}), attr.placeholder)
+
+ def test_missing_longitude_returns_placeholder(self):
+ attr = attrs.GPSCoordinatesAttr()
+ obj = SimpleNamespace(latitude=40.712, longitude=None)
+ self.assertEqual(attr.render(obj, {'name': 'coordinates'}), attr.placeholder)
+
+ def test_both_missing_returns_placeholder(self):
+ attr = attrs.GPSCoordinatesAttr()
+ obj = SimpleNamespace(latitude=None, longitude=None)
+ self.assertEqual(attr.render(obj, {'name': 'coordinates'}), attr.placeholder)
+
+
+class DateTimeAttrTest(TestCase):
+
+ def test_default_spec(self):
+ attr = attrs.DateTimeAttr('created')
+ obj = SimpleNamespace(created='2024-01-01')
+ context = attr.get_context(obj, 'created', '2024-01-01', {})
+ self.assertEqual(context['spec'], 'seconds')
+
+ def test_date_spec(self):
+ attr = attrs.DateTimeAttr('created', spec='date')
+ obj = SimpleNamespace(created='2024-01-01')
+ context = attr.get_context(obj, 'created', '2024-01-01', {})
+ self.assertEqual(context['spec'], 'date')
+
+ def test_minutes_spec(self):
+ attr = attrs.DateTimeAttr('created', spec='minutes')
+ obj = SimpleNamespace(created='2024-01-01')
+ context = attr.get_context(obj, 'created', '2024-01-01', {})
+ self.assertEqual(context['spec'], 'minutes')
diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py
index 7579e7b93..4363c722d 100644
--- a/netbox/netbox/ui/actions.py
+++ b/netbox/netbox/ui/actions.py
@@ -59,7 +59,7 @@ class PanelAction:
"""
# Enforce permissions
user = context['request'].user
- if not user.has_perms(self.permissions):
+ if self.permissions and not user.has_perms(self.permissions):
return ''
return render_to_string(self.template_name, self.get_context(context))
@@ -118,19 +118,19 @@ class AddObject(LinkAction):
url_params (dict): A dictionary of arbitrary URL parameters to append to the resolved URL
"""
def __init__(self, model, url_params=None, **kwargs):
- # Resolve the model class from its app.name label
- try:
- app_label, model_name = model.split('.')
- model = apps.get_model(app_label, model_name)
- except (ValueError, LookupError):
+ # Resolve the model from its label
+ if '.' not in model:
+ raise ValueError(f"Invalid model label: {model}")
+ try:
+ self.model = apps.get_model(model)
+ except LookupError:
raise ValueError(f"Invalid model label: {model}")
- view_name = get_viewname(model, 'add')
kwargs.setdefault('label', _('Add'))
kwargs.setdefault('button_icon', 'plus-thick')
- kwargs.setdefault('permissions', [get_permission_for_model(model, 'add')])
+ kwargs.setdefault('permissions', [get_permission_for_model(self.model, 'add')])
- super().__init__(view_name=view_name, url_params=url_params, **kwargs)
+ super().__init__(view_name=get_viewname(self.model, 'add'), url_params=url_params, **kwargs)
class CopyContent(PanelAction):
@@ -148,10 +148,8 @@ class CopyContent(PanelAction):
super().__init__(**kwargs)
self.target_id = target_id
- def render(self, context):
- return render_to_string(self.template_name, {
+ def get_context(self, context):
+ return {
+ **super().get_context(context),
'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 e4bd93c4e..83a7e912e 100644
--- a/netbox/netbox/ui/attrs.py
+++ b/netbox/netbox/ui/attrs.py
@@ -29,11 +29,27 @@ PLACEHOLDER_HTML = '—'
IMAGE_DECODING_CHOICES = ('auto', 'async', 'sync')
+
+#
+# Mixins
+#
+
+class MapURLMixin:
+ _map_url = None
+
+ @property
+ def map_url(self):
+ if self._map_url is True:
+ return get_config().MAPS_URL
+ if self._map_url:
+ return self._map_url
+ return None
+
+
#
# Attributes
#
-
class ObjectAttribute:
"""
Base class for representing an attribute of an object.
@@ -64,17 +80,20 @@ class ObjectAttribute:
"""
return resolve_attr_path(obj, self.accessor)
- def get_context(self, obj, context):
+ def get_context(self, obj, attr, value, context):
"""
Return any additional template context used to render the attribute value.
Parameters:
obj (object): The object for which the attribute is being rendered
- context (dict): The root template context
+ attr (str): The name of the attribute being rendered
+ value: The value of the attribute on the object
+ context (dict): The panel template context
"""
return {}
def render(self, obj, context):
+ name = context['name']
value = self.get_value(obj)
# If the value is empty, render a placeholder
@@ -82,8 +101,8 @@ class ObjectAttribute:
return self.placeholder
return render_to_string(self.template_name, {
- **self.get_context(obj, context),
- 'name': context['name'],
+ **self.get_context(obj, name, value, context),
+ 'name': name,
'value': value,
})
@@ -112,7 +131,7 @@ class TextAttr(ObjectAttribute):
return self.format_string.format(value)
return value
- def get_context(self, obj, context):
+ def get_context(self, obj, attr, value, context):
return {
'style': self.style,
'copy_button': self.copy_button,
@@ -134,7 +153,7 @@ class NumericAttr(ObjectAttribute):
self.unit_accessor = unit_accessor
self.copy_button = copy_button
- def get_context(self, obj, context):
+ def get_context(self, obj, attr, value, context):
unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None
return {
'unit': unit,
@@ -172,7 +191,7 @@ class ChoiceAttr(ObjectAttribute):
return resolve_attr_path(target, field_name)
- def get_context(self, obj, context):
+ def get_context(self, obj, attr, value, context):
target, field_name = self._resolve_target(obj)
if target is None:
return {'bg_color': None}
@@ -241,7 +260,7 @@ class ImageAttr(ObjectAttribute):
decoding = 'async'
self.decoding = decoding
- def get_context(self, obj, context):
+ def get_context(self, obj, attr, value, context):
return {
'decoding': self.decoding,
'load_lazy': self.load_lazy,
@@ -264,8 +283,7 @@ class RelatedObjectAttr(ObjectAttribute):
self.linkify = linkify
self.grouped_by = grouped_by
- def get_context(self, obj, context):
- value = self.get_value(obj)
+ def get_context(self, obj, attr, value, context):
group = getattr(value, self.grouped_by, None) if self.grouped_by else None
return {
'linkify': self.linkify,
@@ -300,14 +318,13 @@ class RelatedObjectListAttr(RelatedObjectAttr):
self.max_items = max_items
self.overflow_indicator = overflow_indicator
- def _get_items(self, obj):
+ def _get_items(self, items):
"""
Retrieve items from the given object using the accessor path.
Returns a tuple of (items, has_more) where items is a list of resolved objects
and has_more indicates whether additional items exist beyond the max_items limit.
"""
- items = resolve_attr_path(obj, self.accessor)
if items is None:
return [], False
@@ -322,8 +339,8 @@ class RelatedObjectListAttr(RelatedObjectAttr):
return items[:self.max_items], has_more
- def get_context(self, obj, context):
- items, has_more = self._get_items(obj)
+ def get_context(self, obj, attr, value, context):
+ items, has_more = self._get_items(value)
return {
'linkify': self.linkify,
@@ -338,14 +355,15 @@ class RelatedObjectListAttr(RelatedObjectAttr):
}
def render(self, obj, context):
- context = context or {}
- context_data = self.get_context(obj, context)
+ name = context['name']
+ value = self.get_value(obj)
+ context_data = self.get_context(obj, name, value, context)
if not context_data['items']:
return self.placeholder
return render_to_string(self.template_name, {
- 'name': context.get('name'),
+ 'name': name,
**context_data,
})
@@ -366,11 +384,12 @@ class NestedObjectAttr(ObjectAttribute):
self.linkify = linkify
self.max_depth = max_depth
- 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:]
+ def get_context(self, obj, attr, value, context):
+ nodes = []
+ if value is not None:
+ nodes = value.get_ancestors(include_self=True)
+ if self.max_depth:
+ nodes = list(nodes)[-self.max_depth:]
return {
'nodes': nodes,
'linkify': self.linkify,
@@ -394,40 +413,35 @@ class GenericForeignKeyAttr(ObjectAttribute):
super().__init__(*args, **kwargs)
self.linkify = linkify
- def get_context(self, obj, context):
- value = self.get_value(obj)
- content_type = value._meta.verbose_name
+ def get_context(self, obj, attr, value, context):
+ content_type = value._meta.verbose_name if value is not None else None
return {
'content_type': content_type,
'linkify': self.linkify,
}
-class AddressAttr(ObjectAttribute):
+class AddressAttr(MapURLMixin, ObjectAttribute):
"""
A physical or mailing address.
Parameters:
- map_url (bool): If true, the address will render as a hyperlink using settings.MAPS_URL
+ map_url (bool/str): The URL to use when rendering the address. If True, the address will render as a
+ hyperlink using settings.MAPS_URL.
"""
template_name = 'ui/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
+ self._map_url = map_url
- def get_context(self, obj, context):
+ def get_context(self, obj, attr, value, context):
return {
'map_url': self.map_url,
}
-class GPSCoordinatesAttr(ObjectAttribute):
+class GPSCoordinatesAttr(MapURLMixin, ObjectAttribute):
"""
A GPS coordinates pair comprising latitude and longitude values.
@@ -440,24 +454,18 @@ class GPSCoordinatesAttr(ObjectAttribute):
label = _('GPS coordinates')
def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs):
- super().__init__(accessor=None, **kwargs)
+ super().__init__(accessor=latitude_attr, **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:
- self.map_url = map_url
- else:
- self.map_url = None
+ self._map_url = map_url
- def render(self, obj, context=None):
- context = context or {}
+ def render(self, obj, context):
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, {
- **context,
+ 'name': context['name'],
'latitude': latitude,
'longitude': longitude,
'map_url': self.map_url,
@@ -478,7 +486,7 @@ class DateTimeAttr(ObjectAttribute):
super().__init__(*args, **kwargs)
self.spec = spec
- def get_context(self, obj, context):
+ def get_context(self, obj, attr, value, context):
return {
'spec': self.spec,
}
@@ -504,8 +512,9 @@ class TemplatedAttr(ObjectAttribute):
self.template_name = template_name
self.context = context or {}
- def get_context(self, obj, context):
+ def get_context(self, obj, attr, value, context):
return {
+ **context,
**self.context,
'object': obj,
}
diff --git a/netbox/netbox/ui/layout.py b/netbox/netbox/ui/layout.py
index b59fd7b34..375417288 100644
--- a/netbox/netbox/ui/layout.py
+++ b/netbox/netbox/ui/layout.py
@@ -21,10 +21,16 @@ class Layout:
"""
def __init__(self, *rows):
for i, row in enumerate(rows):
- if type(row) is not Row:
+ if not isinstance(row, Row):
raise TypeError(f"Row {i} must be a Row instance, not {type(row)}.")
self.rows = rows
+ def __iter__(self):
+ return iter(self.rows)
+
+ def __repr__(self):
+ return f"Layout({len(self.rows)} rows)"
+
class Row:
"""
@@ -35,10 +41,16 @@ class Row:
"""
def __init__(self, *columns):
for i, column in enumerate(columns):
- if type(column) is not Column:
+ if not isinstance(column, Column):
raise TypeError(f"Column {i} must be a Column instance, not {type(column)}.")
self.columns = columns
+ def __iter__(self):
+ return iter(self.columns)
+
+ def __repr__(self):
+ return f"Row({len(self.columns)} columns)"
+
class Column:
"""
@@ -46,12 +58,25 @@ class Column:
Parameters:
*panels: One or more Panel instances
+ width: Bootstrap grid column width (1-12). If unset, the column will expand to fill available space.
"""
- def __init__(self, *panels):
+ def __init__(self, *panels, width=None):
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)}.")
+ if width is not None:
+ if type(width) is not int:
+ raise ValueError(f"Column width must be an integer, not {type(width)}")
+ if width not in range(1, 13):
+ raise ValueError(f"Column width must be an integer between 1 and 12 (got {width}).")
self.panels = panels
+ self.width = width
+
+ def __iter__(self):
+ return iter(self.panels)
+
+ def __repr__(self):
+ return f"Column({len(self.panels)} panels)"
#
@@ -62,7 +87,7 @@ class SimpleLayout(Layout):
"""
A layout with one row of two columns and a second row with one column.
- Plugin content registered for `left_page`, `right_page`, or `full_width_path` is included automatically. Most object
+ Plugin content registered for `left_page`, `right_page`, or `full_width_page` is included automatically. Most object
views in NetBox utilize this layout.
```
diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py
index fa26cf754..87bf30d6a 100644
--- a/netbox/netbox/ui/panels.py
+++ b/netbox/netbox/ui/panels.py
@@ -45,18 +45,17 @@ class Panel:
Parameters:
title (str): The human-friendly title of the panel
actions (list): An iterable of PanelActions to include in the panel header
- template_name (str): Overrides the default template name, if defined
"""
template_name = None
title = None
actions = None
- def __init__(self, title=None, actions=None, template_name=None):
+ def __init__(self, title=None, actions=None):
if title is not None:
self.title = title
- self.actions = actions or self.actions or []
- if template_name is not None:
- self.template_name = template_name
+ if actions is not None:
+ self.actions = actions
+ self.actions = list(self.actions) if self.actions else []
def get_context(self, context):
"""
@@ -74,6 +73,15 @@ class Panel:
'panel_class': self.__class__.__name__,
}
+ def should_render(self, context):
+ """
+ Determines whether the panel should render on the page. (Default: True)
+
+ Parameters:
+ context (dict): The panel's prepared context (the return value of get_context())
+ """
+ return True
+
def render(self, context):
"""
Render the panel as HTML.
@@ -81,7 +89,10 @@ class Panel:
Parameters:
context (dict): The template context
"""
- return render_to_string(self.template_name, self.get_context(context))
+ ctx = self.get_context(context)
+ if not self.should_render(ctx):
+ return ''
+ return render_to_string(self.template_name, ctx, request=ctx.get('request'))
#
@@ -105,9 +116,15 @@ class ObjectPanel(Panel):
def get_context(self, context):
obj = resolve_attr_path(context, self.accessor)
+ if self.title is not None:
+ title_ = self.title
+ elif obj is not None:
+ title_ = title(obj._meta.verbose_name)
+ else:
+ title_ = None
return {
**super().get_context(context),
- 'title': self.title or title(obj._meta.verbose_name),
+ 'title': title_,
'object': obj,
}
@@ -187,7 +204,7 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
'attrs': [
{
'label': attr.label or self._name_to_label(name),
- 'value': attr.render(ctx['object'], {'name': name}),
+ 'value': attr.render(ctx['object'], {'name': name, 'perms': ctx['perms']}),
} for name, attr in self._attrs.items() if name in attr_names
],
}
@@ -225,9 +242,10 @@ class CommentsPanel(ObjectPanel):
self.field_name = field_name
def get_context(self, context):
+ ctx = super().get_context(context)
return {
- **super().get_context(context),
- 'comments': getattr(context['object'], self.field_name),
+ **ctx,
+ 'comments': getattr(ctx['object'], self.field_name, None),
}
@@ -249,9 +267,10 @@ class JSONPanel(ObjectPanel):
self.actions.append(CopyContent(f'panel_{field_name}'))
def get_context(self, context):
+ ctx = super().get_context(context)
return {
- **super().get_context(context),
- 'data': getattr(context['object'], self.field_name),
+ **ctx,
+ 'data': getattr(ctx['object'], self.field_name, None),
'field_name': self.field_name,
}
@@ -291,22 +310,27 @@ class ObjectsTablePanel(Panel):
def __init__(self, model, filters=None, include_columns=None, exclude_columns=None, **kwargs):
super().__init__(**kwargs)
- # Resolve the model class from its app.name label
- try:
- app_label, model_name = model.split('.')
- self.model = apps.get_model(app_label, model_name)
- except (ValueError, LookupError):
+ # Validate the model label format
+ if '.' not in model:
raise ValueError(f"Invalid model label: {model}")
-
+ self.model_label = model
self.filters = filters or {}
self.include_columns = include_columns or []
self.exclude_columns = exclude_columns 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)
+ @property
+ def model(self):
+ try:
+ return apps.get_model(self.model_label)
+ except LookupError:
+ raise ValueError(f"Invalid model label: {self.model_label}")
def get_context(self, context):
+ model = self.model
+
+ # If no title is specified, derive one from the model name
+ panel_title = self.title or title(model._meta.verbose_name_plural)
+
url_params = {
k: v(context) if callable(v) else v for k, v in self.filters.items()
}
@@ -318,7 +342,8 @@ class ObjectsTablePanel(Panel):
url_params['exclude_columns'] = ','.join(self.exclude_columns)
return {
**super().get_context(context),
- 'viewname': get_viewname(self.model, 'list'),
+ 'title': panel_title,
+ 'viewname': get_viewname(model, 'list'),
'url_params': dict_to_querydict(url_params),
}
@@ -330,12 +355,17 @@ class TemplatePanel(Panel):
Parameters:
template_name (str): The name of the template to render
"""
- def __init__(self, template_name):
- super().__init__(template_name=template_name)
+ def __init__(self, template_name, **kwargs):
+ self.template_name = template_name
+ super().__init__(**kwargs)
- def render(self, context):
- # Pass the entire context to the template
- return render_to_string(self.template_name, context.flatten())
+ def get_context(self, context):
+ # Pass the entire context to the template, but let the panel's own context take precedence
+ # for panel-specific variables (title, actions, panel_class)
+ return {
+ **context.flatten(),
+ **super().get_context(context)
+ }
class TextCodePanel(ObjectPanel):
@@ -350,10 +380,11 @@ class TextCodePanel(ObjectPanel):
self.show_sync_warning = show_sync_warning
def get_context(self, context):
+ ctx = super().get_context(context)
return {
- **super().get_context(context),
+ **ctx,
'show_sync_warning': self.show_sync_warning,
- 'value': getattr(context.get('object'), self.field_name, None),
+ 'value': getattr(ctx['object'], self.field_name, None),
}
@@ -369,6 +400,7 @@ class PluginContentPanel(Panel):
self.method = method
def render(self, context):
+ # Override the default render() method to simply embed rendered plugin content
obj = context.get('object')
return _get_registered_content(obj, self.method, context)
@@ -399,14 +431,10 @@ class ContextTablePanel(ObjectPanel):
return context.get(self.table)
def get_context(self, context):
- table = self._resolve_table(context)
return {
**super().get_context(context),
- 'table': table,
+ 'table': self._resolve_table(context),
}
- def render(self, context):
- table = self._resolve_table(context)
- if table is None:
- return ''
- return super().render(context)
+ def should_render(self, context):
+ return context.get('table') is not None
diff --git a/netbox/templates/circuits/circuit_termination/attrs/connection.html b/netbox/templates/circuits/circuit_termination/attrs/connection.html
new file mode 100644
index 000000000..0a60f9a41
--- /dev/null
+++ b/netbox/templates/circuits/circuit_termination/attrs/connection.html
@@ -0,0 +1,48 @@
+{% load helpers i18n %}
+{% if object.mark_connected %}
+
+
+ {% trans "Marked as connected" %}
+
+{% elif object.cable %}
+
+
{{ object.cable }} {% trans "to" %}
+ {% for peer in object.link_peers %}
+ {% if peer.device %}
+ {{ peer.device|linkify }}
+ {% elif peer.circuit %}
+ {{ peer.circuit|linkify }}
+ {% endif %}
+ {{ peer|linkify }}{% if not forloop.last %},{% endif %}
+ {% endfor %}
+
+
+{% elif perms.dcim.add_cable %}
+
+
+
+
+{% else %}
+ {{ ''|placeholder }}
+{% endif %}
diff --git a/netbox/templates/circuits/circuit_termination/attrs/speed.html b/netbox/templates/circuits/circuit_termination/attrs/speed.html
new file mode 100644
index 000000000..a590ad27a
--- /dev/null
+++ b/netbox/templates/circuits/circuit_termination/attrs/speed.html
@@ -0,0 +1,8 @@
+{% load helpers i18n %}
+{% if object.upstream_speed %}
+ {{ value|humanize_speed }}
+
+ {{ object.upstream_speed|humanize_speed }}
+{% else %}
+ {{ value|humanize_speed }}
+{% endif %}
diff --git a/netbox/templates/circuits/panels/circuit_termination.html b/netbox/templates/circuits/panels/circuit_termination.html
deleted file mode 100644
index 5583d1536..000000000
--- a/netbox/templates/circuits/panels/circuit_termination.html
+++ /dev/null
@@ -1,16 +0,0 @@
-{% extends "ui/panels/_base.html" %}
-{% load helpers i18n %}
-
-{% block panel_content %}
-
-
- | {% trans "Circuit" %} |
- {{ object.circuit|linkify|placeholder }} |
-
-
- | {% trans "Provider" %} |
- {{ object.circuit.provider|linkify|placeholder }} |
-
- {% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
-
-{% endblock panel_content %}
diff --git a/netbox/templates/dcim/panels/installed_device.html b/netbox/templates/dcim/panels/installed_device.html
deleted file mode 100644
index c95bf7f5d..000000000
--- a/netbox/templates/dcim/panels/installed_device.html
+++ /dev/null
@@ -1,21 +0,0 @@
-{% extends "ui/panels/_base.html" %}
-{% load helpers i18n %}
-
-{% block panel_content %}
- {% if object.installed_device %}
- {% with device=object.installed_device %}
-
-
- | {% trans "Device" %} |
- {{ device|linkify }} |
-
-
- | {% trans "Device type" %} |
- {{ device.device_type }} |
-
-
- {% endwith %}
- {% else %}
- {% trans "None" %}
- {% endif %}
-{% endblock panel_content %}
diff --git a/netbox/templates/dcim/panels/installed_module.html b/netbox/templates/dcim/panels/installed_module.html
deleted file mode 100644
index 8125d2a63..000000000
--- a/netbox/templates/dcim/panels/installed_module.html
+++ /dev/null
@@ -1,33 +0,0 @@
-{% extends "ui/panels/_base.html" %}
-{% load helpers i18n %}
-
-{% block panel_content %}
- {% if object.installed_module %}
- {% with module=object.installed_module %}
-
-
- | {% trans "Module" %} |
- {{ module|linkify }} |
-
-
- | {% trans "Manufacturer" %} |
- {{ module.module_type.manufacturer|linkify }} |
-
-
- | {% trans "Module type" %} |
- {{ module.module_type|linkify }} |
-
-
- | {% trans "Serial number" %} |
- {{ module.serial|placeholder }} |
-
-
- | {% trans "Asset tag" %} |
- {{ module.asset_tag|placeholder }} |
-
-
- {% endwith %}
- {% else %}
- {% trans "None" %}
- {% endif %}
-{% endblock panel_content %}
diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html
index 100d5bde7..cb7974fde 100644
--- a/netbox/templates/generic/object.html
+++ b/netbox/templates/generic/object.html
@@ -124,11 +124,11 @@ Context:
{% block content %}
{# Render panel layout declared on view class #}
- {% for row in layout.rows %}
+ {% for row in layout %}
- {% for column in row.columns %}
-
- {% for panel in column.panels %}
+ {% for column in row %}
+
+ {% for panel in column %}
{% render panel %}
{% endfor %}
diff --git a/netbox/templates/ui/attrs/numeric.html b/netbox/templates/ui/attrs/numeric.html
index 5c54f2979..455a66c22 100644
--- a/netbox/templates/ui/attrs/numeric.html
+++ b/netbox/templates/ui/attrs/numeric.html
@@ -1,5 +1,5 @@
{% load i18n %}
-
+
{{ value }}
{% if unit %}
{{ unit|lower }}
diff --git a/netbox/templates/ui/exception.html b/netbox/templates/ui/exception.html
new file mode 100644
index 000000000..a777b0b5e
--- /dev/null
+++ b/netbox/templates/ui/exception.html
@@ -0,0 +1,12 @@
+{% load i18n %}
+
+
+
+
+
+
+ {% blocktrans %}An error occurred when loading content from plugin {{ plugin }}:{% endblocktrans %}
+
+
{{ exception }}
+
+
diff --git a/netbox/templates/ui/panels/_base.html b/netbox/templates/ui/panels/_base.html
index 5128e6428..670d9fda8 100644
--- a/netbox/templates/ui/panels/_base.html
+++ b/netbox/templates/ui/panels/_base.html
@@ -1,15 +1,17 @@
-
+ {% if title or actions %}
+
+ {% endif %}
{% block panel_content %}{% endblock %}
diff --git a/netbox/templates/vpn/panels/ipsecprofile_ike_policy.html b/netbox/templates/vpn/panels/ipsecprofile_ike_policy.html
deleted file mode 100644
index 8b3065cfd..000000000
--- a/netbox/templates/vpn/panels/ipsecprofile_ike_policy.html
+++ /dev/null
@@ -1,34 +0,0 @@
-{% load helpers %}
-{% load i18n %}
-
-
-
-
-
- | {% trans "Name" %} |
- {{ object.ike_policy|linkify }} |
-
-
- | {% trans "Description" %} |
- {{ object.ike_policy.description|placeholder }} |
-
-
- | {% trans "Version" %} |
- {{ object.ike_policy.get_version_display }} |
-
-
- | {% trans "Mode" %} |
- {{ object.ike_policy.get_mode_display }} |
-
-
- | {% trans "Proposals" %} |
-
-
- {% for proposal in object.ike_policy.proposals.all %}
- - {{ proposal }}
- {% endfor %}
-
- |
-
-
-
diff --git a/netbox/templates/vpn/panels/ipsecprofile_ipsec_policy.html b/netbox/templates/vpn/panels/ipsecprofile_ipsec_policy.html
deleted file mode 100644
index cb28c1620..000000000
--- a/netbox/templates/vpn/panels/ipsecprofile_ipsec_policy.html
+++ /dev/null
@@ -1,30 +0,0 @@
-{% load helpers %}
-{% load i18n %}
-
-
-
-
-
- | {% trans "Name" %} |
- {{ object.ipsec_policy|linkify }} |
-
-
- | {% trans "Description" %} |
- {{ object.ipsec_policy.description|placeholder }} |
-
-
- | {% trans "Proposals" %} |
-
-
- {% for proposal in object.ipsec_policy.proposals.all %}
- - {{ proposal }}
- {% endfor %}
-
- |
-
-
- | {% trans "PFS Group" %} |
- {{ object.ipsec_policy.get_pfs_group_display }} |
-
-
-
diff --git a/netbox/utilities/templatetags/plugins.py b/netbox/utilities/templatetags/plugins.py
index 40e6b8196..c3157d119 100644
--- a/netbox/utilities/templatetags/plugins.py
+++ b/netbox/utilities/templatetags/plugins.py
@@ -1,5 +1,6 @@
from django import template as template_
from django.conf import settings
+from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
from netbox.plugins import PluginTemplateExtension
@@ -38,8 +39,11 @@ def _get_registered_content(obj, method, template_context):
context['config'] = settings.PLUGINS_CONFIG.get(plugin_name, {})
# Call the method to render content
- instance = template_extension(context)
- content = getattr(instance, method)()
+ try:
+ instance = template_extension(context)
+ content = getattr(instance, method)()
+ except Exception as e:
+ content = render_to_string('ui/exception.html', {'plugin': plugin_name, 'exception': repr(e)})
html += content
return mark_safe(html)
diff --git a/netbox/vpn/ui/panels.py b/netbox/vpn/ui/panels.py
index 41afec132..20b3f5be9 100644
--- a/netbox/vpn/ui/panels.py
+++ b/netbox/vpn/ui/panels.py
@@ -71,6 +71,23 @@ class IPSecProfilePanel(panels.ObjectAttributesPanel):
mode = attrs.ChoiceAttr('mode')
+class IPSecProfileIKEPolicyPanel(panels.ObjectAttributesPanel):
+ title = _('IKE Policy')
+ name = attrs.RelatedObjectAttr('ike_policy', linkify=True)
+ description = attrs.TextAttr('ike_policy.description')
+ version = attrs.ChoiceAttr('ike_policy.version', label=_('IKE version'))
+ mode = attrs.ChoiceAttr('ike_policy.mode')
+ proposals = attrs.RelatedObjectListAttr('ike_policy.proposals', linkify=True)
+
+
+class IPSecProfileIPSecPolicyPanel(panels.ObjectAttributesPanel):
+ title = _('IPSec Policy')
+ name = attrs.RelatedObjectAttr('ipsec_policy', linkify=True)
+ description = attrs.TextAttr('ipsec_policy.description')
+ proposals = attrs.RelatedObjectListAttr('ipsec_policy.proposals', linkify=True)
+ pfs_group = attrs.ChoiceAttr('ipsec_policy.pfs_group', label=_('PFS group'))
+
+
class L2VPNPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
identifier = attrs.TextAttr('identifier')
diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py
index e74c53b64..f365377ad 100644
--- a/netbox/vpn/views.py
+++ b/netbox/vpn/views.py
@@ -10,7 +10,6 @@ from netbox.ui.panels import (
ObjectsTablePanel,
PluginContentPanel,
RelatedObjectsPanel,
- TemplatePanel,
)
from netbox.views import generic
from utilities.query import count_related
@@ -591,8 +590,8 @@ class IPSecProfileView(generic.ObjectView):
CommentsPanel(),
],
right_panels=[
- TemplatePanel('vpn/panels/ipsecprofile_ike_policy.html'),
- TemplatePanel('vpn/panels/ipsecprofile_ipsec_policy.html'),
+ panels.IPSecProfileIKEPolicyPanel(),
+ panels.IPSecProfileIPSecPolicyPanel(),
],
)
diff --git a/netbox/wireless/ui/panels.py b/netbox/wireless/ui/panels.py
index 82fbaf51d..0135fe4bb 100644
--- a/netbox/wireless/ui/panels.py
+++ b/netbox/wireless/ui/panels.py
@@ -34,10 +34,10 @@ class WirelessLinkInterfacePanel(panels.ObjectPanel):
self.title = title
def get_context(self, context):
- obj = context['object']
+ ctx = super().get_context(context)
return {
- **super().get_context(context),
- 'interface': getattr(obj, self.interface_attr),
+ **ctx,
+ 'interface': getattr(ctx['object'], self.interface_attr),
}