Add plugin dev docs for UI components

This commit is contained in:
Jeremy Stretch
2025-11-07 15:37:20 -05:00
parent a024012abd
commit 917280d1d3
7 changed files with 289 additions and 217 deletions
+34 -64
View File
@@ -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.
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
"""
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
"""
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
View File
@@ -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
View File
@@ -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.
+------+------+
| col1 | col2 |
+------+------+
| col3 |
+-------------+
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 |
+---------------+
```
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
View File
@@ -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
View File
@@ -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