From a024012abd3c440a32cd0cf9c9d80d67979ac354 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Nov 2025 14:54:40 -0500 Subject: [PATCH] Misc cleanup --- netbox/dcim/ui/panels.py | 32 ++-- netbox/extras/ui/panels.py | 11 +- netbox/netbox/ui/attrs.py | 164 ++++++++++++++++-- netbox/netbox/ui/layout.py | 20 ++- netbox/netbox/ui/panels.py | 75 ++------ .../dcim/device/attrs/ipaddress.html | 1 - .../utilities/templatetags/builtins/tags.py | 3 + 7 files changed, 210 insertions(+), 96 deletions(-) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 62435bedf..4661e3151 100644 --- a/netbox/dcim/ui/panels.py +++ b/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') diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py index 2ab55ecd8..f2f9a5c9a 100644 --- a/netbox/extras/ui/panels.py +++ b/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), } diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 7c683b0f7..53e295246 100644 --- a/netbox/netbox/ui/attrs.py +++ b/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 = '' + # # Attributes @@ -21,20 +40,17 @@ class ObjectAttribute: """ template_name = None label = None - placeholder = mark_safe('') + 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' diff --git a/netbox/netbox/ui/layout.py b/netbox/netbox/ui/layout.py index 6612917a7..d3fc69535 100644 --- a/netbox/netbox/ui/layout.py +++ b/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 [] diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 5827bb6b0..149b48563 100644 --- a/netbox/netbox/ui/panels.py +++ b/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) diff --git a/netbox/templates/dcim/device/attrs/ipaddress.html b/netbox/templates/dcim/device/attrs/ipaddress.html index 2af5dab6c..7b4345657 100644 --- a/netbox/templates/dcim/device/attrs/ipaddress.html +++ b/netbox/templates/dcim/device/attrs/ipaddress.html @@ -1,4 +1,3 @@ -{# TODO: Add copy-to-clipboard button #} {% load i18n %} {{ value.address.ip }} {% if value.nat_inside %} diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index cab4f9f20..663bf5647 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/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))