Преглед изворни кода

Add plugin dev docs for UI components

Jeremy Stretch пре 3 месеци
родитељ
комит
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'
             - Getting Started: 'plugins/development/index.md'
             - Models: 'plugins/development/models.md'
             - Models: 'plugins/development/models.md'
             - Views: 'plugins/development/views.md'
             - Views: 'plugins/development/views.md'
+            - UI Components: 'plugins/development/ui-components.md'
             - Navigation: 'plugins/development/navigation.md'
             - Navigation: 'plugins/development/navigation.md'
             - Templates: 'plugins/development/templates.md'
             - Templates: 'plugins/development/templates.md'
             - Tables: 'plugins/development/tables.md'
             - Tables: 'plugins/development/tables.md'

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

@@ -11,6 +11,7 @@ from utilities.views import get_viewname
 __all__ = (
 __all__ = (
     'AddObject',
     'AddObject',
     'CopyContent',
     'CopyContent',
+    'LinkAction',
     'PanelAction',
     '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.
     A link (typically a button) within a panel to perform some associated action, such as adding an object.
 
 
     Attributes:
     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
     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.permissions = permissions
+        self.button_class = button_class
+        self.button_icon = button_icon
 
 
     def get_context(self, context):
     def get_context(self, context):
         """
         """
         Return the template context used to render the action element.
         Return the template context used to render the action element.
 
 
         Parameters:
         Parameters:
-            context: The template context
+            context (dict): The template context
         """
         """
         return {
         return {
             'label': self.label,
             'label': self.label,
@@ -60,7 +55,7 @@ class PanelAction:
         Render the action as HTML.
         Render the action as HTML.
 
 
         Parameters:
         Parameters:
-            context: The template context
+            context (dict): The template context
         """
         """
         # Enforce permissions
         # Enforce permissions
         user = context['request'].user
         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.
     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'
     template_name = 'ui/actions/link.html'
 
 
     def __init__(self, view_name, view_kwargs=None, url_params=None, **kwargs):
     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)
         super().__init__(**kwargs)
-
         self.view_name = view_name
         self.view_name = view_name
         self.view_kwargs = view_kwargs or {}
         self.view_kwargs = view_kwargs or {}
         self.url_params = url_params 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.
         Resolve the URL for the action from its view name and kwargs. Append any additional URL parameters.
 
 
         Parameters:
         Parameters:
-            context: The template context
+            context (dict): The template context
         """
         """
         url = reverse(self.view_name, kwargs=self.view_kwargs)
         url = reverse(self.view_name, kwargs=self.view_kwargs)
         if self.url_params:
         if self.url_params:
@@ -127,19 +112,12 @@ class LinkAction(PanelAction):
 class AddObject(LinkAction):
 class AddObject(LinkAction):
     """
     """
     An action to add a new object.
     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
         # Resolve the model class from its app.name label
         try:
         try:
             app_label, model_name = model.split('.')
             app_label, model_name = model.split('.')
@@ -148,37 +126,29 @@ class AddObject(LinkAction):
             raise ValueError(f"Invalid model label: {model}")
             raise ValueError(f"Invalid model label: {model}")
         view_name = get_viewname(model, 'add')
         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):
 class CopyContent(PanelAction):
     """
     """
     An action to copy the contents of a panel to the clipboard.
     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'
     template_name = 'ui/actions/copy_content.html'
-    label = _('Copy')
-    button_icon = 'content-copy'
 
 
     def __init__(self, target_id, **kwargs):
     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)
         super().__init__(**kwargs)
         self.target_id = target_id
         self.target_id = target_id
 
 
     def render(self, context):
     def render(self, context):
-        """
-        Render the action as HTML.
-
-        Parameters:
-            context: The template context
-        """
         return render_to_string(self.template_name, {
         return render_to_string(self.template_name, {
             'target_id': self.target_id,
             'target_id': self.target_id,
             'label': self.label,
             '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.
     Base class for representing an attribute of an object.
 
 
     Attributes:
     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
     template_name = None
     label = None
     label = None
     placeholder = mark_safe(PLACEHOLDER_HTML)
     placeholder = mark_safe(PLACEHOLDER_HTML)
 
 
     def __init__(self, accessor, label=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
-        """
         self.accessor = accessor
         self.accessor = accessor
         if label is not None:
         if label is not None:
             self.label = label
             self.label = label
@@ -59,7 +55,7 @@ class ObjectAttribute:
         Return the value of the attribute.
         Return the value of the attribute.
 
 
         Parameters:
         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)
         return resolve_attr_path(obj, self.accessor)
 
 
@@ -68,8 +64,8 @@ class ObjectAttribute:
         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: 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 {}
         return {}
 
 
@@ -90,21 +86,15 @@ class ObjectAttribute:
 class TextAttr(ObjectAttribute):
 class TextAttr(ObjectAttribute):
     """
     """
     A text attribute.
     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'
     template_name = 'ui/attrs/text.html'
 
 
     def __init__(self, *args, style=None, format_string=None, copy_button=False, **kwargs):
     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)
         super().__init__(*args, **kwargs)
         self.style = style
         self.style = style
         self.format_string = format_string
         self.format_string = format_string
@@ -127,20 +117,14 @@ class TextAttr(ObjectAttribute):
 class NumericAttr(ObjectAttribute):
 class NumericAttr(ObjectAttribute):
     """
     """
     An integer or float attribute.
     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'
     template_name = 'ui/attrs/numeric.html'
 
 
     def __init__(self, *args, unit_accessor=None, copy_button=False, **kwargs):
     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)
         super().__init__(*args, **kwargs)
         self.unit_accessor = unit_accessor
         self.unit_accessor = unit_accessor
         self.copy_button = copy_button
         self.copy_button = copy_button
@@ -181,19 +165,13 @@ class ChoiceAttr(ObjectAttribute):
 class BooleanAttr(ObjectAttribute):
 class BooleanAttr(ObjectAttribute):
     """
     """
     A boolean attribute.
     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'
     template_name = 'ui/attrs/boolean.html'
 
 
     def __init__(self, *args, display_false=True, **kwargs):
     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)
         super().__init__(*args, **kwargs)
         self.display_false = display_false
         self.display_false = display_false
 
 
@@ -222,21 +200,15 @@ class ImageAttr(ObjectAttribute):
 class RelatedObjectAttr(ObjectAttribute):
 class RelatedObjectAttr(ObjectAttribute):
     """
     """
     An attribute representing a related object.
     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'
     template_name = 'ui/attrs/object.html'
 
 
     def __init__(self, *args, linkify=None, grouped_by=None, **kwargs):
     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)
         super().__init__(*args, **kwargs)
         self.linkify = linkify
         self.linkify = linkify
         self.grouped_by = grouped_by
         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
     An attribute representing a related nested object. Similar to `RelatedObjectAttr`, but includes the ancestors of the
     related object in the rendered output.
     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'
     template_name = 'ui/attrs/nested_object.html'
 
 
     def __init__(self, *args, linkify=None, max_depth=None, **kwargs):
     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)
         super().__init__(*args, **kwargs)
         self.linkify = linkify
         self.linkify = linkify
         self.max_depth = max_depth
         self.max_depth = max_depth
@@ -286,19 +252,13 @@ class NestedObjectAttr(ObjectAttribute):
 class AddressAttr(ObjectAttribute):
 class AddressAttr(ObjectAttribute):
     """
     """
     A physical or mailing address.
     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'
     template_name = 'ui/attrs/address.html'
 
 
     def __init__(self, *args, map_url=True, **kwargs):
     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)
         super().__init__(*args, **kwargs)
         if map_url is True:
         if map_url is True:
             self.map_url = get_config().MAPS_URL
             self.map_url = get_config().MAPS_URL
@@ -316,21 +276,16 @@ class AddressAttr(ObjectAttribute):
 class GPSCoordinatesAttr(ObjectAttribute):
 class GPSCoordinatesAttr(ObjectAttribute):
     """
     """
     A GPS coordinates pair comprising latitude and longitude values.
     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'
     template_name = 'ui/attrs/gps_coordinates.html'
     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):
-        """
-        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)
         super().__init__(accessor=None, **kwargs)
         self.latitude_attr = latitude_attr
         self.latitude_attr = latitude_attr
         self.longitude_attr = longitude_attr
         self.longitude_attr = longitude_attr
@@ -365,18 +320,12 @@ class TimezoneAttr(ObjectAttribute):
 class TemplatedAttr(ObjectAttribute):
 class TemplatedAttr(ObjectAttribute):
     """
     """
     Renders an attribute using a custom template.
     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):
     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)
         super().__init__(*args, **kwargs)
         self.template_name = template_name
         self.template_name = template_name
         self.context = context or {}
         self.context = context or {}

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

@@ -15,6 +15,9 @@ __all__ = (
 class Layout:
 class Layout:
     """
     """
     A collection of rows and columns comprising the layout of content within the user interface.
     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):
     def __init__(self, *rows):
         for i, row in enumerate(rows):
         for i, row in enumerate(rows):
@@ -26,6 +29,9 @@ class Layout:
 class Row:
 class Row:
     """
     """
     A collection of columns arranged horizontally.
     A collection of columns arranged horizontally.
+
+    Parameters:
+        *columns: One or more Column instances
     """
     """
     def __init__(self, *columns):
     def __init__(self, *columns):
         for i, column in enumerate(columns):
         for i, column in enumerate(columns):
@@ -37,6 +43,9 @@ class Row:
 class Column:
 class Column:
     """
     """
     A collection of panels arranged vertically.
     A collection of panels arranged vertically.
+
+    Parameters:
+        *panels: One or more Panel instances
     """
     """
     def __init__(self, *panels):
     def __init__(self, *panels):
         for i, panel in enumerate(panels):
         for i, panel in enumerate(panels):
@@ -51,13 +60,23 @@ class Column:
 
 
 class SimpleLayout(Layout):
 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):
     def __init__(self, left_panels=None, right_panels=None, bottom_panels=None):
         left_panels = left_panels or []
         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
     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
     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.
     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
     template_name = None
     title = None
     title = None
     actions = None
     actions = None
 
 
     def __init__(self, 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:
         if title is not None:
             self.title = title
             self.title = title
         self.actions = actions or self.actions or []
         self.actions = actions or self.actions or []
@@ -58,7 +58,7 @@ class Panel:
         Return the context data to be used when rendering the panel.
         Return the context data to be used when rendering the panel.
 
 
         Parameters:
         Parameters:
-            context: The template context
+            context (dict): The template context
         """
         """
         return {
         return {
             'request': context.get('request'),
             'request': context.get('request'),
@@ -72,7 +72,7 @@ class Panel:
         Render the panel as HTML.
         Render the panel as HTML.
 
 
         Parameters:
         Parameters:
-            context: The template context
+            context (dict): The template context
         """
         """
         return render_to_string(self.template_name, self.get_context(context))
         return render_to_string(self.template_name, self.get_context(context))
 
 
@@ -84,16 +84,13 @@ class Panel:
 class ObjectPanel(Panel):
 class ObjectPanel(Panel):
     """
     """
     Base class for object-specific panels.
     Base class for object-specific panels.
+
+    Parameters:
+        accessor (str): The dotted path in context data to the object being rendered (default: "object")
     """
     """
     accessor = 'object'
     accessor = 'object'
 
 
     def __init__(self, accessor=None, **kwargs):
     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)
         super().__init__(**kwargs)
 
 
         if accessor is not None:
         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
     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.
     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'
     template_name = 'ui/panels/object_attributes.html'
 
 
     def __init__(self, only=None, exclude=None, **kwargs):
     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)
         super().__init__(**kwargs)
 
 
         # Set included/excluded attributes
         # Set included/excluded attributes
@@ -192,7 +188,7 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
 
 
 class OrganizationalObjectPanel(ObjectAttributesPanel, 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'))
     name = attrs.TextAttr('name', label=_('Name'))
     description = attrs.TextAttr('description', label=_('Description'))
     description = attrs.TextAttr('description', label=_('Description'))
@@ -200,25 +196,24 @@ class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttribute
 
 
 class NestedGroupObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta):
 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)
     parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True)
+    description = attrs.TextAttr('description', label=_('Description'))
 
 
 
 
 class CommentsPanel(ObjectPanel):
 class CommentsPanel(ObjectPanel):
     """
     """
     A panel which displays comments associated with an object.
     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'
     template_name = 'ui/panels/comments.html'
     title = _('Comments')
     title = _('Comments')
 
 
     def __init__(self, field_name='comments', **kwargs):
     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)
         super().__init__(**kwargs)
         self.field_name = field_name
         self.field_name = field_name
 
 
@@ -232,17 +227,14 @@ class CommentsPanel(ObjectPanel):
 class JSONPanel(ObjectPanel):
 class JSONPanel(ObjectPanel):
     """
     """
     A panel which renders formatted JSON data from an object's JSONField.
     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'
     template_name = 'ui/panels/json.html'
 
 
     def __init__(self, field_name, copy_button=True, **kwargs):
     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)
         super().__init__(**kwargs)
         self.field_name = field_name
         self.field_name = field_name
 
 
@@ -278,18 +270,16 @@ class RelatedObjectsPanel(Panel):
 class ObjectsTablePanel(Panel):
 class ObjectsTablePanel(Panel):
     """
     """
     A panel which displays a table of objects (rendered via HTMX).
     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'
     template_name = 'ui/panels/objects_table.html'
     title = None
     title = None
 
 
     def __init__(self, model, filters=None, **kwargs):
     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)
         super().__init__(**kwargs)
 
 
         # Resolve the model class from its app.name label
         # Resolve the model class from its app.name label
@@ -321,14 +311,11 @@ class ObjectsTablePanel(Panel):
 class TemplatePanel(Panel):
 class TemplatePanel(Panel):
     """
     """
     A panel which renders custom content using an HTML template.
     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):
     def __init__(self, template_name, **kwargs):
-        """
-        Instantiate a new TemplatePanel.
-
-        Parameters:
-            template_name: The name of the template to render
-        """
         super().__init__(**kwargs)
         super().__init__(**kwargs)
         self.template_name = template_name
         self.template_name = template_name
 
 
@@ -342,7 +329,7 @@ class PluginContentPanel(Panel):
     A panel which displays embedded plugin content.
     A panel which displays embedded plugin content.
 
 
     Parameters:
     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):
     def __init__(self, method, **kwargs):
         super().__init__(**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.
     Note: If `template_name` is not specified, it will be determined automatically based on the queryset model.
 
 
     Attributes:
     Attributes:
+        layout: An instance of `netbox.ui.layout.Layout` which defines the page layout (overrides HTML template)
         tab: A ViewTab instance for the view
         tab: A ViewTab instance for the view
         actions: An iterable of ObjectAction subclasses (see ActionsMixin)
         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
         Return self.template_name if defined. Otherwise, dynamically resolve the template name using the queryset
         model's `app_label` and `model_name`.
         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:
         if self.template_name is not None:
             return self.template_name
             return self.template_name
         model_opts = self.queryset.model._meta
         model_opts = self.queryset.model._meta