Просмотр исходного кода

Add plugin dev docs for UI components

Jeremy Stretch 3 месяцев назад
Родитель
Сommit
917280d1d3

+ 148 - 0
docs/plugins/development/ui-components.md

@@ -0,0 +1,148 @@
+# 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.
+
+!!! 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.
+
+## Page Layout
+
+A layout defines the general arrangement of content on a page into rows and columns. The layout is defined under the [view](./views.md) and declares a set of rows, each of which may have one or more columns. Below is an example layout.
+
+```
++-------+-------+-------+
+| Col 1 | Col 2 | Col 3 |
++-------+-------+-------+
+|         Col 4         |
++-----------+-----------+
+|   Col 5   |   Col 6   |
++-----------+-----------+
+```
+
+The above layout can be achieved with the following declaration under a view:
+
+```python
+from netbox.ui import layout
+from netbox.views import generic
+
+class MyView(generic.ObjectView):
+    layout = layout.Layout(
+        layout.Row(
+            layout.Column(),
+            layout.Column(),
+            layout.Column(),
+        ),
+        layout.Row(
+            layout.Column(),
+        ),
+        layout.Row(
+            layout.Column(),
+            layout.Column(),
+        ),
+    )
+```
+
+!!! note
+    Currently, layouts are supported only for subclasses of [`generic.ObjectView`](./views.md#netbox.views.generic.ObjectView).
+
+::: netbox.ui.layout.Layout
+
+::: netbox.ui.layout.SimpleLayout
+
+::: netbox.ui.layout.Row
+
+::: netbox.ui.layout.Column
+
+## Panels
+
+Within each column, related blocks of content are arranged into panels. Each panel has a title and may have a set of associated actions, but the content within is otherwise arbitrary.
+
+Plugins can define their own panels by inheriting from the base class `netbox.ui.panels.Panel`. Override the `get_context()` method to pass additional context to your custom panel template. An example is provided below.
+
+```python
+from django.utils.translation import gettext_lazy as _
+from netbox.ui.panels import Panel
+
+class RecentChangesPanel(Panel):
+    template_name = 'my_plugin/panels/recent_changes.html'
+    title = _('Recent Changes')
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'changes': get_changes()[:10],
+        }
+```
+
+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.ui.panels.Panel
+
+::: netbox.ui.panels.ObjectPanel
+
+::: netbox.ui.panels.ObjectAttributesPanel
+
+#### Object Attributes
+
+The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
+
+| Class                                | Description                                      |
+|--------------------------------------|--------------------------------------------------|
+| `netbox.ui.attrs.AddressAttr`        | A physical or mailing address.                   |
+| `netbox.ui.attrs.BooleanAttr`        | A boolean value                                  |
+| `netbox.ui.attrs.ColorAttr`          | A color expressed in RGB                         |
+| `netbox.ui.attrs.ChoiceAttr`         | A selection from a set of choices                |
+| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude)         |
+| `netbox.ui.attrs.ImageAttr`          | An attached image (displays the image)           |
+| `netbox.ui.attrs.NestedObjectAttr`   | A related nested object                          |
+| `netbox.ui.attrs.NumericAttr`        | An integer or float value                        |
+| `netbox.ui.attrs.RelatedObjectAttr`  | A related object                                 |
+| `netbox.ui.attrs.TemplatedAttr`      | Renders an attribute using a custom template     |
+| `netbox.ui.attrs.TextAttr`           | A string (text) value                            |
+| `netbox.ui.attrs.TimezoneAttr`       | A timezone with annotated offset                 |
+| `netbox.ui.attrs.UtilizationAttr`    | A numeric value expressed as a utilization graph |
+
+::: netbox.ui.panels.OrganizationalObjectPanel
+
+::: netbox.ui.panels.NestedGroupObjectPanel
+
+::: netbox.ui.panels.CommentsPanel
+
+::: netbox.ui.panels.JSONPanel
+
+::: netbox.ui.panels.RelatedObjectsPanel
+
+::: netbox.ui.panels.ObjectsTablePanel
+
+::: netbox.ui.panels.TemplatePanel
+
+::: netbox.ui.panels.PluginContentPanel
+
+## 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.
+
+```python
+from django.utils.translation import gettext_lazy as _
+from netbox.ui import actions, panels
+
+panels.ObjectsTablePanel(
+    model='dcim.Region',
+    title=_('Child Regions'),
+    filters={'parent_id': lambda ctx: ctx['object'].pk},
+    actions=[
+        actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
+    ],
+),
+```
+
+::: netbox.ui.actions.PanelAction
+
+::: netbox.ui.actions.LinkAction
+
+::: netbox.ui.actions.AddObject
+
+::: netbox.ui.actions.CopyContent

+ 1 - 0
mkdocs.yml

@@ -143,6 +143,7 @@ nav:
             - Getting Started: 'plugins/development/index.md'
             - Models: 'plugins/development/models.md'
             - Views: 'plugins/development/views.md'
+            - UI Components: 'plugins/development/ui-components.md'
             - Navigation: 'plugins/development/navigation.md'
             - Templates: 'plugins/development/templates.md'
             - Tables: 'plugins/development/tables.md'

+ 34 - 64
netbox/netbox/ui/actions.py

@@ -11,6 +11,7 @@ from utilities.views import get_viewname
 __all__ = (
     'AddObject',
     'CopyContent',
+    'LinkAction',
     'PanelAction',
 )
 
@@ -20,34 +21,28 @@ class PanelAction:
     A link (typically a button) within a panel to perform some associated action, such as adding an object.
 
     Attributes:
-        template_name: The name of the template to render
-        label: The default human-friendly button text
-        button_class: Bootstrap CSS class for the button
-        button_icon: Name of the button's MDI icon
+        template_name (str): The name of the template to render
+
+    Parameters:
+        label (str): The human-friendly button text
+        permissions (list): An iterable of permissions required to display the action
+        button_class (str): Bootstrap CSS class for the button
+        button_icon (str): Name of the button's MDI icon
     """
     template_name = None
-    label = None
-    button_class = 'primary'
-    button_icon = None
-
-    def __init__(self, label=None, permissions=None):
-        """
-        Initialize a new PanelAction.
 
-        Parameters:
-            label: The human-friendly button text
-            permissions: A list of permissions required to display the action
-        """
-        if label is not None:
-            self.label = label
+    def __init__(self, label, permissions=None, button_class='primary', button_icon=None):
+        self.label = label
         self.permissions = permissions
+        self.button_class = button_class
+        self.button_icon = button_icon
 
     def get_context(self, context):
         """
         Return the template context used to render the action element.
 
         Parameters:
-            context: The template context
+            context (dict): The template context
         """
         return {
             'label': self.label,
@@ -60,7 +55,7 @@ class PanelAction:
         Render the action as HTML.
 
         Parameters:
-            context: The template context
+            context (dict): The template context
         """
         # Enforce permissions
         user = context['request'].user
@@ -74,26 +69,16 @@ class LinkAction(PanelAction):
     """
     A hyperlink (typically a button) within a panel to perform some associated action, such as adding an object.
 
-    Attributes:
-        label: The default human-friendly button text
-        button_class: Bootstrap CSS class for the button
-        button_icon: Name of the button's MDI icon
+    Parameters:
+        view_name (str): Name of the view to which the action will link
+        view_kwargs (dict): Additional keyword arguments to pass to `reverse()` when resolving the URL
+        url_params (dict): A dictionary of arbitrary URL parameters to append to the action's URL. If the value of a key
+            is a callable, it will be passed the current template context.
     """
     template_name = 'ui/actions/link.html'
 
     def __init__(self, view_name, view_kwargs=None, url_params=None, **kwargs):
-        """
-        Initialize a new PanelAction.
-
-        Parameters:
-            view_name: Name of the view to which the action will link
-            view_kwargs: Additional keyword arguments to pass to the view when resolving its URL
-            url_params: A dictionary of arbitrary URL parameters to append to the action's URL
-            permissions: A list of permissions required to display the action
-            label: The human-friendly button text
-        """
         super().__init__(**kwargs)
-
         self.view_name = view_name
         self.view_kwargs = view_kwargs or {}
         self.url_params = url_params or {}
@@ -103,7 +88,7 @@ class LinkAction(PanelAction):
         Resolve the URL for the action from its view name and kwargs. Append any additional URL parameters.
 
         Parameters:
-            context: The template context
+            context (dict): The template context
         """
         url = reverse(self.view_name, kwargs=self.view_kwargs)
         if self.url_params:
@@ -127,19 +112,12 @@ class LinkAction(PanelAction):
 class AddObject(LinkAction):
     """
     An action to add a new object.
-    """
-    label = _('Add')
-    button_icon = 'plus-thick'
-
-    def __init__(self, model, url_params=None, label=None):
-        """
-        Initialize a new AddObject action.
 
-        Parameters:
-            model: The dotted label of the model to be added (e.g. "dcim.site")
-            url_params: A dictionary of arbitrary URL parameters to append to the resolved URL
-            label: The human-friendly button text
-        """
+    Parameters:
+        model (str): The dotted label of the model to be added (e.g. "dcim.site")
+        url_params (dict): A dictionary of arbitrary URL parameters to append to the resolved URL
+    """
+    def __init__(self, model, url_params=None, **kwargs):
         # Resolve the model class from its app.name label
         try:
             app_label, model_name = model.split('.')
@@ -148,37 +126,29 @@ class AddObject(LinkAction):
             raise ValueError(f"Invalid model label: {model}")
         view_name = get_viewname(model, 'add')
 
-        super().__init__(view_name=view_name, url_params=url_params, label=label)
+        kwargs.setdefault('label', _('Add'))
+        kwargs.setdefault('button_icon', 'plus-thick')
+        kwargs.setdefault('permissions', [get_permission_for_model(model, 'add')])
 
-        # Require "add" permission on the model
-        self.permissions = [get_permission_for_model(model, 'add')]
+        super().__init__(view_name=view_name, url_params=url_params, **kwargs)
 
 
 class CopyContent(PanelAction):
     """
     An action to copy the contents of a panel to the clipboard.
+
+    Parameters:
+        target_id (str): The ID of the target element containing the content to be copied
     """
     template_name = 'ui/actions/copy_content.html'
-    label = _('Copy')
-    button_icon = 'content-copy'
 
     def __init__(self, target_id, **kwargs):
-        """
-        Instantiate a new CopyContent action.
-
-        Parameters:
-            target_id: The ID of the target element containing the content to be copied
-        """
+        kwargs.setdefault('label', _('Copy'))
+        kwargs.setdefault('button_icon', 'content-copy')
         super().__init__(**kwargs)
         self.target_id = target_id
 
     def render(self, context):
-        """
-        Render the action as HTML.
-
-        Parameters:
-            context: The template context
-        """
         return render_to_string(self.template_name, {
             'target_id': self.target_id,
             'label': self.label,

+ 42 - 93
netbox/netbox/ui/attrs.py

@@ -34,22 +34,18 @@ class ObjectAttribute:
     Base class for representing an attribute of an object.
 
     Attributes:
-        template_name: The name of the template to render
-        label: Human-friendly label for the rendered attribute
-        placeholder: HTML to render for empty/null values
+        template_name (str): The name of the template to render
+        placeholder (str): HTML to render for empty/null values
+
+    Parameters:
+        accessor (str): The dotted path to the attribute being rendered (e.g. "site.region.name")
+        label (str): Human-friendly label for the rendered attribute
     """
     template_name = None
     label = None
     placeholder = mark_safe(PLACEHOLDER_HTML)
 
     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
-        """
         self.accessor = accessor
         if label is not None:
             self.label = label
@@ -59,7 +55,7 @@ class ObjectAttribute:
         Return the value of the attribute.
 
         Parameters:
-            obj: The object for which the attribute is being rendered
+            obj (object): The object for which the attribute is being rendered
         """
         return resolve_attr_path(obj, self.accessor)
 
@@ -68,8 +64,8 @@ class ObjectAttribute:
         Return any additional template context used to render the attribute value.
 
         Parameters:
-            obj: The object for which the attribute is being rendered
-            context: The root template context
+            obj (object): The object for which the attribute is being rendered
+            context (dict): The root template context
         """
         return {}
 
@@ -90,21 +86,15 @@ class ObjectAttribute:
 class TextAttr(ObjectAttribute):
     """
     A text attribute.
+
+    Parameters:
+         style (str): CSS class to apply to the rendered attribute
+         format_string (str): If specified, the value will be formatted using this string when rendering
+         copy_button (bool): Set to True to include a copy-to-clipboard button
     """
     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
@@ -127,20 +117,14 @@ class TextAttr(ObjectAttribute):
 class NumericAttr(ObjectAttribute):
     """
     An integer or float attribute.
+
+    Parameters:
+         unit_accessor (str): Accessor for the unit of measurement to display alongside the value (if any)
+         copy_button (bool): Set to True to include a copy-to-clipboard button
     """
     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
@@ -181,19 +165,13 @@ class ChoiceAttr(ObjectAttribute):
 class BooleanAttr(ObjectAttribute):
     """
     A boolean attribute.
+
+    Parameters:
+         display_false (bool): If False, a placeholder will be rendered instead of the "False" indication
     """
     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
 
@@ -222,21 +200,15 @@ class ImageAttr(ObjectAttribute):
 class RelatedObjectAttr(ObjectAttribute):
     """
     An attribute representing a related object.
+
+    Parameters:
+         linkify (bool): If True, the rendered value will be hyperlinked to the related object's detail view
+         grouped_by (str): A second-order object to annotate alongside the related object; for example, an attribute
+            representing the dcim.Site model might specify grouped_by="region"
     """
     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
@@ -254,20 +226,14 @@ 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.
+
+    Parameters:
+         linkify (bool): If True, the rendered value will be hyperlinked to the related object's detail view
+         max_depth (int): Maximum number of ancestors to display (default: all)
     """
     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
@@ -286,19 +252,13 @@ class NestedObjectAttr(ObjectAttribute):
 class AddressAttr(ObjectAttribute):
     """
     A physical or mailing address.
+
+    Parameters:
+         map_url (bool): If true, the address will render as a hyperlink using settings.MAPS_URL
     """
     template_name = 'ui/attrs/address.html'
 
     def __init__(self, *args, map_url=True, **kwargs):
-        """
-        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
@@ -316,21 +276,16 @@ class AddressAttr(ObjectAttribute):
 class GPSCoordinatesAttr(ObjectAttribute):
     """
     A GPS coordinates pair comprising latitude and longitude values.
+
+    Parameters:
+         latitude_attr (float): The name of the field containing the latitude value
+         longitude_attr (float): The name of the field containing the longitude value
+         map_url (bool): If true, the address will render as a hyperlink using settings.MAPS_URL
     """
     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
@@ -365,18 +320,12 @@ class TimezoneAttr(ObjectAttribute):
 class TemplatedAttr(ObjectAttribute):
     """
     Renders an attribute using a custom template.
+
+    Parameters:
+         template_name (str): The name of the template to render
+         context (dict): Additional context to pass to the template when rendering
     """
     def __init__(self, *args, template_name, context=None, **kwargs):
-        """
-        Instantiate a new TemplatedAttr.
-
-        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 {}

+ 25 - 6
netbox/netbox/ui/layout.py

@@ -15,6 +15,9 @@ __all__ = (
 class Layout:
     """
     A collection of rows and columns comprising the layout of content within the user interface.
+
+    Parameters:
+        *rows: One or more Row instances
     """
     def __init__(self, *rows):
         for i, row in enumerate(rows):
@@ -26,6 +29,9 @@ class Layout:
 class Row:
     """
     A collection of columns arranged horizontally.
+
+    Parameters:
+        *columns: One or more Column instances
     """
     def __init__(self, *columns):
         for i, column in enumerate(columns):
@@ -37,6 +43,9 @@ class Row:
 class Column:
     """
     A collection of panels arranged vertically.
+
+    Parameters:
+        *panels: One or more Panel instances
     """
     def __init__(self, *panels):
         for i, panel in enumerate(panels):
@@ -51,13 +60,23 @@ class Column:
 
 class SimpleLayout(Layout):
     """
-    A layout with one row of two columns and a second row with one column. Includes registered plugin content.
+    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
+    views in NetBox utilize this layout.
+
+    ```
+    +-------+-------+
+    | Col 1 | Col 2 |
+    +-------+-------+
+    |     Col 3     |
+    +---------------+
+    ```
 
-    +------+------+
-    | col1 | col2 |
-    +------+------+
-    |    col3     |
-    +-------------+
+    Parameters:
+        left_panels: Panel instances to be rendered in the top lefthand column
+        right_panels: Panel instances to be rendered in the top righthand column
+        bottom_panels: Panel instances to be rendered in the bottom row
     """
     def __init__(self, left_panels=None, right_panels=None, bottom_panels=None):
         left_panels = left_panels or []

+ 38 - 51
netbox/netbox/ui/panels.py

@@ -36,19 +36,19 @@ class Panel:
     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.
+
+    Attributes:
+        template_name (str): The name of the template used to render the panel
+
+    Parameters:
+        title (str): The human-friendly title of the panel
+        actions (list): An iterable of PanelActions to include in the panel header
     """
     template_name = None
     title = None
     actions = None
 
     def __init__(self, title=None, actions=None):
-        """
-        Instantiate a new Panel.
-
-        Parameters:
-            title: The human-friendly title of the panel
-            actions: An iterable of PanelActions to include in the panel header
-        """
         if title is not None:
             self.title = title
         self.actions = actions or self.actions or []
@@ -58,7 +58,7 @@ class Panel:
         Return the context data to be used when rendering the panel.
 
         Parameters:
-            context: The template context
+            context (dict): The template context
         """
         return {
             'request': context.get('request'),
@@ -72,7 +72,7 @@ class Panel:
         Render the panel as HTML.
 
         Parameters:
-            context: The template context
+            context (dict): The template context
         """
         return render_to_string(self.template_name, self.get_context(context))
 
@@ -84,16 +84,13 @@ class Panel:
 class ObjectPanel(Panel):
     """
     Base class for object-specific panels.
+
+    Parameters:
+        accessor (str): The dotted path in context data to the object being rendered (default: "object")
     """
     accessor = 'object'
 
     def __init__(self, accessor=None, **kwargs):
-        """
-        Instantiate a new ObjectPanel.
-
-        Parameters:
-            accessor: The dotted path in context data to the object being rendered (default: "object")
-        """
         super().__init__(**kwargs)
 
         if accessor is not None:
@@ -141,17 +138,16 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
 
     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.
+
+    Note that the `only` and `exclude` parameters are mutually exclusive.
+
+    Parameters:
+            only (list): If specified, only attributes in this list will be displayed
+            exclude (list): If specified, attributes in this list will be excluded from display
     """
     template_name = 'ui/panels/object_attributes.html'
 
     def __init__(self, only=None, exclude=None, **kwargs):
-        """
-        Instantiate a new ObjectPanel.
-
-        Parameters:
-            only: If specified, only attributes in this list will be displayed
-            exclude: If specified, attributes in this list will be excluded from display
-        """
         super().__init__(**kwargs)
 
         # Set included/excluded attributes
@@ -192,7 +188,7 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
 
 class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta):
     """
-    An ObjectPanel with attributes common to OrganizationalModels. Includes name and description.
+    An ObjectPanel with attributes common to OrganizationalModels. Includes `name` and `description` attributes.
     """
     name = attrs.TextAttr('name', label=_('Name'))
     description = attrs.TextAttr('description', label=_('Description'))
@@ -200,25 +196,24 @@ class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttribute
 
 class NestedGroupObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta):
     """
-    An ObjectPanel with attributes common to NestedGroupObjects. Includes the parent object.
+    An ObjectPanel with attributes common to NestedGroupObjects. Includes the `parent` attribute.
     """
+    name = attrs.TextAttr('name', label=_('Name'))
     parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True)
+    description = attrs.TextAttr('description', label=_('Description'))
 
 
 class CommentsPanel(ObjectPanel):
     """
     A panel which displays comments associated with an object.
+
+    Parameters:
+        field_name (str): The name of the comment field on the object (default: "comments")
     """
     template_name = 'ui/panels/comments.html'
     title = _('Comments')
 
     def __init__(self, field_name='comments', **kwargs):
-        """
-        Instantiate a new CommentsPanel.
-
-        Parameters:
-            field_name: The name of the comment field on the object (default: "comments")
-        """
         super().__init__(**kwargs)
         self.field_name = field_name
 
@@ -232,17 +227,14 @@ class CommentsPanel(ObjectPanel):
 class JSONPanel(ObjectPanel):
     """
     A panel which renders formatted JSON data from an object's JSONField.
+
+    Parameters:
+        field_name (str): The name of the JSON field on the object
+        copy_button (bool): Set to True (default) to include a copy-to-clipboard button
     """
     template_name = 'ui/panels/json.html'
 
     def __init__(self, field_name, copy_button=True, **kwargs):
-        """
-        Instantiate a new JSONPanel.
-
-        Parameters:
-            field_name: The name of the JSON field on the object
-            copy_button: Set to True (default) to include a copy-to-clipboard button
-        """
         super().__init__(**kwargs)
         self.field_name = field_name
 
@@ -278,18 +270,16 @@ class RelatedObjectsPanel(Panel):
 class ObjectsTablePanel(Panel):
     """
     A panel which displays a table of objects (rendered via HTMX).
+
+    Parameters:
+        model (str): The dotted label of the model to be added (e.g. "dcim.site")
+        filters (dict): A dictionary of arbitrary URL parameters to append to the table's URL. If the value of a key is
+            a callable, it will be passed the current template context.
     """
     template_name = 'ui/panels/objects_table.html'
     title = None
 
     def __init__(self, model, filters=None, **kwargs):
-        """
-        Instantiate a new ObjectsTablePanel.
-
-        Parameters:
-            model: The dotted label of the model to be added (e.g. "dcim.site")
-            filters: A dictionary of arbitrary URL parameters to append to the table's URL
-        """
         super().__init__(**kwargs)
 
         # Resolve the model class from its app.name label
@@ -321,14 +311,11 @@ class ObjectsTablePanel(Panel):
 class TemplatePanel(Panel):
     """
     A panel which renders custom content using an HTML template.
+
+    Parameters:
+        template_name (str): The name of the template to render
     """
     def __init__(self, template_name, **kwargs):
-        """
-        Instantiate a new TemplatePanel.
-
-        Parameters:
-            template_name: The name of the template to render
-        """
         super().__init__(**kwargs)
         self.template_name = template_name
 
@@ -342,7 +329,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 (str): The name of the plugin method to render (e.g. "left_page")
     """
     def __init__(self, method, **kwargs):
         super().__init__(**kwargs)

+ 1 - 3
netbox/netbox/views/generic/object_views.py

@@ -44,6 +44,7 @@ class ObjectView(ActionsMixin, BaseObjectView):
     Note: If `template_name` is not specified, it will be determined automatically based on the queryset model.
 
     Attributes:
+        layout: An instance of `netbox.ui.layout.Layout` which defines the page layout (overrides HTML template)
         tab: A ViewTab instance for the view
         actions: An iterable of ObjectAction subclasses (see ActionsMixin)
     """
@@ -59,9 +60,6 @@ class ObjectView(ActionsMixin, BaseObjectView):
         Return self.template_name if defined. Otherwise, dynamically resolve the template name using the queryset
         model's `app_label` and `model_name`.
         """
-        # TODO: Temporarily allow layout to override template_name
-        if self.layout is not None:
-            return 'generic/object.html'
         if self.template_name is not None:
             return self.template_name
         model_opts = self.queryset.model._meta