Jelajahi Sumber

Misc cleanup

Jeremy Stretch 3 bulan lalu
induk
melakukan
a024012abd

+ 16 - 16
netbox/dcim/ui/panels.py

@@ -7,7 +7,7 @@ class SitePanel(panels.ObjectAttributesPanel):
     region = attrs.NestedObjectAttr('region', linkify=True)
     group = attrs.NestedObjectAttr('group', linkify=True)
     status = attrs.ChoiceAttr('status')
-    tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     facility = attrs.TextAttr('facility')
     description = attrs.TextAttr('description')
     timezone = attrs.TimezoneAttr('time_zone')
@@ -17,9 +17,9 @@ class SitePanel(panels.ObjectAttributesPanel):
 
 
 class LocationPanel(panels.NestedGroupObjectPanel):
-    site = attrs.ObjectAttr('site', linkify=True, grouped_by='group')
+    site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
     status = attrs.ChoiceAttr('status')
-    tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     facility = attrs.TextAttr('facility')
 
 
@@ -40,13 +40,13 @@ class RackNumberingPanel(panels.ObjectAttributesPanel):
 
 class RackPanel(panels.ObjectAttributesPanel):
     region = attrs.NestedObjectAttr('site.region', linkify=True)
-    site = attrs.ObjectAttr('site', linkify=True, grouped_by='group')
+    site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
     location = attrs.NestedObjectAttr('location', linkify=True)
     facility = attrs.TextAttr('facility')
-    tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     status = attrs.ChoiceAttr('status')
-    rack_type = attrs.ObjectAttr('rack_type', linkify=True, grouped_by='manufacturer')
-    role = attrs.ObjectAttr('role', linkify=True)
+    rack_type = attrs.RelatedObjectAttr('rack_type', linkify=True, grouped_by='manufacturer')
+    role = attrs.RelatedObjectAttr('role', linkify=True)
     description = attrs.TextAttr('description')
     serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
     asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
@@ -66,26 +66,26 @@ class RackRolePanel(panels.OrganizationalObjectPanel):
 
 
 class RackTypePanel(panels.ObjectAttributesPanel):
-    manufacturer = attrs.ObjectAttr('manufacturer', linkify=True)
+    manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
     model = attrs.TextAttr('model')
     description = attrs.TextAttr('description')
 
 
 class DevicePanel(panels.ObjectAttributesPanel):
     region = attrs.NestedObjectAttr('site.region', linkify=True)
-    site = attrs.ObjectAttr('site', linkify=True, grouped_by='group')
+    site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
     location = attrs.NestedObjectAttr('location', linkify=True)
     rack = attrs.TemplatedAttr('rack', template_name='dcim/device/attrs/rack.html')
-    virtual_chassis = attrs.ObjectAttr('virtual_chassis', linkify=True)
+    virtual_chassis = attrs.RelatedObjectAttr('virtual_chassis', linkify=True)
     parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html')
     gps_coordinates = attrs.GPSCoordinatesAttr()
-    tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group')
-    device_type = attrs.ObjectAttr('device_type', linkify=True, grouped_by='manufacturer')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    device_type = attrs.RelatedObjectAttr('device_type', linkify=True, grouped_by='manufacturer')
     description = attrs.TextAttr('description')
     airflow = attrs.ChoiceAttr('airflow')
     serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
     asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
-    config_template = attrs.ObjectAttr('config_template', linkify=True)
+    config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
 
 
 class DeviceManagementPanel(panels.ObjectAttributesPanel):
@@ -109,7 +109,7 @@ class DeviceManagementPanel(panels.ObjectAttributesPanel):
         label=_('Out-of-band IP'),
         template_name='dcim/device/attrs/ipaddress.html',
     )
-    cluster = attrs.ObjectAttr('cluster', linkify=True)
+    cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
 
 
 class DeviceDimensionsPanel(panels.ObjectAttributesPanel):
@@ -120,10 +120,10 @@ class DeviceDimensionsPanel(panels.ObjectAttributesPanel):
 
 
 class DeviceTypePanel(panels.ObjectAttributesPanel):
-    manufacturer = attrs.ObjectAttr('manufacturer', linkify=True)
+    manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
     model = attrs.TextAttr('model')
     part_number = attrs.TextAttr('part_number')
-    default_platform = attrs.ObjectAttr('default_platform', linkify=True)
+    default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True)
     description = attrs.TextAttr('description')
     height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height'))
     exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization')

+ 6 - 5
netbox/extras/ui/panels.py

@@ -3,6 +3,7 @@ from django.template.loader import render_to_string
 from django.utils.translation import gettext_lazy as _
 
 from netbox.ui import actions, panels
+from utilities.data import resolve_attr_path
 
 __all__ = (
     'CustomFieldsPanel',
@@ -13,13 +14,13 @@ __all__ = (
 
 class CustomFieldsPanel(panels.ObjectPanel):
     """
-    Render a panel showing the value of all custom fields defined on the object.
+    A panel showing the value of all custom fields defined on an object.
     """
     template_name = 'extras/panels/custom_fields.html'
     title = _('Custom Fields')
 
     def get_context(self, context):
-        obj = context['object']
+        obj = resolve_attr_path(context, self.accessor)
         return {
             **super().get_context(context),
             'custom_fields': obj.get_custom_fields_by_group(),
@@ -35,7 +36,7 @@ class CustomFieldsPanel(panels.ObjectPanel):
 
 class ImageAttachmentsPanel(panels.ObjectsTablePanel):
     """
-    Render a table listing all images attached to the object.
+    A panel showing all images attached to the object.
     """
     actions = [
         actions.AddObject(
@@ -55,7 +56,7 @@ class ImageAttachmentsPanel(panels.ObjectsTablePanel):
 
 class TagsPanel(panels.ObjectPanel):
     """
-    Render a panel showing the tags assigned to the object.
+    A panel showing the tags assigned to the object.
     """
     template_name = 'extras/panels/tags.html'
     title = _('Tags')
@@ -63,5 +64,5 @@ class TagsPanel(panels.ObjectPanel):
     def get_context(self, context):
         return {
             **super().get_context(context),
-            'object': context['object'],
+            'object': resolve_attr_path(context, self.accessor),
         }

+ 153 - 11
netbox/netbox/ui/attrs.py

@@ -5,6 +5,25 @@ from django.utils.translation import gettext_lazy as _
 from netbox.config import get_config
 from utilities.data import resolve_attr_path
 
+__all__ = (
+    'AddressAttr',
+    'BooleanAttr',
+    'ColorAttr',
+    'ChoiceAttr',
+    'GPSCoordinatesAttr',
+    'ImageAttr',
+    'NestedObjectAttr',
+    'NumericAttr',
+    'ObjectAttribute',
+    'RelatedObjectAttr',
+    'TemplatedAttr',
+    'TextAttr',
+    'TimezoneAttr',
+    'UtilizationAttr',
+)
+
+PLACEHOLDER_HTML = '<span class="text-muted">&mdash;</span>'
+
 
 #
 # Attributes
@@ -21,20 +40,17 @@ class ObjectAttribute:
     """
     template_name = None
     label = None
-    placeholder = mark_safe('<span class="text-muted">&mdash;</span>')
+    placeholder = mark_safe(PLACEHOLDER_HTML)
 
-    def __init__(self, accessor, label=None, template_name=None):
+    def __init__(self, accessor, label=None):
         """
         Instantiate a new ObjectAttribute.
 
         Parameters:
              accessor: The dotted path to the attribute being rendered (e.g. "site.region.name")
              label: Human-friendly label for the rendered attribute
-             template_name: The name of the template to render
         """
         self.accessor = accessor
-        if template_name is not None:
-            self.template_name = template_name
         if label is not None:
             self.label = label
 
@@ -53,25 +69,42 @@ class ObjectAttribute:
 
         Parameters:
             obj: The object for which the attribute is being rendered
-            context: The template context
+            context: The root template context
         """
         return {}
 
     def render(self, obj, context):
         value = self.get_value(obj)
+
+        # If the value is empty, render a placeholder
         if value in (None, ''):
             return self.placeholder
-        context = self.get_context(obj, context)
+
         return render_to_string(self.template_name, {
-            **context,
+            **self.get_context(obj, context),
+            'name': context['name'],
             'value': value,
         })
 
 
 class TextAttr(ObjectAttribute):
+    """
+    A text attribute.
+    """
     template_name = 'ui/attrs/text.html'
 
     def __init__(self, *args, style=None, format_string=None, copy_button=False, **kwargs):
+        """
+        Instantiate a new TextAttr.
+
+        Parameters:
+             accessor: The dotted path to the attribute being rendered (e.g. "site.region.name")
+             label: Human-friendly label for the rendered attribute
+             template_name: The name of the template to render
+             style: CSS class to apply to the rendered attribute
+             format_string: If specified, the value will be formatted using this string when rendering
+             copy_button: Set to True to include a copy-to-clipboard button
+        """
         super().__init__(*args, **kwargs)
         self.style = style
         self.format_string = format_string
@@ -81,7 +114,7 @@ class TextAttr(ObjectAttribute):
         value = resolve_attr_path(obj, self.accessor)
         # Apply format string (if any)
         if value and self.format_string:
-            value = self.format_string.format(value)
+            return self.format_string.format(value)
         return value
 
     def get_context(self, obj, context):
@@ -92,9 +125,22 @@ class TextAttr(ObjectAttribute):
 
 
 class NumericAttr(ObjectAttribute):
+    """
+    An integer or float attribute.
+    """
     template_name = 'ui/attrs/numeric.html'
 
     def __init__(self, *args, unit_accessor=None, copy_button=False, **kwargs):
+        """
+        Instantiate a new NumericAttr.
+
+        Parameters:
+             accessor: The dotted path to the attribute being rendered (e.g. "site.region.name")
+             unit_accessor: Accessor for the unit of measurement to display alongside the value (if any)
+             copy_button: Set to True to include a copy-to-clipboard button
+             label: Human-friendly label for the rendered attribute
+             template_name: The name of the template to render
+        """
         super().__init__(*args, **kwargs)
         self.unit_accessor = unit_accessor
         self.copy_button = copy_button
@@ -108,6 +154,12 @@ class NumericAttr(ObjectAttribute):
 
 
 class ChoiceAttr(ObjectAttribute):
+    """
+    A selection from a set of choices.
+
+    The class calls get_FOO_display() on the object to retrieve the human-friendly choice label. If a get_FOO_color()
+    method exists on the object, it will be used to render a background color for the attribute value.
+    """
     template_name = 'ui/attrs/choice.html'
 
     def get_value(self, obj):
@@ -127,9 +179,21 @@ class ChoiceAttr(ObjectAttribute):
 
 
 class BooleanAttr(ObjectAttribute):
+    """
+    A boolean attribute.
+    """
     template_name = 'ui/attrs/boolean.html'
 
     def __init__(self, *args, display_false=True, **kwargs):
+        """
+        Instantiate a new BooleanAttr.
+
+        Parameters:
+             accessor: The dotted path to the attribute being rendered (e.g. "site.region.name")
+             display_false: If False, a placeholder will be rendered instead of the "False" indication
+             label: Human-friendly label for the rendered attribute
+             template_name: The name of the template to render
+        """
         super().__init__(*args, **kwargs)
         self.display_false = display_false
 
@@ -141,18 +205,38 @@ class BooleanAttr(ObjectAttribute):
 
 
 class ColorAttr(ObjectAttribute):
+    """
+    An RGB color value.
+    """
     template_name = 'ui/attrs/color.html'
     label = _('Color')
 
 
 class ImageAttr(ObjectAttribute):
+    """
+    An attribute representing an image field on the model. Displays the uploaded image.
+    """
     template_name = 'ui/attrs/image.html'
 
 
-class ObjectAttr(ObjectAttribute):
+class RelatedObjectAttr(ObjectAttribute):
+    """
+    An attribute representing a related object.
+    """
     template_name = 'ui/attrs/object.html'
 
     def __init__(self, *args, linkify=None, grouped_by=None, **kwargs):
+        """
+        Instantiate a new RelatedObjectAttr.
+
+        Parameters:
+             accessor: The dotted path to the attribute being rendered (e.g. "site.region.name")
+             linkify: If True, the rendered value will be hyperlinked to the related object's detail view
+             grouped_by: A second-order object to annotate alongside the related object; for example, an attribute
+                representing the dcim.Site model might specify grouped_by="region"
+             label: Human-friendly label for the rendered attribute
+             template_name: The name of the template to render
+        """
         super().__init__(*args, **kwargs)
         self.linkify = linkify
         self.grouped_by = grouped_by
@@ -167,9 +251,23 @@ class ObjectAttr(ObjectAttribute):
 
 
 class NestedObjectAttr(ObjectAttribute):
+    """
+    An attribute representing a related nested object. Similar to `RelatedObjectAttr`, but includes the ancestors of the
+    related object in the rendered output.
+    """
     template_name = 'ui/attrs/nested_object.html'
 
     def __init__(self, *args, linkify=None, max_depth=None, **kwargs):
+        """
+        Instantiate a new NestedObjectAttr. Shows a related object as well as its ancestors.
+
+        Parameters:
+             accessor: The dotted path to the attribute being rendered (e.g. "site.region.name")
+             linkify: If True, the rendered value will be hyperlinked to the related object's detail view
+             max_depth: Maximum number of ancestors to display (default: all)
+             label: Human-friendly label for the rendered attribute
+             template_name: The name of the template to render
+        """
         super().__init__(*args, **kwargs)
         self.linkify = linkify
         self.max_depth = max_depth
@@ -186,9 +284,21 @@ class NestedObjectAttr(ObjectAttribute):
 
 
 class AddressAttr(ObjectAttribute):
+    """
+    A physical or mailing address.
+    """
     template_name = 'ui/attrs/address.html'
 
     def __init__(self, *args, map_url=True, **kwargs):
+        """
+        Instantiate a new AddressAttr.
+
+        Parameters:
+             accessor: The dotted path to the attribute being rendered (e.g. "site.region.name")
+             map_url: If true, the address will render as a hyperlink using settings.MAPS_URL
+             label: Human-friendly label for the rendered attribute
+             template_name: The name of the template to render
+        """
         super().__init__(*args, **kwargs)
         if map_url is True:
             self.map_url = get_config().MAPS_URL
@@ -204,10 +314,23 @@ class AddressAttr(ObjectAttribute):
 
 
 class GPSCoordinatesAttr(ObjectAttribute):
+    """
+    A GPS coordinates pair comprising latitude and longitude values.
+    """
     template_name = 'ui/attrs/gps_coordinates.html'
     label = _('GPS coordinates')
 
     def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs):
+        """
+        Instantiate a new GPSCoordinatesAttr.
+
+        Parameters:
+             latitude_attr: The name of the field containing the latitude value
+             longitude_attr: The name of the field containing the longitude value
+             map_url: If true, the address will render as a hyperlink using settings.MAPS_URL
+             label: Human-friendly label for the rendered attribute
+             template_name: The name of the template to render
+        """
         super().__init__(accessor=None, **kwargs)
         self.latitude_attr = latitude_attr
         self.longitude_attr = longitude_attr
@@ -233,13 +356,29 @@ class GPSCoordinatesAttr(ObjectAttribute):
 
 
 class TimezoneAttr(ObjectAttribute):
+    """
+    A timezone value. Includes the numeric offset from UTC.
+    """
     template_name = 'ui/attrs/timezone.html'
 
 
 class TemplatedAttr(ObjectAttribute):
+    """
+    Renders an attribute using a custom template.
+    """
+    def __init__(self, *args, template_name, context=None, **kwargs):
+        """
+        Instantiate a new TemplatedAttr.
 
-    def __init__(self, *args, context=None, **kwargs):
+        Parameters:
+             accessor: The dotted path to the attribute being rendered (e.g. "site.region.name")
+             template_name: The name of the template to render
+             context: Additional context to pass to the template when rendering
+             label: Human-friendly label for the rendered attribute
+             template_name: The name of the template to render
+        """
         super().__init__(*args, **kwargs)
+        self.template_name = template_name
         self.context = context or {}
 
     def get_context(self, obj, context):
@@ -250,4 +389,7 @@ class TemplatedAttr(ObjectAttribute):
 
 
 class UtilizationAttr(ObjectAttribute):
+    """
+    Renders the value of an attribute as a utilization graph.
+    """
     template_name = 'ui/attrs/utilization.html'

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

@@ -13,7 +13,9 @@ __all__ = (
 #
 
 class Layout:
-
+    """
+    A collection of rows and columns comprising the layout of content within the user interface.
+    """
     def __init__(self, *rows):
         for i, row in enumerate(rows):
             if type(row) is not Row:
@@ -22,7 +24,9 @@ class Layout:
 
 
 class Row:
-
+    """
+    A collection of columns arranged horizontally.
+    """
     def __init__(self, *columns):
         for i, column in enumerate(columns):
             if type(column) is not Column:
@@ -31,7 +35,9 @@ class Row:
 
 
 class Column:
-
+    """
+    A collection of panels arranged vertically.
+    """
     def __init__(self, *panels):
         for i, panel in enumerate(panels):
             if not isinstance(panel, Panel):
@@ -40,12 +46,18 @@ class Column:
 
 
 #
-# Standard layouts
+# Common layouts
 #
 
 class SimpleLayout(Layout):
     """
     A layout with one row of two columns and a second row with one column. Includes registered plugin content.
+
+    +------+------+
+    | col1 | col2 |
+    +------+------+
+    |    col3     |
+    +-------------+
     """
     def __init__(self, left_panels=None, right_panels=None, bottom_panels=None):
         left_panels = left_panels or []

+ 16 - 59
netbox/netbox/ui/panels.py

@@ -1,5 +1,3 @@
-from abc import ABC, ABCMeta
-
 from django.apps import apps
 from django.template.loader import render_to_string
 from django.utils.translation import gettext_lazy as _
@@ -20,9 +18,9 @@ __all__ = (
     'ObjectPanel',
     'ObjectsTablePanel',
     'OrganizationalObjectPanel',
-    'RelatedObjectsPanel',
     'Panel',
     'PluginContentPanel',
+    'RelatedObjectsPanel',
     'TemplatePanel',
 )
 
@@ -31,14 +29,13 @@ __all__ = (
 # Base classes
 #
 
-class Panel(ABC):
+class Panel:
     """
     A block of content rendered within an HTML template.
 
-    Attributes:
-        template_name: The name of the template to render
-        title: The human-friendly title of the panel
-        actions: A list of PanelActions to include in the panel header
+    Panels are arranged within rows and columns, (generally) render as discrete "cards" within the user interface. Each
+    panel has a title and may have one or more actions associated with it, which will be rendered as hyperlinks in the
+    top right corner of the card.
     """
     template_name = None
     title = None
@@ -50,7 +47,7 @@ class Panel(ABC):
 
         Parameters:
             title: The human-friendly title of the panel
-            actions: A list of PanelActions to include in the panel header
+            actions: An iterable of PanelActions to include in the panel header
         """
         if title is not None:
             self.title = title
@@ -95,7 +92,7 @@ class ObjectPanel(Panel):
         Instantiate a new ObjectPanel.
 
         Parameters:
-            accessor: The name of the attribute on the object (default: "object")
+            accessor: The dotted path in context data to the object being rendered (default: "object")
         """
         super().__init__(**kwargs)
 
@@ -103,12 +100,6 @@ class ObjectPanel(Panel):
             self.accessor = accessor
 
     def get_context(self, context):
-        """
-        Return the context data to be used when rendering the panel.
-
-        Parameters:
-            context: The template context
-        """
         obj = resolve_attr_path(context, self.accessor)
         return {
             **super().get_context(context),
@@ -117,7 +108,7 @@ class ObjectPanel(Panel):
         }
 
 
-class ObjectAttributesPanelMeta(ABCMeta):
+class ObjectAttributesPanelMeta(type):
 
     def __new__(mcls, name, bases, namespace, **kwargs):
         declared = {}
@@ -148,9 +139,8 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
     """
     A panel which displays selected attributes of an object.
 
-    Attributes:
-        template_name: The name of the template to render
-        accessor: The name of the attribute on the object
+    Attributes are added to the panel by declaring ObjectAttribute instances in the class body (similar to fields on
+    a Django form). Attributes are displayed in the order they are declared.
     """
     template_name = 'ui/panels/object_attributes.html'
 
@@ -159,7 +149,6 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
         Instantiate a new ObjectPanel.
 
         Parameters:
-            accessor: The name of the attribute on the object
             only: If specified, only attributes in this list will be displayed
             exclude: If specified, attributes in this list will be excluded from display
         """
@@ -181,12 +170,6 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
         return label
 
     def get_context(self, context):
-        """
-        Return the context data to be used when rendering the panel.
-
-        Parameters:
-            context: The template context
-        """
         # Determine which attributes to display in the panel based on only/exclude args
         attr_names = set(self._attrs.keys())
         if self.only:
@@ -209,7 +192,7 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
 
 class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta):
     """
-    An ObjectPanel with attributes common to OrganizationalModels.
+    An ObjectPanel with attributes common to OrganizationalModels. Includes name and description.
     """
     name = attrs.TextAttr('name', label=_('Name'))
     description = attrs.TextAttr('description', label=_('Description'))
@@ -217,7 +200,7 @@ class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttribute
 
 class NestedGroupObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta):
     """
-    An ObjectPanel with attributes common to NestedGroupObjects.
+    An ObjectPanel with attributes common to NestedGroupObjects. Includes the parent object.
     """
     parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True)
 
@@ -234,18 +217,12 @@ class CommentsPanel(ObjectPanel):
         Instantiate a new CommentsPanel.
 
         Parameters:
-            field_name: The name of the comment field on the object
+            field_name: The name of the comment field on the object (default: "comments")
         """
         super().__init__(**kwargs)
         self.field_name = field_name
 
     def get_context(self, context):
-        """
-        Return the context data to be used when rendering the panel.
-
-        Parameters:
-            context: The template context
-        """
         return {
             **super().get_context(context),
             'comments': getattr(context['object'], self.field_name),
@@ -270,17 +247,9 @@ class JSONPanel(ObjectPanel):
         self.field_name = field_name
 
         if copy_button:
-            self.actions.append(
-                CopyContent(f'panel_{field_name}'),
-            )
+            self.actions.append(CopyContent(f'panel_{field_name}'))
 
     def get_context(self, context):
-        """
-        Return the context data to be used when rendering the panel.
-
-        Parameters:
-            context: The template context
-        """
         return {
             **super().get_context(context),
             'data': getattr(context['object'], self.field_name),
@@ -300,12 +269,6 @@ class RelatedObjectsPanel(Panel):
     title = _('Related Objects')
 
     def get_context(self, context):
-        """
-        Return the context data to be used when rendering the panel.
-
-        Parameters:
-            context: The template context
-        """
         return {
             **super().get_context(context),
             'related_models': context.get('related_models'),
@@ -343,12 +306,6 @@ class ObjectsTablePanel(Panel):
             self.title = title(self.model._meta.verbose_name_plural)
 
     def get_context(self, context):
-        """
-        Return the context data to be used when rendering the panel.
-
-        Parameters:
-            context: The template context
-        """
         url_params = {
             k: v(context) if callable(v) else v for k, v in self.filters.items()
         }
@@ -363,7 +320,7 @@ class ObjectsTablePanel(Panel):
 
 class TemplatePanel(Panel):
     """
-    A panel which renders content using an HTML template.
+    A panel which renders custom content using an HTML template.
     """
     def __init__(self, template_name, **kwargs):
         """
@@ -385,7 +342,7 @@ class PluginContentPanel(Panel):
     A panel which displays embedded plugin content.
 
     Parameters:
-        method: The name of the plugin method to render (e.g. left_page)
+        method: The name of the plugin method to render (e.g. "left_page")
     """
     def __init__(self, method, **kwargs):
         super().__init__(**kwargs)

+ 0 - 1
netbox/templates/dcim/device/attrs/ipaddress.html

@@ -1,4 +1,3 @@
-{# TODO: Add copy-to-clipboard button #}
 {% load i18n %}
 <a href="{{ value.get_absolute_url }}"{% if name %} id="attr_{{ name }}"{% endif %}>{{ value.address.ip }}</a>
 {% if value.nat_inside %}

+ 3 - 0
netbox/utilities/templatetags/builtins/tags.py

@@ -184,4 +184,7 @@ def static_with_params(path, **params):
 
 @register.simple_tag(takes_context=True)
 def render(context, component):
+    """
+    Render a UI component (e.g. a Panel) by calling its render() method and passing the current template context.
+    """
     return mark_safe(component.render(context))