From 1de41b4964909b37ac7fff9b1ba2c060574343e2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 4 Nov 2025 20:06:18 -0500 Subject: [PATCH] Add layouts for DeviceType & ModuleTypeProfile --- netbox/dcim/ui/panels.py | 21 +++++++++ netbox/dcim/views.py | 42 +++++++++++++++++- netbox/netbox/ui/actions.py | 30 ++++++++++++- netbox/netbox/ui/attrs.py | 14 ++++++ netbox/netbox/ui/panels.py | 43 +++++++++++++++++-- netbox/templates/ui/actions/copy_content.html | 7 +++ .../ui/{action.html => actions/link.html} | 2 +- netbox/templates/ui/attrs/boolean.html | 2 +- netbox/templates/ui/attrs/image.html | 3 ++ netbox/templates/ui/panels/json.html | 5 +++ 10 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 netbox/templates/ui/actions/copy_content.html rename netbox/templates/ui/{action.html => actions/link.html} (56%) create mode 100644 netbox/templates/ui/attrs/image.html create mode 100644 netbox/templates/ui/panels/json.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index d26dfda45..d6309c600 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -112,3 +112,24 @@ class DeviceManagementPanel(panels.ObjectPanel): label=_('Out-of-band IP'), template_name='dcim/device/attrs/ipaddress.html', ) + + +class DeviceTypePanel(panels.ObjectPanel): + manufacturer = attrs.ObjectAttr('manufacturer', label=_('Manufacturer'), linkify=True) + model = attrs.TextAttr('model', label=_('Model')) + part_number = attrs.TextAttr('part_number', label=_('Part number')) + default_platform = attrs.ObjectAttr('default_platform', label=_('Default platform'), linkify=True) + description = attrs.TextAttr('description', label=_('Description')) + u_height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) + exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization', label=_('Exclude from utilization')) + full_depth = attrs.BooleanAttr('is_full_depth', label=_('Full depth')) + weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display', label=_('Weight')) + subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child')) + airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) + front_image = attrs.ImageAttr('front_image', label=_('Front image')) + rear_image = attrs.ImageAttr('rear_image', label=_('Rear image')) + + +class ModuleTypeProfilePanel(panels.ObjectPanel): + name = attrs.TextAttr('name', label=_('Name')) + description = attrs.TextAttr('description', label=_('Description')) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3d2dad903..051d2867d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -21,7 +21,7 @@ from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * from netbox.ui import actions, layout from netbox.ui.panels import ( - CommentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, OrganizationalObjectPanel, RelatedObjectsPanel, + CommentsPanel, JSONPanel, NestedGroupObjectPanel, ObjectsTablePanel, OrganizationalObjectPanel, RelatedObjectsPanel, TemplatePanel, ) from netbox.views import generic @@ -1308,6 +1308,18 @@ class DeviceTypeListView(generic.ObjectListView): @register_model_view(DeviceType) class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = DeviceType.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.DeviceTypePanel(), + TagsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + ImageAttachmentsPanel(), + ], + ) def get_extra_context(self, request, instance): return { @@ -1559,6 +1571,34 @@ class ModuleTypeProfileListView(generic.ObjectListView): @register_model_view(ModuleTypeProfile) class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView): queryset = ModuleTypeProfile.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.ModuleTypeProfilePanel(), + TagsPanel(), + CommentsPanel(), + ], + right_panels=[ + JSONPanel(field_name='schema', title=_('Schema')), + CustomFieldsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.ModuleType', + title=_('Module Types'), + filters={ + 'profile_id': lambda ctx: ctx['object'].pk, + }, + actions=[ + actions.AddObject( + 'dcim.ModuleType', + url_params={ + 'profile': lambda ctx: ctx['object'].pk, + } + ), + ], + ), + ] + ) @register_model_view(ModuleTypeProfile, 'add', detail=False) diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index 8a3d7ecb1..a94520bdc 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -24,11 +24,12 @@ class PanelAction: button_class: Bootstrap CSS class for the button button_icon: Name of the button's MDI icon """ - template_name = 'ui/action.html' + template_name = 'ui/actions/link.html' label = None button_class = 'primary' button_icon = None + # TODO: Refactor URL parameters to AddObject def __init__(self, view_name, view_kwargs=None, url_params=None, permissions=None, label=None): """ Initialize a new PanelAction. @@ -114,3 +115,30 @@ class AddObject(PanelAction): # Require "add" permission on the model self.permissions = [get_permission_for_model(model, 'add')] + + +class CopyContent: + """ + An action to copy the contents of a panel to the clipboard. + """ + template_name = 'ui/actions/copy_content.html' + label = _('Copy') + button_class = 'primary' + button_icon = 'content-copy' + + def __init__(self, target_id): + 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, + 'button_class': self.button_class, + 'button_icon': self.button_icon, + }) diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 4df8f64e1..72c5dba5f 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -135,6 +135,20 @@ class ColorAttr(Attr): }) +class ImageAttr(Attr): + template_name = 'ui/attrs/image.html' + + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + if value in (None, ''): + return self.placeholder + return render_to_string(self.template_name, { + **context, + 'value': value, + }) + + class ObjectAttr(Attr): template_name = 'ui/attrs/object.html' diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index b2f7ad2eb..eefbde5b4 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -5,6 +5,7 @@ from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from netbox.ui import attrs +from netbox.ui.actions import CopyContent from utilities.querydict import dict_to_querydict from utilities.string import title from utilities.templatetags.plugins import _get_registered_content @@ -12,6 +13,7 @@ from utilities.views import get_viewname __all__ = ( 'CommentsPanel', + 'JSONPanel', 'NestedGroupObjectPanel', 'ObjectPanel', 'ObjectsTablePanel', @@ -34,7 +36,7 @@ class Panel(ABC): """ template_name = None title = None - actions = [] + actions = None def __init__(self, title=None, actions=None): """ @@ -46,8 +48,7 @@ class Panel(ABC): """ if title is not None: self.title = title - if actions is not None: - self.actions = actions + self.actions = actions or [] def get_context(self, context): """ @@ -251,6 +252,42 @@ class ObjectsTablePanel(Panel): } +class JSONPanel(Panel): + """ + A panel which renders formatted JSON data. + """ + 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 + + if copy_button: + self.actions.append( + CopyContent(f'panel_{field_name}'), + ) + + def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ + return { + **super().get_context(context), + 'data': getattr(context['object'], self.field_name), + 'field_name': self.field_name, + } + + class TemplatePanel(Panel): """ A panel which renders content using an HTML template. diff --git a/netbox/templates/ui/actions/copy_content.html b/netbox/templates/ui/actions/copy_content.html new file mode 100644 index 000000000..67f54354b --- /dev/null +++ b/netbox/templates/ui/actions/copy_content.html @@ -0,0 +1,7 @@ +{% load i18n %} + + {% if button_icon %} + + {% endif %} + {{ label }} + diff --git a/netbox/templates/ui/action.html b/netbox/templates/ui/actions/link.html similarity index 56% rename from netbox/templates/ui/action.html rename to netbox/templates/ui/actions/link.html index c61357312..11c6b6da9 100644 --- a/netbox/templates/ui/action.html +++ b/netbox/templates/ui/actions/link.html @@ -1,4 +1,4 @@ - + {% if button_icon %} {% endif %} diff --git a/netbox/templates/ui/attrs/boolean.html b/netbox/templates/ui/attrs/boolean.html index a724d687b..a7087c94f 100644 --- a/netbox/templates/ui/attrs/boolean.html +++ b/netbox/templates/ui/attrs/boolean.html @@ -1 +1 @@ -{% checkmark object.desc_units %} +{% checkmark value %} diff --git a/netbox/templates/ui/attrs/image.html b/netbox/templates/ui/attrs/image.html new file mode 100644 index 000000000..3c10113c4 --- /dev/null +++ b/netbox/templates/ui/attrs/image.html @@ -0,0 +1,3 @@ + + {{ value.name }} + diff --git a/netbox/templates/ui/panels/json.html b/netbox/templates/ui/panels/json.html new file mode 100644 index 000000000..36d3d4d1a --- /dev/null +++ b/netbox/templates/ui/panels/json.html @@ -0,0 +1,5 @@ +{% extends "ui/panels/_base.html" %} + +{% block panel_content %} +
{{ data|json }}
+{% endblock panel_content %}