diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 8fe7db406..e0274f660 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1202,7 +1202,7 @@ class RackReservationView(generic.ObjectView): layout = layout.Layout( layout.Row( layout.Column( - panels.RackPanel(accessor='rack', only=['region', 'site', 'location']), + panels.RackPanel(title=_('Rack'), accessor='rack', only=['region', 'site', 'location']), CustomFieldsPanel(), TagsPanel(), CommentsPanel(), diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index 10be487c8..8a3d7ecb1 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -1,6 +1,7 @@ from urllib.parse import urlencode from django.apps import apps +from django.template.loader import render_to_string from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -14,48 +15,102 @@ __all__ = ( 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 = 'ui/action.html' label = None button_class = 'primary' button_icon = None def __init__(self, view_name, view_kwargs=None, url_params=None, permissions=None, label=None): + """ + 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 + """ self.view_name = view_name - self.view_kwargs = view_kwargs + self.view_kwargs = view_kwargs or {} self.url_params = url_params or {} self.permissions = permissions if label is not None: self.label = label def get_url(self, context): - url = reverse(self.view_name, kwargs=self.view_kwargs or {}) + """ + Resolve the URL for the action from its view name and kwargs. Append any additional URL parameters. + + Parameters: + context: The template context + """ + url = reverse(self.view_name, kwargs=self.view_kwargs) if self.url_params: + # If the param value is callable, call it with the context and save the result. url_params = { k: v(context) if callable(v) else v for k, v in self.url_params.items() } + # Set the return URL if not already set and an object is available. if 'return_url' not in url_params and 'object' in context: url_params['return_url'] = context['object'].get_absolute_url() url = f'{url}?{urlencode(url_params)}' return url - def get_context(self, context): - return { + def render(self, context): + """ + Render the action as HTML. + + Parameters: + context: The template context + """ + # Enforce permissions + user = context['request'].user + if not user.has_perms(self.permissions): + return '' + + return render_to_string(self.template_name, { 'url': self.get_url(context), 'label': self.label, 'button_class': self.button_class, 'button_icon': self.button_icon, - } + }) class AddObject(PanelAction): + """ + An action to add a new object. + """ label = _('Add') button_icon = 'plus-thick' - def __init__(self, model, label=None, url_params=None): + 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 + """ # Resolve the model class from its app.name label - app_label, model_name = model.split('.') - model = apps.get_model(app_label, model_name) + try: + app_label, model_name = model.split('.') + model = apps.get_model(app_label, model_name) + except (ValueError, LookupError): + raise ValueError(f"Invalid model label: {model}") view_name = get_viewname(model, 'add') + super().__init__(view_name=view_name, label=label, url_params=url_params) - # Require "add" permission on the model by default + # Require "add" permission on the model self.permissions = [get_permission_for_model(model, 'add')] diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 05eae3e36..53a6f0792 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -24,24 +24,52 @@ __all__ = ( class Panel(ABC): + """ + A block of content rendered within an HTML template. + + Attributes: + template_name: The name of the template to render + title: The human-friendly title of the panel + actions: A list of PanelActions to include in the panel header + """ template_name = None title = None actions = [] def __init__(self, title=None, actions=None): + """ + Instantiate a new Panel. + + Parameters: + title: The human-friendly title of the panel + actions: A list of PanelActions to include in the panel header + """ if title is not None: self.title = title if actions is not None: self.actions = actions def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ return { 'request': context.get('request'), + 'object': context.get('object'), 'title': self.title, - 'actions': [action.get_context(context) for action in self.actions], + 'actions': self.actions, } def render(self, context): + """ + Render the panel as HTML. + + Parameters: + context: The template context + """ return render_to_string(self.template_name, self.get_context(context)) @@ -73,21 +101,43 @@ class ObjectPanelMeta(ABCMeta): class ObjectPanel(Panel, metaclass=ObjectPanelMeta): - accessor = None + """ + A panel which displays selected attributes of an object. + + Attributes: + template_name: The name of the template to render + accessor: The name of the attribute on the object + """ template_name = 'ui/panels/object.html' + accessor = None def __init__(self, accessor=None, only=None, exclude=None, **kwargs): + """ + Instantiate a new ObjectPanel. + + Parameters: + accessor: The name of the attribute on the object + only: If specified, only attributes in this list will be displayed + exclude: If specified, attributes in this list will be excluded from display + """ super().__init__(**kwargs) + if accessor is not None: self.accessor = accessor # Set included/excluded attributes if only is not None and exclude is not None: - raise ValueError("attrs and exclude cannot both be specified.") + raise ValueError("only and exclude cannot both be specified.") self.only = only or [] self.exclude = exclude or [] def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ # Determine which attributes to display in the panel based on only/exclude args attr_names = set(self._attrs.keys()) if self.only: @@ -99,7 +149,6 @@ class ObjectPanel(Panel, metaclass=ObjectPanelMeta): return { **super().get_context(context), - 'object': obj, 'attrs': [ { 'label': attr.label or title(name), @@ -110,24 +159,42 @@ class ObjectPanel(Panel, metaclass=ObjectPanelMeta): class OrganizationalObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): + """ + An ObjectPanel with attributes common to OrganizationalModels. + """ name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) class NestedGroupObjectPanel(OrganizationalObjectPanel, metaclass=ObjectPanelMeta): + """ + An ObjectPanel with attributes common to NestedGroupObjects. + """ parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) class CommentsPanel(Panel): + """ + A panel which displays comments associated with an object. + """ template_name = 'ui/panels/comments.html' title = _('Comments') class RelatedObjectsPanel(Panel): + """ + A panel which displays the types and counts of related objects. + """ template_name = 'ui/panels/related_objects.html' title = _('Related Objects') def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ return { **super().get_context(context), 'related_models': context.get('related_models'), @@ -135,20 +202,42 @@ class RelatedObjectsPanel(Panel): class ObjectsTablePanel(Panel): + """ + A panel which displays a table of objects (rendered via HTMX). + """ 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 - app_label, model_name = model.split('.') - self.model = apps.get_model(app_label, model_name) + try: + app_label, model_name = model.split('.') + self.model = apps.get_model(app_label, model_name) + except (ValueError, LookupError): + raise ValueError(f"Invalid model label: {model}") + self.filters = filters or {} + + # If no title is specified, derive one from the model name if self.title is None: self.title = title(self.model._meta.verbose_name_plural) def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ url_params = { k: v(context) if callable(v) else v for k, v in self.filters.items() } @@ -162,8 +251,16 @@ class ObjectsTablePanel(Panel): class TemplatePanel(Panel): - + """ + A panel which renders content using an HTML template. + """ 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 @@ -173,7 +270,12 @@ class TemplatePanel(Panel): class PluginContentPanel(Panel): + """ + A panel which displays embedded plugin content. + Parameters: + method: The name of the plugin method to render (e.g. left_page) + """ def __init__(self, method, **kwargs): super().__init__(**kwargs) self.method = method diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index a9783178a..100d5bde7 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -129,7 +129,7 @@ Context: {% for column in row.columns %}