From bcc410d99f82f278e71bdac9a37421f14219f229 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 6 Apr 2026 15:35:18 -0400 Subject: [PATCH] Closes #20924: Ready UI components for use by plugins (#21827) * Misc cleanup * Include permissions in TemplatedAttr context * Introduce CircuitTerminationPanel to replace generic panel * Replace all instantiations of Panel with TemplatePanel * Misc cleanup for layouts * Enable specifying column grid width * Panel.render() should pass the request to render_to_string() * CopyContent does not need to override render() * Avoid setting mutable panel actions * Catch exceptions raised when rendering embedded plugin content * Handle panel title when object is not available * Introduce should_render() method on Panel class * Misc cleanup * Pass the value returned by get_context() to should_render() * Yet more cleanup * Fix typos * Clean up object attrs * Replace candidate template panels with ObjectAttributesPanel subclasses * Add tests for object attrs * Remove beta warning * PluginContentPanel should not call should_render() * Clean up AddObject * speed.html should reference value for port_speed * Address PR feedback --- docs/plugins/development/ui-components.md | 97 +++++++--- netbox/circuits/ui/panels.py | 30 ++- netbox/circuits/views.py | 6 +- netbox/core/views.py | 6 + netbox/dcim/ui/panels.py | 78 ++++---- netbox/dcim/views.py | 19 +- netbox/extras/ui/panels.py | 9 +- netbox/ipam/ui/attrs.py | 21 +- netbox/ipam/ui/panels.py | 6 +- netbox/ipam/views.py | 15 ++ netbox/netbox/tests/test_ui.py | 183 +++++++++++++++++- netbox/netbox/ui/actions.py | 28 ++- netbox/netbox/ui/attrs.py | 105 +++++----- netbox/netbox/ui/layout.py | 33 +++- netbox/netbox/ui/panels.py | 100 ++++++---- .../circuit_termination/attrs/connection.html | 48 +++++ .../circuit_termination/attrs/speed.html | 8 + .../circuits/panels/circuit_termination.html | 16 -- .../dcim/panels/installed_device.html | 21 -- .../dcim/panels/installed_module.html | 33 ---- netbox/templates/generic/object.html | 8 +- netbox/templates/ui/attrs/numeric.html | 2 +- netbox/templates/ui/exception.html | 12 ++ netbox/templates/ui/panels/_base.html | 22 ++- .../vpn/panels/ipsecprofile_ike_policy.html | 34 ---- .../vpn/panels/ipsecprofile_ipsec_policy.html | 30 --- netbox/utilities/templatetags/plugins.py | 8 +- netbox/vpn/ui/panels.py | 17 ++ netbox/vpn/views.py | 5 +- netbox/wireless/ui/panels.py | 6 +- 30 files changed, 630 insertions(+), 376 deletions(-) create mode 100644 netbox/templates/circuits/circuit_termination/attrs/connection.html create mode 100644 netbox/templates/circuits/circuit_termination/attrs/speed.html delete mode 100644 netbox/templates/circuits/panels/circuit_termination.html delete mode 100644 netbox/templates/dcim/panels/installed_device.html delete mode 100644 netbox/templates/dcim/panels/installed_module.html create mode 100644 netbox/templates/ui/exception.html delete mode 100644 netbox/templates/vpn/panels/ipsecprofile_ike_policy.html delete mode 100644 netbox/templates/vpn/panels/ipsecprofile_ipsec_policy.html 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 %} +
    +
    + + {% trans "Trace" %} + + {% if perms.dcim.change_cable %} + + {% trans "Edit" %} + + {% endif %} + {% if perms.dcim.delete_cable %} + + {% trans "Disconnect" %} + + {% endif %} +
    +{% 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 %} - - - - - - - - - - {% include 'circuits/inc/circuit_termination_fields.html' with termination=object %} -
    {% trans "Circuit" %}{{ object.circuit|linkify|placeholder }}
    {% trans "Provider" %}{{ object.circuit.provider|linkify|placeholder }}
    -{% 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 %} + 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 @@
    -

    - {{ title }} - {% if actions %} -
    - {% for action in actions %} - {% render action %} - {% endfor %} -
    - {% endif %} -

    + {% if title or actions %} +

    + {{ title|default:"" }} + {% if actions %} +
    + {% for action in actions %} + {% render action %} + {% endfor %} +
    + {% endif %} +

    + {% 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 "IKE Policy" %}

    - - - - - - - - - - - - - - - - - - - - - -
    {% 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 "IPSec Policy" %}

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