Răsfoiți Sursa

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
Jeremy Stretch 1 zi în urmă
părinte
comite
bcc410d99f

+ 69 - 28
docs/plugins/development/ui-components.md

@@ -1,12 +1,9 @@
 # UI Components
 # 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
 ## Page Layout
 
 
@@ -75,9 +72,12 @@ class RecentChangesPanel(Panel):
             **super().get_context(context),
             **super().get_context(context),
             'changes': get_changes()[:10],
             '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
 ::: 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
 ::: 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.OrganizationalObjectPanel
 
 
 ::: netbox.ui.panels.NestedGroupObjectPanel
 ::: 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.TemplatePanel
 
 
+::: netbox.ui.panels.TextCodePanel
+
+::: netbox.ui.panels.ContextTablePanel
+
 ::: netbox.ui.panels.PluginContentPanel
 ::: 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.
 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.AddObject
 
 
 ::: netbox.ui.actions.CopyContent
 ::: 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

+ 23 - 7
netbox/circuits/ui/panels.py

@@ -13,13 +13,9 @@ class CircuitCircuitTerminationPanel(panels.ObjectPanel):
     template_name = 'circuits/panels/circuit_circuit_termination.html'
     template_name = 'circuits/panels/circuit_circuit_termination.html'
     title = _('Termination')
     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):
     def get_context(self, context):
         return {
         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):
 class CircuitGroupPanel(panels.OrganizationalObjectPanel):
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
 
 

+ 1 - 5
netbox/circuits/views.py

@@ -8,7 +8,6 @@ from netbox.ui import actions, layout
 from netbox.ui.panels import (
 from netbox.ui.panels import (
     CommentsPanel,
     CommentsPanel,
     ObjectsTablePanel,
     ObjectsTablePanel,
-    Panel,
     RelatedObjectsPanel,
     RelatedObjectsPanel,
 )
 )
 from netbox.views import generic
 from netbox.views import generic
@@ -512,10 +511,7 @@ class CircuitTerminationView(generic.ObjectView):
     queryset = CircuitTermination.objects.all()
     queryset = CircuitTermination.objects.all()
     layout = layout.SimpleLayout(
     layout = layout.SimpleLayout(
         left_panels=[
         left_panels=[
-            Panel(
-                template_name='circuits/panels/circuit_termination.html',
-                title=_('Circuit Termination'),
-            )
+            panels.CircuitTerminationPanel(),
         ],
         ],
         right_panels=[
         right_panels=[
             CustomFieldsPanel(),
             CustomFieldsPanel(),

+ 6 - 0
netbox/core/views.py

@@ -193,8 +193,14 @@ class DataFileView(generic.ObjectView):
             layout.Column(
             layout.Column(
                 panels.DataFilePanel(),
                 panels.DataFilePanel(),
                 panels.DataFileContentPanel(),
                 panels.DataFileContentPanel(),
+                PluginContentPanel('left_page'),
             ),
             ),
         ),
         ),
+        layout.Row(
+            layout.Column(
+                PluginContentPanel('full_width_page'),
+            )
+        ),
     )
     )
 
 
 
 

+ 35 - 43
netbox/dcim/ui/panels.py

@@ -1,5 +1,4 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.template.loader import render_to_string
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from netbox.ui import actions, attrs, panels
 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"))
     unit_count = attrs.TextAttr('unit_count', label=_("Total U's"))
     status = attrs.ChoiceAttr('status')
     status = attrs.ChoiceAttr('status')
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
-    user = attrs.RelatedObjectAttr('user')
+    user = attrs.RelatedObjectAttr('user', linkify=True)
     description = attrs.TextAttr('description')
     description = attrs.TextAttr('description')
 
 
 
 
@@ -220,8 +219,8 @@ class PowerPortPanel(panels.ObjectAttributesPanel):
     label = attrs.TextAttr('label')
     label = attrs.TextAttr('label')
     type = attrs.ChoiceAttr('type')
     type = attrs.ChoiceAttr('type')
     description = attrs.TextAttr('description')
     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):
 class PowerOutletPanel(panels.ObjectAttributesPanel):
@@ -244,7 +243,7 @@ class FrontPortPanel(panels.ObjectAttributesPanel):
     label = attrs.TextAttr('label')
     label = attrs.TextAttr('label')
     type = attrs.ChoiceAttr('type')
     type = attrs.ChoiceAttr('type')
     color = attrs.ColorAttr('color')
     color = attrs.ColorAttr('color')
-    positions = attrs.TextAttr('positions')
+    positions = attrs.NumericAttr('positions')
     description = attrs.TextAttr('description')
     description = attrs.TextAttr('description')
 
 
 
 
@@ -255,7 +254,7 @@ class RearPortPanel(panels.ObjectAttributesPanel):
     label = attrs.TextAttr('label')
     label = attrs.TextAttr('label')
     type = attrs.ChoiceAttr('type')
     type = attrs.ChoiceAttr('type')
     color = attrs.ColorAttr('color')
     color = attrs.ColorAttr('color')
-    positions = attrs.TextAttr('positions')
+    positions = attrs.NumericAttr('positions')
     description = attrs.TextAttr('description')
     description = attrs.TextAttr('description')
 
 
 
 
@@ -268,6 +267,15 @@ class ModuleBayPanel(panels.ObjectAttributesPanel):
     description = attrs.TextAttr('description')
     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):
 class DeviceBayPanel(panels.ObjectAttributesPanel):
     device = attrs.RelatedObjectAttr('device', linkify=True)
     device = attrs.RelatedObjectAttr('device', linkify=True)
     name = attrs.TextAttr('name')
     name = attrs.TextAttr('name')
@@ -275,6 +283,12 @@ class DeviceBayPanel(panels.ObjectAttributesPanel):
     description = attrs.TextAttr('description')
     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):
 class InventoryItemPanel(panels.ObjectAttributesPanel):
     device = attrs.RelatedObjectAttr('device', linkify=True)
     device = attrs.RelatedObjectAttr('device', linkify=True)
     parent = attrs.RelatedObjectAttr('parent', linkify=True, label=_('Parent item'))
     parent = attrs.RelatedObjectAttr('parent', linkify=True, label=_('Parent item'))
@@ -393,10 +407,6 @@ class ConnectionPanel(panels.ObjectPanel):
             'show_endpoints': self.show_endpoints,
             '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):
 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):
 class VirtualChassisMembersPanel(panels.ObjectPanel):
     """
     """
@@ -451,10 +457,8 @@ class VirtualChassisMembersPanel(panels.ObjectPanel):
             'vc_members': context.get('vc_members'),
             '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):
 class PowerUtilizationPanel(panels.ObjectPanel):
@@ -470,11 +474,9 @@ class PowerUtilizationPanel(panels.ObjectPanel):
             'vc_members': context.get('vc_members'),
             'vc_members': context.get('vc_members'),
         }
         }
 
 
-    def render(self, context):
+    def should_render(self, context):
         obj = context['object']
         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):
 class InterfacePanel(panels.ObjectAttributesPanel):
@@ -485,7 +487,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
     type = attrs.ChoiceAttr('type')
     type = attrs.ChoiceAttr('type')
     speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
     speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
     duplex = attrs.ChoiceAttr('duplex')
     duplex = attrs.ChoiceAttr('duplex')
-    mtu = attrs.TextAttr('mtu', label=_('MTU'))
+    mtu = attrs.NumericAttr('mtu', label=_('MTU'))
     enabled = attrs.BooleanAttr('enabled')
     enabled = attrs.BooleanAttr('enabled')
     mgmt_only = attrs.BooleanAttr('mgmt_only', label=_('Management only'))
     mgmt_only = attrs.BooleanAttr('mgmt_only', label=_('Management only'))
     description = attrs.TextAttr('description')
     description = attrs.TextAttr('description')
@@ -494,7 +496,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
     mode = attrs.ChoiceAttr('mode', label=_('802.1Q mode'))
     mode = attrs.ChoiceAttr('mode', label=_('802.1Q mode'))
     qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
     qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
     untagged_vlan = attrs.RelatedObjectAttr('untagged_vlan', linkify=True, label=_('Untagged VLAN'))
     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'))
     tunnel = attrs.RelatedObjectAttr('tunnel_termination.tunnel', linkify=True, label=_('Tunnel'))
     l2vpn = attrs.RelatedObjectAttr('l2vpn_termination.l2vpn', linkify=True, label=_('L2VPN'))
     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'
     template_name = 'dcim/panels/interface_connection.html'
     title = _('Connection')
     title = _('Connection')
 
 
-    def render(self, context):
+    def should_render(self, context):
         obj = context.get('object')
         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):
 class VirtualCircuitPanel(panels.ObjectPanel):
@@ -542,12 +541,11 @@ class VirtualCircuitPanel(panels.ObjectPanel):
     template_name = 'dcim/panels/interface_virtual_circuit.html'
     template_name = 'dcim/panels/interface_virtual_circuit.html'
     title = _('Virtual Circuit')
     title = _('Virtual Circuit')
 
 
-    def render(self, context):
+    def should_render(self, context):
         obj = context.get('object')
         obj = context.get('object')
         if not obj or not obj.is_virtual or not hasattr(obj, 'virtual_circuit_termination'):
         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):
 class InterfaceWirelessPanel(panels.ObjectPanel):
@@ -557,12 +555,9 @@ class InterfaceWirelessPanel(panels.ObjectPanel):
     template_name = 'dcim/panels/interface_wireless.html'
     template_name = 'dcim/panels/interface_wireless.html'
     title = _('Wireless')
     title = _('Wireless')
 
 
-    def render(self, context):
+    def should_render(self, context):
         obj = context.get('object')
         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):
 class WirelessLANsPanel(panels.ObjectPanel):
@@ -572,9 +567,6 @@ class WirelessLANsPanel(panels.ObjectPanel):
     template_name = 'dcim/panels/interface_wireless_lans.html'
     template_name = 'dcim/panels/interface_wireless_lans.html'
     title = _('Wireless LANs')
     title = _('Wireless LANs')
 
 
-    def render(self, context):
+    def should_render(self, context):
         obj = context.get('object')
         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

+ 6 - 13
netbox/dcim/views.py

@@ -27,7 +27,6 @@ from netbox.ui.panels import (
     NestedGroupObjectPanel,
     NestedGroupObjectPanel,
     ObjectsTablePanel,
     ObjectsTablePanel,
     OrganizationalObjectPanel,
     OrganizationalObjectPanel,
-    Panel,
     RelatedObjectsPanel,
     RelatedObjectsPanel,
     TemplatePanel,
     TemplatePanel,
 )
 )
@@ -1771,7 +1770,7 @@ class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
             CommentsPanel(),
             CommentsPanel(),
         ],
         ],
         right_panels=[
         right_panels=[
-            Panel(
+            TemplatePanel(
                 title=_('Attributes'),
                 title=_('Attributes'),
                 template_name='dcim/panels/module_type_attributes.html',
                 template_name='dcim/panels/module_type_attributes.html',
             ),
             ),
@@ -2945,7 +2944,7 @@ class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
             CommentsPanel(),
             CommentsPanel(),
         ],
         ],
         right_panels=[
         right_panels=[
-            Panel(
+            TemplatePanel(
                 title=_('Module Type'),
                 title=_('Module Type'),
                 template_name='dcim/panels/module_type.html',
                 template_name='dcim/panels/module_type.html',
             ),
             ),
@@ -3753,10 +3752,7 @@ class ModuleBayView(generic.ObjectView):
         ],
         ],
         right_panels=[
         right_panels=[
             CustomFieldsPanel(),
             CustomFieldsPanel(),
-            Panel(
-                title=_('Installed Module'),
-                template_name='dcim/panels/installed_module.html',
-            ),
+            panels.InstalledModulePanel(),
         ],
         ],
     )
     )
 
 
@@ -3828,10 +3824,7 @@ class DeviceBayView(generic.ObjectView):
             TagsPanel(),
             TagsPanel(),
         ],
         ],
         right_panels=[
         right_panels=[
-            Panel(
-                title=_('Installed Device'),
-                template_name='dcim/panels/installed_device.html',
-            ),
+            panels.InstalledDevicePanel(),
         ],
         ],
     )
     )
 
 
@@ -4323,11 +4316,11 @@ class CableView(generic.ObjectView):
             CommentsPanel(),
             CommentsPanel(),
         ],
         ],
         right_panels=[
         right_panels=[
-            Panel(
+            TemplatePanel(
                 title=_('Termination A'),
                 title=_('Termination A'),
                 template_name='dcim/panels/cable_termination_a.html',
                 template_name='dcim/panels/cable_termination_a.html',
             ),
             ),
-            Panel(
+            TemplatePanel(
                 title=_('Termination B'),
                 title=_('Termination B'),
                 template_name='dcim/panels/cable_termination_b.html',
                 template_name='dcim/panels/cable_termination_b.html',
             ),
             ),

+ 2 - 7
netbox/extras/ui/panels.py

@@ -1,5 +1,4 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.template.loader import render_to_string
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from netbox.ui import actions, attrs, panels
 from netbox.ui import actions, attrs, panels
@@ -65,12 +64,8 @@ class CustomFieldsPanel(panels.ObjectPanel):
             'custom_fields': obj.get_custom_fields_by_group(),
             '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):
 class ImageAttachmentsPanel(panels.ObjectsTablePanel):

+ 12 - 3
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
     Renders a VRF reference, displaying 'Global' when no VRF is assigned. Optionally includes
     the route distinguisher (RD).
     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'
     template_name = 'ipam/attrs/vrf.html'
 
 
@@ -14,11 +17,17 @@ class VRFDisplayAttr(attrs.ObjectAttribute):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
         self.show_rd = show_rd
         self.show_rd = show_rd
 
 
+    def get_context(self, obj, attr, value, context):
+        return {
+            'show_rd': self.show_rd,
+        }
+
     def render(self, obj, context):
     def render(self, obj, context):
+        name = context['name']
         value = self.get_value(obj)
         value = self.get_value(obj)
+
         return render_to_string(self.template_name, {
         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,
             'value': value,
-            'show_rd': self.show_rd,
         })
         })

+ 2 - 4
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')
         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):
 class ServiceTemplatePanel(panels.ObjectAttributesPanel):

+ 15 - 0
netbox/ipam/views.py

@@ -16,6 +16,7 @@ from netbox.ui.panels import (
     CommentsPanel,
     CommentsPanel,
     ContextTablePanel,
     ContextTablePanel,
     ObjectsTablePanel,
     ObjectsTablePanel,
+    PluginContentPanel,
     RelatedObjectsPanel,
     RelatedObjectsPanel,
     TemplatePanel,
     TemplatePanel,
 )
 )
@@ -55,11 +56,13 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView):
             layout.Column(
             layout.Column(
                 panels.VRFPanel(),
                 panels.VRFPanel(),
                 TagsPanel(),
                 TagsPanel(),
+                PluginContentPanel('left_page'),
             ),
             ),
             layout.Column(
             layout.Column(
                 RelatedObjectsPanel(),
                 RelatedObjectsPanel(),
                 CustomFieldsPanel(),
                 CustomFieldsPanel(),
                 CommentsPanel(),
                 CommentsPanel(),
+                PluginContentPanel('right_page'),
             ),
             ),
         ),
         ),
         layout.Row(
         layout.Row(
@@ -70,6 +73,11 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView):
                 ContextTablePanel('export_targets_table', title=_('Export route targets')),
                 ContextTablePanel('export_targets_table', title=_('Export route targets')),
             ),
             ),
         ),
         ),
+        layout.Row(
+            layout.Column(
+                PluginContentPanel('full_width_page'),
+            ),
+        ),
     )
     )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
@@ -169,10 +177,12 @@ class RouteTargetView(generic.ObjectView):
             layout.Column(
             layout.Column(
                 panels.RouteTargetPanel(),
                 panels.RouteTargetPanel(),
                 TagsPanel(),
                 TagsPanel(),
+                PluginContentPanel('left_page'),
             ),
             ),
             layout.Column(
             layout.Column(
                 CustomFieldsPanel(),
                 CustomFieldsPanel(),
                 CommentsPanel(),
                 CommentsPanel(),
+                PluginContentPanel('right_page'),
             ),
             ),
         ),
         ),
         layout.Row(
         layout.Row(
@@ -207,6 +217,11 @@ class RouteTargetView(generic.ObjectView):
                 ),
                 ),
             ),
             ),
         ),
         ),
+        layout.Row(
+            layout.Column(
+                PluginContentPanel('full_width_page'),
+            ),
+        ),
     )
     )
 
 
 
 

+ 180 - 3
netbox/netbox/tests/test_ui.py

@@ -1,3 +1,5 @@
+from types import SimpleNamespace
+
 from django.test import TestCase
 from django.test import TestCase
 
 
 from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
 from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
@@ -76,7 +78,7 @@ class ChoiceAttrTest(TestCase):
             self.termination.get_role_display(),
             self.termination.get_role_display(),
         )
         )
         self.assertEqual(
         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()},
             {'bg_color': self.termination.get_role_color()},
         )
         )
 
 
@@ -88,7 +90,7 @@ class ChoiceAttrTest(TestCase):
             self.termination.interface.get_type_display(),
             self.termination.interface.get_type_display(),
         )
         )
         self.assertEqual(
         self.assertEqual(
-            attr.get_context(self.termination, {}),
+            attr.get_context(self.termination, 'interface.type', attr.get_value(self.termination), {}),
             {'bg_color': None},
             {'bg_color': None},
         )
         )
 
 
@@ -100,7 +102,9 @@ class ChoiceAttrTest(TestCase):
             self.termination.virtual_circuit.get_status_display(),
             self.termination.virtual_circuit.get_status_display(),
         )
         )
         self.assertEqual(
         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()},
             {'bg_color': self.termination.virtual_circuit.get_status_color()},
         )
         )
 
 
@@ -213,3 +217,176 @@ class RelatedObjectListAttrTest(TestCase):
         self.assertInHTML('<li>IKE Proposal 2</li>', rendered)
         self.assertInHTML('<li>IKE Proposal 2</li>', rendered)
         self.assertNotIn('IKE Proposal 3', rendered)
         self.assertNotIn('IKE Proposal 3', rendered)
         self.assertIn('…', 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')

+ 12 - 14
netbox/netbox/ui/actions.py

@@ -59,7 +59,7 @@ class PanelAction:
         """
         """
         # Enforce permissions
         # Enforce permissions
         user = context['request'].user
         user = context['request'].user
-        if not user.has_perms(self.permissions):
+        if self.permissions and not user.has_perms(self.permissions):
             return ''
             return ''
 
 
         return render_to_string(self.template_name, self.get_context(context))
         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
         url_params (dict): A dictionary of arbitrary URL parameters to append to the resolved URL
     """
     """
     def __init__(self, model, url_params=None, **kwargs):
     def __init__(self, model, url_params=None, **kwargs):
-        # Resolve the model class from its app.name label
+        # Resolve the model from its label
+        if '.' not in model:
+            raise ValueError(f"Invalid model label: {model}")
         try:
         try:
-            app_label, model_name = model.split('.')
-            model = apps.get_model(app_label, model_name)
-        except (ValueError, LookupError):
+            self.model = apps.get_model(model)
+        except LookupError:
             raise ValueError(f"Invalid model label: {model}")
             raise ValueError(f"Invalid model label: {model}")
-        view_name = get_viewname(model, 'add')
 
 
         kwargs.setdefault('label', _('Add'))
         kwargs.setdefault('label', _('Add'))
         kwargs.setdefault('button_icon', 'plus-thick')
         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):
 class CopyContent(PanelAction):
@@ -148,10 +148,8 @@ class CopyContent(PanelAction):
         super().__init__(**kwargs)
         super().__init__(**kwargs)
         self.target_id = target_id
         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,
             'target_id': self.target_id,
-            'label': self.label,
-            'button_class': self.button_class,
-            'button_icon': self.button_icon,
-        })
+        }

+ 59 - 50
netbox/netbox/ui/attrs.py

@@ -29,10 +29,26 @@ PLACEHOLDER_HTML = '<span class="text-muted">&mdash;</span>'
 
 
 IMAGE_DECODING_CHOICES = ('auto', 'async', 'sync')
 IMAGE_DECODING_CHOICES = ('auto', 'async', 'sync')
 
 
+
 #
 #
-# Attributes
+# 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:
 class ObjectAttribute:
     """
     """
@@ -64,17 +80,20 @@ class ObjectAttribute:
         """
         """
         return resolve_attr_path(obj, self.accessor)
         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.
         Return any additional template context used to render the attribute value.
 
 
         Parameters:
         Parameters:
             obj (object): The object for which the attribute is being rendered
             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 {}
         return {}
 
 
     def render(self, obj, context):
     def render(self, obj, context):
+        name = context['name']
         value = self.get_value(obj)
         value = self.get_value(obj)
 
 
         # If the value is empty, render a placeholder
         # If the value is empty, render a placeholder
@@ -82,8 +101,8 @@ class ObjectAttribute:
             return self.placeholder
             return self.placeholder
 
 
         return render_to_string(self.template_name, {
         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,
             'value': value,
         })
         })
 
 
@@ -112,7 +131,7 @@ class TextAttr(ObjectAttribute):
             return self.format_string.format(value)
             return self.format_string.format(value)
         return value
         return value
 
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         return {
         return {
             'style': self.style,
             'style': self.style,
             'copy_button': self.copy_button,
             'copy_button': self.copy_button,
@@ -134,7 +153,7 @@ class NumericAttr(ObjectAttribute):
         self.unit_accessor = unit_accessor
         self.unit_accessor = unit_accessor
         self.copy_button = copy_button
         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
         unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None
         return {
         return {
             'unit': unit,
             'unit': unit,
@@ -172,7 +191,7 @@ class ChoiceAttr(ObjectAttribute):
 
 
         return resolve_attr_path(target, field_name)
         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)
         target, field_name = self._resolve_target(obj)
         if target is None:
         if target is None:
             return {'bg_color': None}
             return {'bg_color': None}
@@ -241,7 +260,7 @@ class ImageAttr(ObjectAttribute):
             decoding = 'async'
             decoding = 'async'
         self.decoding = decoding
         self.decoding = decoding
 
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         return {
         return {
             'decoding': self.decoding,
             'decoding': self.decoding,
             'load_lazy': self.load_lazy,
             'load_lazy': self.load_lazy,
@@ -264,8 +283,7 @@ class RelatedObjectAttr(ObjectAttribute):
         self.linkify = linkify
         self.linkify = linkify
         self.grouped_by = grouped_by
         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
         group = getattr(value, self.grouped_by, None) if self.grouped_by else None
         return {
         return {
             'linkify': self.linkify,
             'linkify': self.linkify,
@@ -300,14 +318,13 @@ class RelatedObjectListAttr(RelatedObjectAttr):
         self.max_items = max_items
         self.max_items = max_items
         self.overflow_indicator = overflow_indicator
         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.
         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
         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.
         and has_more indicates whether additional items exist beyond the max_items limit.
         """
         """
-        items = resolve_attr_path(obj, self.accessor)
         if items is None:
         if items is None:
             return [], False
             return [], False
 
 
@@ -322,8 +339,8 @@ class RelatedObjectListAttr(RelatedObjectAttr):
 
 
         return items[:self.max_items], has_more
         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 {
         return {
             'linkify': self.linkify,
             'linkify': self.linkify,
@@ -338,14 +355,15 @@ class RelatedObjectListAttr(RelatedObjectAttr):
         }
         }
 
 
     def render(self, obj, context):
     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']:
         if not context_data['items']:
             return self.placeholder
             return self.placeholder
 
 
         return render_to_string(self.template_name, {
         return render_to_string(self.template_name, {
-            'name': context.get('name'),
+            'name': name,
             **context_data,
             **context_data,
         })
         })
 
 
@@ -366,11 +384,12 @@ class NestedObjectAttr(ObjectAttribute):
         self.linkify = linkify
         self.linkify = linkify
         self.max_depth = max_depth
         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 {
         return {
             'nodes': nodes,
             'nodes': nodes,
             'linkify': self.linkify,
             'linkify': self.linkify,
@@ -394,40 +413,35 @@ class GenericForeignKeyAttr(ObjectAttribute):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
         self.linkify = linkify
         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 {
         return {
             'content_type': content_type,
             'content_type': content_type,
             'linkify': self.linkify,
             'linkify': self.linkify,
         }
         }
 
 
 
 
-class AddressAttr(ObjectAttribute):
+class AddressAttr(MapURLMixin, ObjectAttribute):
     """
     """
     A physical or mailing address.
     A physical or mailing address.
 
 
     Parameters:
     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'
     template_name = 'ui/attrs/address.html'
 
 
     def __init__(self, *args, map_url=True, **kwargs):
     def __init__(self, *args, map_url=True, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
-        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
-
-    def get_context(self, obj, context):
+        self._map_url = map_url
+
+    def get_context(self, obj, attr, value, context):
         return {
         return {
             'map_url': self.map_url,
             'map_url': self.map_url,
         }
         }
 
 
 
 
-class GPSCoordinatesAttr(ObjectAttribute):
+class GPSCoordinatesAttr(MapURLMixin, ObjectAttribute):
     """
     """
     A GPS coordinates pair comprising latitude and longitude values.
     A GPS coordinates pair comprising latitude and longitude values.
 
 
@@ -440,24 +454,18 @@ class GPSCoordinatesAttr(ObjectAttribute):
     label = _('GPS coordinates')
     label = _('GPS coordinates')
 
 
     def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs):
     def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs):
-        super().__init__(accessor=None, **kwargs)
+        super().__init__(accessor=latitude_attr, **kwargs)
         self.latitude_attr = latitude_attr
         self.latitude_attr = latitude_attr
         self.longitude_attr = longitude_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
-
-    def render(self, obj, context=None):
-        context = context or {}
+        self._map_url = map_url
+
+    def render(self, obj, context):
         latitude = resolve_attr_path(obj, self.latitude_attr)
         latitude = resolve_attr_path(obj, self.latitude_attr)
         longitude = resolve_attr_path(obj, self.longitude_attr)
         longitude = resolve_attr_path(obj, self.longitude_attr)
         if latitude is None or longitude is None:
         if latitude is None or longitude is None:
             return self.placeholder
             return self.placeholder
         return render_to_string(self.template_name, {
         return render_to_string(self.template_name, {
-            **context,
+            'name': context['name'],
             'latitude': latitude,
             'latitude': latitude,
             'longitude': longitude,
             'longitude': longitude,
             'map_url': self.map_url,
             'map_url': self.map_url,
@@ -478,7 +486,7 @@ class DateTimeAttr(ObjectAttribute):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
         self.spec = spec
         self.spec = spec
 
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         return {
         return {
             'spec': self.spec,
             'spec': self.spec,
         }
         }
@@ -504,8 +512,9 @@ class TemplatedAttr(ObjectAttribute):
         self.template_name = template_name
         self.template_name = template_name
         self.context = context or {}
         self.context = context or {}
 
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         return {
         return {
+            **context,
             **self.context,
             **self.context,
             'object': obj,
             'object': obj,
         }
         }

+ 29 - 4
netbox/netbox/ui/layout.py

@@ -21,10 +21,16 @@ class Layout:
     """
     """
     def __init__(self, *rows):
     def __init__(self, *rows):
         for i, row in enumerate(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)}.")
                 raise TypeError(f"Row {i} must be a Row instance, not {type(row)}.")
         self.rows = rows
         self.rows = rows
 
 
+    def __iter__(self):
+        return iter(self.rows)
+
+    def __repr__(self):
+        return f"Layout({len(self.rows)} rows)"
+
 
 
 class Row:
 class Row:
     """
     """
@@ -35,10 +41,16 @@ class Row:
     """
     """
     def __init__(self, *columns):
     def __init__(self, *columns):
         for i, column in enumerate(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)}.")
                 raise TypeError(f"Column {i} must be a Column instance, not {type(column)}.")
         self.columns = columns
         self.columns = columns
 
 
+    def __iter__(self):
+        return iter(self.columns)
+
+    def __repr__(self):
+        return f"Row({len(self.columns)} columns)"
+
 
 
 class Column:
 class Column:
     """
     """
@@ -46,12 +58,25 @@ class Column:
 
 
     Parameters:
     Parameters:
         *panels: One or more Panel instances
         *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):
         for i, panel in enumerate(panels):
             if not isinstance(panel, Panel):
             if not isinstance(panel, Panel):
                 raise TypeError(f"Panel {i} must be an instance of a Panel, not {type(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.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.
     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.
     views in NetBox utilize this layout.
 
 
     ```
     ```

+ 64 - 36
netbox/netbox/ui/panels.py

@@ -45,18 +45,17 @@ class Panel:
     Parameters:
     Parameters:
         title (str): The human-friendly title of the panel
         title (str): The human-friendly title of the panel
         actions (list): An iterable of PanelActions to include in the panel header
         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
     template_name = None
     title = None
     title = None
     actions = 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:
         if title is not None:
             self.title = title
             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):
     def get_context(self, context):
         """
         """
@@ -74,6 +73,15 @@ class Panel:
             'panel_class': self.__class__.__name__,
             '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):
     def render(self, context):
         """
         """
         Render the panel as HTML.
         Render the panel as HTML.
@@ -81,7 +89,10 @@ class Panel:
         Parameters:
         Parameters:
             context (dict): The template context
             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):
     def get_context(self, context):
         obj = resolve_attr_path(context, self.accessor)
         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 {
         return {
             **super().get_context(context),
             **super().get_context(context),
-            'title': self.title or title(obj._meta.verbose_name),
+            'title': title_,
             'object': obj,
             'object': obj,
         }
         }
 
 
@@ -187,7 +204,7 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
             'attrs': [
             'attrs': [
                 {
                 {
                     'label': attr.label or self._name_to_label(name),
                     '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
                 } for name, attr in self._attrs.items() if name in attr_names
             ],
             ],
         }
         }
@@ -225,9 +242,10 @@ class CommentsPanel(ObjectPanel):
         self.field_name = field_name
         self.field_name = field_name
 
 
     def get_context(self, context):
     def get_context(self, context):
+        ctx = super().get_context(context)
         return {
         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}'))
             self.actions.append(CopyContent(f'panel_{field_name}'))
 
 
     def get_context(self, context):
     def get_context(self, context):
+        ctx = super().get_context(context)
         return {
         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,
             '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):
     def __init__(self, model, filters=None, include_columns=None, exclude_columns=None, **kwargs):
         super().__init__(**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}")
             raise ValueError(f"Invalid model label: {model}")
-
+        self.model_label = model
         self.filters = filters or {}
         self.filters = filters or {}
         self.include_columns = include_columns or []
         self.include_columns = include_columns or []
         self.exclude_columns = exclude_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):
     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 = {
         url_params = {
             k: v(context) if callable(v) else v for k, v in self.filters.items()
             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)
             url_params['exclude_columns'] = ','.join(self.exclude_columns)
         return {
         return {
             **super().get_context(context),
             **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),
             'url_params': dict_to_querydict(url_params),
         }
         }
 
 
@@ -330,12 +355,17 @@ class TemplatePanel(Panel):
     Parameters:
     Parameters:
         template_name (str): The name of the template to render
         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):
 class TextCodePanel(ObjectPanel):
@@ -350,10 +380,11 @@ class TextCodePanel(ObjectPanel):
         self.show_sync_warning = show_sync_warning
         self.show_sync_warning = show_sync_warning
 
 
     def get_context(self, context):
     def get_context(self, context):
+        ctx = super().get_context(context)
         return {
         return {
-            **super().get_context(context),
+            **ctx,
             'show_sync_warning': self.show_sync_warning,
             '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
         self.method = method
 
 
     def render(self, context):
     def render(self, context):
+        # Override the default render() method to simply embed rendered plugin content
         obj = context.get('object')
         obj = context.get('object')
         return _get_registered_content(obj, self.method, context)
         return _get_registered_content(obj, self.method, context)
 
 
@@ -399,14 +431,10 @@ class ContextTablePanel(ObjectPanel):
         return context.get(self.table)
         return context.get(self.table)
 
 
     def get_context(self, context):
     def get_context(self, context):
-        table = self._resolve_table(context)
         return {
         return {
             **super().get_context(context),
             **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

+ 48 - 0
netbox/templates/circuits/circuit_termination/attrs/connection.html

@@ -0,0 +1,48 @@
+{% load helpers i18n %}
+{% if object.mark_connected %}
+  <div>
+    <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
+    <span class="text-muted">{% trans "Marked as connected" %}</span>
+  </div>
+{% elif object.cable %}
+  <div>
+    <a class="d-block d-md-inline" href="{{ object.cable.get_absolute_url }}">{{ object.cable }}</a> {% trans "to" %}
+    {% for peer in object.link_peers %}
+      {% if peer.device %}
+        {{ peer.device|linkify }}<br/>
+      {% elif peer.circuit %}
+        {{ peer.circuit|linkify }}<br/>
+      {% endif %}
+      {{ peer|linkify }}{% if not forloop.last %},{% endif %}
+    {% endfor %}
+  </div>
+  <div class="mt-1">
+    <a href="{% url 'circuits:circuittermination_trace' pk=object.pk %}" class="btn btn-sm btn-primary" title="{% trans "Trace" %}">
+      <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> {% trans "Trace" %}
+    </a>
+    {% if perms.dcim.change_cable %}
+      <a href="{% url 'dcim:cable_edit' pk=object.cable.pk %}?return_url={{ object.circuit.get_absolute_url }}" title="{% trans "Edit cable" %}" class="btn btn-sm btn-warning">
+        <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> {% trans "Edit" %}
+      </a>
+    {% endif %}
+    {% if perms.dcim.delete_cable %}
+      <a href="{% url 'dcim:cable_delete' pk=object.cable.pk %}?return_url={{ object.circuit.get_absolute_url }}" title="{% trans "Remove cable" %}" class="btn btn-sm btn-danger">
+        <i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {% trans "Disconnect" %}
+      </a>
+    {% endif %}
+  </div>
+{% elif perms.dcim.add_cable %}
+  <div class="dropdown">
+    <button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+      <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
+    </button>
+    <ul class="dropdown-menu">
+      <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">{% trans "Interface" %}</a></li>
+      <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">{% trans "Front Port" %}</a></li>
+      <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">{% trans "Rear Port" %}</a></li>
+      <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a></li>
+    </ul>
+  </div>
+{% else %}
+  {{ ''|placeholder }}
+{% endif %}

+ 8 - 0
netbox/templates/circuits/circuit_termination/attrs/speed.html

@@ -0,0 +1,8 @@
+{% load helpers i18n %}
+{% if object.upstream_speed %}
+  <i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ value|humanize_speed }}
+  <i class="mdi mdi-slash-forward"></i>
+  <i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ object.upstream_speed|humanize_speed }}
+{% else %}
+  {{ value|humanize_speed }}
+{% endif %}

+ 0 - 16
netbox/templates/circuits/panels/circuit_termination.html

@@ -1,16 +0,0 @@
-{% extends "ui/panels/_base.html" %}
-{% load helpers i18n %}
-
-{% block panel_content %}
-  <table class="table table-hover attr-table">
-    <tr>
-      <th scope="row">{% trans "Circuit" %}</th>
-      <td>{{ object.circuit|linkify|placeholder }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Provider" %}</th>
-      <td>{{ object.circuit.provider|linkify|placeholder }}</td>
-    </tr>
-    {% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
-  </table>
-{% endblock panel_content %}

+ 0 - 21
netbox/templates/dcim/panels/installed_device.html

@@ -1,21 +0,0 @@
-{% extends "ui/panels/_base.html" %}
-{% load helpers i18n %}
-
-{% block panel_content %}
-  {% if object.installed_device %}
-    {% with device=object.installed_device %}
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Device" %}</th>
-          <td>{{ device|linkify }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Device type" %}</th>
-          <td>{{ device.device_type }}</td>
-        </tr>
-      </table>
-    {% endwith %}
-  {% else %}
-    <div class="card-body text-muted">{% trans "None" %}</div>
-  {% endif %}
-{% endblock panel_content %}

+ 0 - 33
netbox/templates/dcim/panels/installed_module.html

@@ -1,33 +0,0 @@
-{% extends "ui/panels/_base.html" %}
-{% load helpers i18n %}
-
-{% block panel_content %}
-  {% if object.installed_module %}
-    {% with module=object.installed_module %}
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Module" %}</th>
-          <td>{{ module|linkify }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Manufacturer" %}</th>
-          <td>{{ module.module_type.manufacturer|linkify }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Module type" %}</th>
-          <td>{{ module.module_type|linkify }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Serial number" %}</th>
-          <td class="font-monospace">{{ module.serial|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Asset tag" %}</th>
-          <td class="font-monospace">{{ module.asset_tag|placeholder }}</td>
-        </tr>
-      </table>
-    {% endwith %}
-  {% else %}
-    <div class="card-body text-muted">{% trans "None" %}</div>
-  {% endif %}
-{% endblock panel_content %}

+ 4 - 4
netbox/templates/generic/object.html

@@ -124,11 +124,11 @@ Context:
 
 
 {% block content %}
 {% block content %}
   {# Render panel layout declared on view class #}
   {# Render panel layout declared on view class #}
-  {% for row in layout.rows %}
+  {% for row in layout %}
     <div class="row">
     <div class="row">
-      {% for column in row.columns %}
-        <div class="col">
-          {% for panel in column.panels %}
+      {% for column in row %}
+        <div class="col-12 col-md{% if column.width %}-{{ column.width }}{% endif %}">
+          {% for panel in column %}
             {% render panel %}
             {% render panel %}
           {% endfor %}
           {% endfor %}
         </div>
         </div>

+ 1 - 1
netbox/templates/ui/attrs/numeric.html

@@ -1,5 +1,5 @@
 {% load i18n %}
 {% load i18n %}
-<span{% if style %} class="{{ style }}"{% endif %}>
+<span>
   <span{% if name %} id="attr_{{ name }}"{% endif %}>{{ value }}</span>
   <span{% if name %} id="attr_{{ name }}"{% endif %}>{{ value }}</span>
   {% if unit %}
   {% if unit %}
     {{ unit|lower }}
     {{ unit|lower }}

+ 12 - 0
netbox/templates/ui/exception.html

@@ -0,0 +1,12 @@
+{% load i18n %}
+<div class="alert alert-danger" role="alert">
+  <div class="alert-icon">
+    <i class="mdi mdi-alert"></i>
+  </div>
+  <div>
+    <p>
+      {% blocktrans %}An error occurred when loading content from plugin {{ plugin }}:{% endblocktrans %}
+    </p>
+    <pre class="p-0">{{ exception }}</pre>
+  </div>
+</div>

+ 12 - 10
netbox/templates/ui/panels/_base.html

@@ -1,15 +1,17 @@
 <!-- begin {{ panel_class|default:"panel" }} -->
 <!-- begin {{ panel_class|default:"panel" }} -->
 <div class="card">
 <div class="card">
-  <h2 class="card-header">
-    {{ title }}
-    {% if actions %}
-      <div class="card-actions">
-        {% for action in actions %}
-          {% render action %}
-        {% endfor %}
-      </div>
-    {% endif %}
-  </h2>
+  {% if title or actions %}
+    <h2 class="card-header">
+      {{ title|default:"" }}
+      {% if actions %}
+        <div class="card-actions">
+          {% for action in actions %}
+            {% render action %}
+          {% endfor %}
+        </div>
+      {% endif %}
+    </h2>
+  {% endif %}
   {% block panel_content %}{% endblock %}
   {% block panel_content %}{% endblock %}
 </div>
 </div>
 <!-- end {{ panel_class|default:"panel" }} -->
 <!-- end {{ panel_class|default:"panel" }} -->

+ 0 - 34
netbox/templates/vpn/panels/ipsecprofile_ike_policy.html

@@ -1,34 +0,0 @@
-{% load helpers %}
-{% load i18n %}
-
-<div class="card">
-  <h2 class="card-header">{% trans "IKE Policy" %}</h2>
-  <table class="table table-hover attr-table">
-    <tr>
-      <th scope="row">{% trans "Name" %}</th>
-      <td>{{ object.ike_policy|linkify }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Description" %}</th>
-      <td>{{ object.ike_policy.description|placeholder }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Version" %}</th>
-      <td>{{ object.ike_policy.get_version_display }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Mode" %}</th>
-      <td>{{ object.ike_policy.get_mode_display }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Proposals" %}</th>
-      <td>
-        <ul class="list-unstyled mb-0">
-          {% for proposal in object.ike_policy.proposals.all %}
-            <li><a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a></li>
-          {% endfor %}
-        </ul>
-      </td>
-    </tr>
-  </table>
-</div>

+ 0 - 30
netbox/templates/vpn/panels/ipsecprofile_ipsec_policy.html

@@ -1,30 +0,0 @@
-{% load helpers %}
-{% load i18n %}
-
-<div class="card">
-  <h2 class="card-header">{% trans "IPSec Policy" %}</h2>
-  <table class="table table-hover attr-table">
-    <tr>
-      <th scope="row">{% trans "Name" %}</th>
-      <td>{{ object.ipsec_policy|linkify }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Description" %}</th>
-      <td>{{ object.ipsec_policy.description|placeholder }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Proposals" %}</th>
-      <td>
-        <ul class="list-unstyled mb-0">
-          {% for proposal in object.ipsec_policy.proposals.all %}
-            <li><a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a></li>
-          {% endfor %}
-        </ul>
-      </td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "PFS Group" %}</th>
-      <td>{{ object.ipsec_policy.get_pfs_group_display }}</td>
-    </tr>
-  </table>
-</div>

+ 6 - 2
netbox/utilities/templatetags/plugins.py

@@ -1,5 +1,6 @@
 from django import template as template_
 from django import template as template_
 from django.conf import settings
 from django.conf import settings
+from django.template.loader import render_to_string
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 
 
 from netbox.plugins import PluginTemplateExtension
 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, {})
         context['config'] = settings.PLUGINS_CONFIG.get(plugin_name, {})
 
 
         # Call the method to render content
         # 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
         html += content
 
 
     return mark_safe(html)
     return mark_safe(html)

+ 17 - 0
netbox/vpn/ui/panels.py

@@ -71,6 +71,23 @@ class IPSecProfilePanel(panels.ObjectAttributesPanel):
     mode = attrs.ChoiceAttr('mode')
     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):
 class L2VPNPanel(panels.ObjectAttributesPanel):
     name = attrs.TextAttr('name')
     name = attrs.TextAttr('name')
     identifier = attrs.TextAttr('identifier')
     identifier = attrs.TextAttr('identifier')

+ 2 - 3
netbox/vpn/views.py

@@ -10,7 +10,6 @@ from netbox.ui.panels import (
     ObjectsTablePanel,
     ObjectsTablePanel,
     PluginContentPanel,
     PluginContentPanel,
     RelatedObjectsPanel,
     RelatedObjectsPanel,
-    TemplatePanel,
 )
 )
 from netbox.views import generic
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.query import count_related
@@ -591,8 +590,8 @@ class IPSecProfileView(generic.ObjectView):
             CommentsPanel(),
             CommentsPanel(),
         ],
         ],
         right_panels=[
         right_panels=[
-            TemplatePanel('vpn/panels/ipsecprofile_ike_policy.html'),
-            TemplatePanel('vpn/panels/ipsecprofile_ipsec_policy.html'),
+            panels.IPSecProfileIKEPolicyPanel(),
+            panels.IPSecProfileIPSecPolicyPanel(),
         ],
         ],
     )
     )
 
 

+ 3 - 3
netbox/wireless/ui/panels.py

@@ -34,10 +34,10 @@ class WirelessLinkInterfacePanel(panels.ObjectPanel):
         self.title = title
         self.title = title
 
 
     def get_context(self, context):
     def get_context(self, context):
-        obj = context['object']
+        ctx = super().get_context(context)
         return {
         return {
-            **super().get_context(context),
-            'interface': getattr(obj, self.interface_attr),
+            **ctx,
+            'interface': getattr(ctx['object'], self.interface_attr),
         }
         }