diff --git a/docs/plugins/development/ui-components.md b/docs/plugins/development/ui-components.md new file mode 100644 index 00000000000..a8fe2eff0f2 --- /dev/null +++ b/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 diff --git a/mkdocs.yml b/mkdocs.yml index 078fc5e50cb..07628e775c6 100644 --- a/mkdocs.yml +++ b/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' diff --git a/netbox/dcim/ui/__init__.py b/netbox/dcim/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py new file mode 100644 index 00000000000..87ceb9c4a05 --- /dev/null +++ b/netbox/dcim/ui/panels.py @@ -0,0 +1,189 @@ +from django.utils.translation import gettext_lazy as _ + +from netbox.ui import attrs, panels + + +class SitePanel(panels.ObjectAttributesPanel): + region = attrs.NestedObjectAttr('region', linkify=True) + group = attrs.NestedObjectAttr('group', linkify=True) + name = attrs.TextAttr('name') + status = attrs.ChoiceAttr('status') + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') + facility = attrs.TextAttr('facility') + description = attrs.TextAttr('description') + timezone = attrs.TimezoneAttr('time_zone') + physical_address = attrs.AddressAttr('physical_address', map_url=True) + shipping_address = attrs.AddressAttr('shipping_address', map_url=True) + gps_coordinates = attrs.GPSCoordinatesAttr() + + +class LocationPanel(panels.NestedGroupObjectPanel): + site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group') + status = attrs.ChoiceAttr('status') + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') + facility = attrs.TextAttr('facility') + + +class RackDimensionsPanel(panels.ObjectAttributesPanel): + form_factor = attrs.ChoiceAttr('form_factor') + width = attrs.ChoiceAttr('width') + height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) + outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display') + outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display') + outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display') + mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm') + + +class RackNumberingPanel(panels.ObjectAttributesPanel): + starting_unit = attrs.TextAttr('starting_unit') + desc_units = attrs.BooleanAttr('desc_units', label=_('Descending units')) + + +class RackPanel(panels.ObjectAttributesPanel): + region = attrs.NestedObjectAttr('site.region', linkify=True) + site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group') + location = attrs.NestedObjectAttr('location', linkify=True) + name = attrs.TextAttr('name') + facility = attrs.TextAttr('facility', label=_('Facility ID')) + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') + status = attrs.ChoiceAttr('status') + rack_type = attrs.RelatedObjectAttr('rack_type', linkify=True, grouped_by='manufacturer') + role = attrs.RelatedObjectAttr('role', linkify=True) + description = attrs.TextAttr('description') + serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) + asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) + airflow = attrs.ChoiceAttr('airflow') + space_utilization = attrs.UtilizationAttr('get_utilization') + power_utilization = attrs.UtilizationAttr('get_power_utilization') + + +class RackWeightPanel(panels.ObjectAttributesPanel): + weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display') + max_weight = attrs.NumericAttr('max_weight', unit_accessor='get_weight_unit_display', label=_('Maximum weight')) + total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/rack/attrs/total_weight.html') + + +class RackRolePanel(panels.OrganizationalObjectPanel): + color = attrs.ColorAttr('color') + + +class RackReservationPanel(panels.ObjectAttributesPanel): + units = attrs.TextAttr('unit_list') + status = attrs.ChoiceAttr('status') + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') + user = attrs.RelatedObjectAttr('user') + description = attrs.TextAttr('description') + + +class RackTypePanel(panels.ObjectAttributesPanel): + manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True) + model = attrs.TextAttr('model') + description = attrs.TextAttr('description') + + +class DevicePanel(panels.ObjectAttributesPanel): + region = attrs.NestedObjectAttr('site.region', linkify=True) + site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group') + location = attrs.NestedObjectAttr('location', linkify=True) + rack = attrs.TemplatedAttr('rack', template_name='dcim/device/attrs/rack.html') + virtual_chassis = attrs.RelatedObjectAttr('virtual_chassis', linkify=True) + parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html') + gps_coordinates = attrs.GPSCoordinatesAttr() + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') + device_type = attrs.RelatedObjectAttr('device_type', linkify=True, grouped_by='manufacturer') + description = attrs.TextAttr('description') + airflow = attrs.ChoiceAttr('airflow') + serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) + asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) + config_template = attrs.RelatedObjectAttr('config_template', linkify=True) + + +class DeviceManagementPanel(panels.ObjectAttributesPanel): + title = _('Management') + + status = attrs.ChoiceAttr('status') + role = attrs.NestedObjectAttr('role', linkify=True, max_depth=3) + platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3) + primary_ip4 = attrs.TemplatedAttr( + 'primary_ip4', + label=_('Primary IPv4'), + template_name='dcim/device/attrs/ipaddress.html', + ) + primary_ip6 = attrs.TemplatedAttr( + 'primary_ip6', + label=_('Primary IPv6'), + template_name='dcim/device/attrs/ipaddress.html', + ) + oob_ip = attrs.TemplatedAttr( + 'oob_ip', + label=_('Out-of-band IP'), + template_name='dcim/device/attrs/ipaddress.html', + ) + cluster = attrs.RelatedObjectAttr('cluster', linkify=True) + + +class DeviceDimensionsPanel(panels.ObjectAttributesPanel): + title = _('Dimensions') + + height = attrs.TextAttr('device_type.u_height', format_string='{}U') + total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html') + + +class DeviceTypePanel(panels.ObjectAttributesPanel): + manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True) + model = attrs.TextAttr('model') + part_number = attrs.TextAttr('part_number') + default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True) + description = attrs.TextAttr('description') + height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) + exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization') + full_depth = attrs.BooleanAttr('is_full_depth') + weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display') + subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child')) + airflow = attrs.ChoiceAttr('airflow') + front_image = attrs.ImageAttr('front_image') + rear_image = attrs.ImageAttr('rear_image') + + +class ModuleTypeProfilePanel(panels.ObjectAttributesPanel): + name = attrs.TextAttr('name') + description = attrs.TextAttr('description') + + +class VirtualChassisMembersPanel(panels.ObjectPanel): + """ + A panel which lists all members of a virtual chassis. + """ + template_name = 'dcim/panels/virtual_chassis_members.html' + title = _('Virtual Chassis Members') + + def get_context(self, context): + return { + **super().get_context(context), + 'vc_members': context.get('vc_members'), + } + + def render(self, context): + if not context.get('vc_members'): + return '' + return super().render(context) + + +class PowerUtilizationPanel(panels.ObjectPanel): + """ + A panel which displays the power utilization statistics for a device. + """ + template_name = 'dcim/panels/power_utilization.html' + title = _('Power Utilization') + + def get_context(self, context): + return { + **super().get_context(context), + 'vc_members': context.get('vc_members'), + } + + def render(self, context): + obj = context['object'] + if not obj.powerports.exists() or not obj.poweroutlets.exists(): + return '' + return super().render(context) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a41078a112a..508b7e6f2ea 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger @@ -12,10 +13,17 @@ from django.views.generic import View from circuits.models import Circuit, CircuitTermination +from dcim.ui import panels +from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel from extras.views import ObjectConfigContextView, ObjectRenderConfigView from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * +from netbox.ui import actions, layout +from netbox.ui.panels import ( + CommentsPanel, JSONPanel, NestedGroupObjectPanel, ObjectsTablePanel, OrganizationalObjectPanel, RelatedObjectsPanel, + TemplatePanel, +) from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -221,6 +229,27 @@ class RegionListView(generic.ObjectListView): @register_model_view(Region) class RegionView(GetRelatedModelsMixin, generic.ObjectView): queryset = Region.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + NestedGroupObjectPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + ], + bottom_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}), + ], + ), + ] + ) def get_extra_context(self, request, instance): regions = instance.get_descendants(include_self=True) @@ -332,6 +361,27 @@ class SiteGroupListView(generic.ObjectListView): @register_model_view(SiteGroup) class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = SiteGroup.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + NestedGroupObjectPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.SiteGroup', + title=_('Child Groups'), + filters={'parent_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}), + ], + ), + ] + ) def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) @@ -461,6 +511,39 @@ class SiteListView(generic.ObjectListView): @register_model_view(Site) class SiteView(GetRelatedModelsMixin, generic.ObjectView): queryset = Site.objects.prefetch_related('tenant__group') + layout = layout.SimpleLayout( + left_panels=[ + panels.SitePanel(), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + ImageAttachmentsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.Location', + filters={'site_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject('dcim.Location', url_params={'site': lambda ctx: ctx['object'].pk}), + ], + ), + ObjectsTablePanel( + model='dcim.Device', + title=_('Non-Racked Devices'), + filters={ + 'site_id': lambda ctx: ctx['object'].pk, + 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, + 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, + }, + actions=[ + actions.AddObject('dcim.Device', url_params={'site': lambda ctx: ctx['object'].pk}), + ], + ), + ] + ) def get_extra_context(self, request, instance): return { @@ -561,6 +644,52 @@ class LocationListView(generic.ObjectListView): @register_model_view(Location) class LocationView(GetRelatedModelsMixin, generic.ObjectView): queryset = Location.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.LocationPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + ImageAttachmentsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.Location', + title=_('Child Locations'), + filters={'parent_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject( + 'dcim.Location', + url_params={ + 'site': lambda ctx: ctx['object'].site_id, + 'parent': lambda ctx: ctx['object'].pk, + } + ), + ], + ), + ObjectsTablePanel( + model='dcim.Device', + title=_('Non-Racked Devices'), + filters={ + 'location_id': lambda ctx: ctx['object'].pk, + 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, + 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, + }, + actions=[ + actions.AddObject( + 'dcim.Device', + url_params={ + 'site': lambda ctx: ctx['object'].site_id, + 'parent': lambda ctx: ctx['object'].pk, + } + ), + ], + ), + ] + ) def get_extra_context(self, request, instance): locations = instance.get_descendants(include_self=True) @@ -661,6 +790,16 @@ class RackRoleListView(generic.ObjectListView): @register_model_view(RackRole) class RackRoleView(GetRelatedModelsMixin, generic.ObjectView): queryset = RackRole.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.RackRolePanel(), + TagsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + CustomFieldsPanel(), + ], + ) def get_extra_context(self, request, instance): return { @@ -727,7 +866,22 @@ class RackTypeListView(generic.ObjectListView): @register_model_view(RackType) class RackTypeView(GetRelatedModelsMixin, generic.ObjectView): + template_name = 'generic/object.html' queryset = RackType.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.RackTypePanel(), + panels.RackDimensionsPanel(title=_('Dimensions')), + TagsPanel(), + CommentsPanel(), + ], + right_panels=[ + panels.RackNumberingPanel(title=_('Numbering')), + panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']), + CustomFieldsPanel(), + RelatedObjectsPanel(), + ], + ) def get_extra_context(self, request, instance): return { @@ -845,6 +999,22 @@ def get(self, request): @register_model_view(Rack) class RackView(GetRelatedModelsMixin, generic.ObjectView): queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') + layout = layout.SimpleLayout( + left_panels=[ + panels.RackPanel(), + panels.RackDimensionsPanel(title=_('Dimensions')), + panels.RackNumberingPanel(title=_('Numbering')), + panels.RackWeightPanel(title=_('Weight')), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ImageAttachmentsPanel(), + ], + right_panels=[ + TemplatePanel('dcim/panels/rack_elevations.html'), + RelatedObjectsPanel(), + ], + ) def get_extra_context(self, request, instance): peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site) @@ -976,6 +1146,19 @@ class RackReservationListView(generic.ObjectListView): @register_model_view(RackReservation) class RackReservationView(generic.ObjectView): queryset = RackReservation.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.RackPanel(accessor='object.rack', only=['region', 'site', 'location', 'name']), + panels.RackReservationPanel(title=_('Reservation')), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ], + right_panels=[ + TemplatePanel(template_name='dcim/panels/rack_reservation_elevations.html'), + RelatedObjectsPanel(), + ], + ) @register_model_view(RackReservation, 'add', detail=False) @@ -1049,6 +1232,10 @@ class ManufacturerListView(generic.ObjectListView): @register_model_view(Manufacturer) class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView): queryset = Manufacturer.objects.all() + layout = layout.SimpleLayout( + left_panels=[OrganizationalObjectPanel(), TagsPanel()], + right_panels=[RelatedObjectsPanel(), CustomFieldsPanel()], + ) def get_extra_context(self, request, instance): return { @@ -1122,6 +1309,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 { @@ -1372,7 +1571,36 @@ class ModuleTypeProfileListView(generic.ObjectListView): @register_model_view(ModuleTypeProfile) class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView): + template_name = 'generic/object.html' 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) @@ -2213,6 +2441,43 @@ class DeviceListView(generic.ObjectListView): @register_model_view(Device) class DeviceView(generic.ObjectView): queryset = Device.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.DevicePanel(), + panels.VirtualChassisMembersPanel(), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ObjectsTablePanel( + model='dcim.VirtualDeviceContext', + filters={'device_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject('dcim.VirtualDeviceContext', url_params={'device': lambda ctx: ctx['object'].pk}), + ], + ), + ], + right_panels=[ + panels.DeviceManagementPanel(), + panels.PowerUtilizationPanel(), + ObjectsTablePanel( + model='ipam.Service', + title=_('Application Services'), + filters={'device_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject( + 'ipam.Service', + url_params={ + 'parent_object_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk, + 'parent': lambda ctx: ctx['object'].pk + } + ), + ], + ), + ImageAttachmentsPanel(), + panels.DeviceDimensionsPanel(), + TemplatePanel('dcim/panels/device_rack_elevations.html'), + ], + ) def get_extra_context(self, request, instance): # VirtualChassis members @@ -2225,7 +2490,7 @@ def get_extra_context(self, request, instance): return { 'vc_members': vc_members, - 'svg_extra': f'highlight=id:{instance.pk}' + 'svg_extra': f'highlight=id:{instance.pk}', } diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py new file mode 100644 index 00000000000..f2f9a5c9af3 --- /dev/null +++ b/netbox/extras/ui/panels.py @@ -0,0 +1,68 @@ +from django.contrib.contenttypes.models import ContentType +from django.template.loader import render_to_string +from django.utils.translation import gettext_lazy as _ + +from netbox.ui import actions, panels +from utilities.data import resolve_attr_path + +__all__ = ( + 'CustomFieldsPanel', + 'ImageAttachmentsPanel', + 'TagsPanel', +) + + +class CustomFieldsPanel(panels.ObjectPanel): + """ + A panel showing the value of all custom fields defined on an object. + """ + template_name = 'extras/panels/custom_fields.html' + title = _('Custom Fields') + + def get_context(self, context): + obj = resolve_attr_path(context, self.accessor) + return { + **super().get_context(context), + 'custom_fields': obj.get_custom_fields_by_group(), + } + + def render(self, context): + ctx = self.get_context(context) + # Hide the panel if no custom fields exist + if not ctx['custom_fields']: + return '' + return render_to_string(self.template_name, self.get_context(context)) + + +class ImageAttachmentsPanel(panels.ObjectsTablePanel): + """ + A panel showing all images attached to the object. + """ + actions = [ + actions.AddObject( + 'extras.imageattachment', + url_params={ + 'object_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk, + 'object_id': lambda ctx: ctx['object'].pk, + 'return_url': lambda ctx: ctx['object'].get_absolute_url(), + }, + label=_('Attach an image'), + ), + ] + + def __init__(self, **kwargs): + super().__init__('extras.imageattachment', **kwargs) + + +class TagsPanel(panels.ObjectPanel): + """ + A panel showing the tags assigned to the object. + """ + template_name = 'extras/panels/tags.html' + title = _('Tags') + + def get_context(self, context): + return { + **super().get_context(context), + 'object': resolve_attr_path(context, self.accessor), + } diff --git a/netbox/netbox/ui/__init__.py b/netbox/netbox/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py new file mode 100644 index 00000000000..7579e7b9340 --- /dev/null +++ b/netbox/netbox/ui/actions.py @@ -0,0 +1,157 @@ +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 _ + +from utilities.permissions import get_permission_for_model +from utilities.views import get_viewname + +__all__ = ( + 'AddObject', + 'CopyContent', + 'LinkAction', + 'PanelAction', +) + + +class PanelAction: + """ + A link (typically a button) within a panel to perform some associated action, such as adding an object. + + Attributes: + 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 + + 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 (dict): The template context + """ + return { + 'label': self.label, + 'button_class': self.button_class, + 'button_icon': self.button_icon, + } + + def render(self, context): + """ + Render the action as HTML. + + Parameters: + context (dict): The template context + """ + # Enforce permissions + user = context['request'].user + if not user.has_perms(self.permissions): + return '' + + return render_to_string(self.template_name, self.get_context(context)) + + +class LinkAction(PanelAction): + """ + A hyperlink (typically a button) within a panel to perform some associated action, such as adding an object. + + 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): + super().__init__(**kwargs) + self.view_name = view_name + self.view_kwargs = view_kwargs or {} + self.url_params = url_params or {} + + def get_url(self, context): + """ + Resolve the URL for the action from its view name and kwargs. Append any additional URL parameters. + + Parameters: + context (dict): 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 { + **super().get_context(context), + 'url': self.get_url(context), + } + + +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 + """ + def __init__(self, model, url_params=None, **kwargs): + # Resolve the model class from its app.name label + 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') + + kwargs.setdefault('label', _('Add')) + kwargs.setdefault('button_icon', 'plus-thick') + kwargs.setdefault('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' + + def __init__(self, target_id, **kwargs): + kwargs.setdefault('label', _('Copy')) + kwargs.setdefault('button_icon', 'content-copy') + super().__init__(**kwargs) + self.target_id = target_id + + def render(self, 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 new file mode 100644 index 00000000000..bf55e3f3c4c --- /dev/null +++ b/netbox/netbox/ui/attrs.py @@ -0,0 +1,344 @@ +from django.template.loader import render_to_string +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + +from netbox.config import get_config +from utilities.data import resolve_attr_path + +__all__ = ( + 'AddressAttr', + 'BooleanAttr', + 'ColorAttr', + 'ChoiceAttr', + 'GPSCoordinatesAttr', + 'ImageAttr', + 'NestedObjectAttr', + 'NumericAttr', + 'ObjectAttribute', + 'RelatedObjectAttr', + 'TemplatedAttr', + 'TextAttr', + 'TimezoneAttr', + 'UtilizationAttr', +) + +PLACEHOLDER_HTML = '' + + +# +# Attributes +# + +class ObjectAttribute: + """ + Base class for representing an attribute of an object. + + Attributes: + 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): + self.accessor = accessor + if label is not None: + self.label = label + + def get_value(self, obj): + """ + Return the value of the attribute. + + Parameters: + obj (object): The object for which the attribute is being rendered + """ + return resolve_attr_path(obj, self.accessor) + + def get_context(self, obj, context): + """ + Return any additional template context used to render the attribute value. + + Parameters: + obj (object): The object for which the attribute is being rendered + context (dict): The root template context + """ + return {} + + def render(self, obj, context): + value = self.get_value(obj) + + # If the value is empty, render a placeholder + if value in (None, ''): + return self.placeholder + + return render_to_string(self.template_name, { + **self.get_context(obj, context), + 'name': context['name'], + 'value': value, + }) + + +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): + super().__init__(*args, **kwargs) + self.style = style + self.format_string = format_string + self.copy_button = copy_button + + def get_value(self, obj): + value = resolve_attr_path(obj, self.accessor) + # Apply format string (if any) + if value and self.format_string: + return self.format_string.format(value) + return value + + def get_context(self, obj, context): + return { + 'style': self.style, + 'copy_button': self.copy_button, + } + + +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): + super().__init__(*args, **kwargs) + self.unit_accessor = unit_accessor + self.copy_button = copy_button + + def get_context(self, obj, context): + unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None + return { + 'unit': unit, + 'copy_button': self.copy_button, + } + + +class ChoiceAttr(ObjectAttribute): + """ + A selection from a set of choices. + + The class calls get_FOO_display() on the object to retrieve the human-friendly choice label. If a get_FOO_color() + method exists on the object, it will be used to render a background color for the attribute value. + """ + template_name = 'ui/attrs/choice.html' + + def get_value(self, obj): + try: + return getattr(obj, f'get_{self.accessor}_display')() + except AttributeError: + return resolve_attr_path(obj, self.accessor) + + def get_context(self, obj, context): + try: + bg_color = getattr(obj, f'get_{self.accessor}_color')() + except AttributeError: + bg_color = None + return { + 'bg_color': bg_color, + } + + +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): + super().__init__(*args, **kwargs) + self.display_false = display_false + + def get_value(self, obj): + value = super().get_value(obj) + if value is False and self.display_false is False: + return None + return value + + +class ColorAttr(ObjectAttribute): + """ + An RGB color value. + """ + template_name = 'ui/attrs/color.html' + label = _('Color') + + +class ImageAttr(ObjectAttribute): + """ + An attribute representing an image field on the model. Displays the uploaded image. + """ + template_name = 'ui/attrs/image.html' + + +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): + super().__init__(*args, **kwargs) + self.linkify = linkify + self.grouped_by = grouped_by + + def get_context(self, obj, context): + value = self.get_value(obj) + group = getattr(value, self.grouped_by, None) if self.grouped_by else None + return { + 'linkify': self.linkify, + 'group': group, + } + + +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): + super().__init__(*args, **kwargs) + self.linkify = linkify + self.max_depth = max_depth + + def get_context(self, obj, context): + value = self.get_value(obj) + nodes = value.get_ancestors(include_self=True) + if self.max_depth: + nodes = list(nodes)[-self.max_depth:] + return { + 'nodes': nodes, + 'linkify': self.linkify, + } + + +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): + super().__init__(*args, **kwargs) + if map_url is True: + self.map_url = get_config().MAPS_URL + elif map_url: + self.map_url = map_url + else: + self.map_url = None + + def get_context(self, obj, context): + return { + 'map_url': self.map_url, + } + + +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): + super().__init__(accessor=None, **kwargs) + self.latitude_attr = latitude_attr + self.longitude_attr = longitude_attr + if map_url is True: + self.map_url = get_config().MAPS_URL + elif map_url: + self.map_url = map_url + else: + self.map_url = None + + def render(self, obj, context=None): + context = context or {} + latitude = resolve_attr_path(obj, self.latitude_attr) + longitude = resolve_attr_path(obj, self.longitude_attr) + if latitude is None or longitude is None: + return self.placeholder + return render_to_string(self.template_name, { + **context, + 'latitude': latitude, + 'longitude': longitude, + 'map_url': self.map_url, + }) + + +class TimezoneAttr(ObjectAttribute): + """ + A timezone value. Includes the numeric offset from UTC. + """ + template_name = 'ui/attrs/timezone.html' + + +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): + super().__init__(*args, **kwargs) + self.template_name = template_name + self.context = context or {} + + def get_context(self, obj, context): + return { + **self.context, + 'object': obj, + } + + +class UtilizationAttr(ObjectAttribute): + """ + Renders the value of an attribute as a utilization graph. + """ + template_name = 'ui/attrs/utilization.html' diff --git a/netbox/netbox/ui/layout.py b/netbox/netbox/ui/layout.py new file mode 100644 index 00000000000..b59fd7b3474 --- /dev/null +++ b/netbox/netbox/ui/layout.py @@ -0,0 +1,94 @@ +from netbox.ui.panels import Panel, PluginContentPanel + +__all__ = ( + 'Column', + 'Layout', + 'Row', + 'SimpleLayout', +) + + +# +# Base classes +# + +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): + if type(row) is not Row: + raise TypeError(f"Row {i} must be a Row instance, not {type(row)}.") + self.rows = rows + + +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): + if type(column) is not Column: + raise TypeError(f"Column {i} must be a Column instance, not {type(column)}.") + self.columns = columns + + +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): + if not isinstance(panel, Panel): + raise TypeError(f"Panel {i} must be an instance of a Panel, not {type(panel)}.") + self.panels = panels + + +# +# Common layouts +# + +class SimpleLayout(Layout): + """ + 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 | + +---------------+ + ``` + + 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 [] + right_panels = right_panels or [] + bottom_panels = bottom_panels or [] + rows = [ + Row( + Column(*left_panels, PluginContentPanel('left_page')), + Column(*right_panels, PluginContentPanel('right_page')), + ), + Row( + Column(*bottom_panels, PluginContentPanel('full_width_page')) + ) + ] + super().__init__(*rows) diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py new file mode 100644 index 00000000000..4d16cb8d348 --- /dev/null +++ b/netbox/netbox/ui/panels.py @@ -0,0 +1,341 @@ +from django.apps import apps +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.data import resolve_attr_path +from utilities.querydict import dict_to_querydict +from utilities.string import title +from utilities.templatetags.plugins import _get_registered_content +from utilities.views import get_viewname + +__all__ = ( + 'CommentsPanel', + 'JSONPanel', + 'NestedGroupObjectPanel', + 'ObjectAttributesPanel', + 'ObjectPanel', + 'ObjectsTablePanel', + 'OrganizationalObjectPanel', + 'Panel', + 'PluginContentPanel', + 'RelatedObjectsPanel', + 'TemplatePanel', +) + + +# +# Base classes +# + +class Panel: + """ + A block of content rendered within an HTML template. + + 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): + if title is not None: + self.title = title + self.actions = actions or self.actions or [] + + def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context (dict): The template context + """ + return { + 'request': context.get('request'), + 'object': context.get('object'), + 'title': self.title, + 'actions': self.actions, + 'panel_class': self.__class__.__name__, + } + + def render(self, context): + """ + Render the panel as HTML. + + Parameters: + context (dict): The template context + """ + return render_to_string(self.template_name, self.get_context(context)) + + +# +# Object-specific panels +# + +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): + super().__init__(**kwargs) + + if accessor is not None: + self.accessor = accessor + + def get_context(self, context): + obj = resolve_attr_path(context, self.accessor) + return { + **super().get_context(context), + 'title': self.title or title(obj._meta.verbose_name), + 'object': obj, + } + + +class ObjectAttributesPanelMeta(type): + + def __new__(mcls, name, bases, namespace, **kwargs): + declared = {} + + # Walk MRO parents (excluding `object`) for declared attributes + for base in reversed([b for b in bases if hasattr(b, "_attrs")]): + for key, attr in getattr(base, '_attrs', {}).items(): + if key not in declared: + declared[key] = attr + + # Add local declarations in the order they appear in the class body + for key, attr in namespace.items(): + if isinstance(attr, attrs.ObjectAttribute): + declared[key] = attr + + namespace['_attrs'] = declared + + # Remove Attrs from the class namespace to keep things tidy + local_items = [key for key, attr in namespace.items() if isinstance(attr, attrs.ObjectAttribute)] + for key in local_items: + namespace.pop(key) + + cls = super().__new__(mcls, name, bases, namespace, **kwargs) + return cls + + +class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta): + """ + A panel which displays selected attributes of an object. + + 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): + super().__init__(**kwargs) + + # Set included/excluded attributes + if only is not None and exclude is not None: + raise ValueError("only and exclude cannot both be specified.") + self.only = only or [] + self.exclude = exclude or [] + + @staticmethod + def _name_to_label(name): + """ + Format an attribute's name to be presented as a human-friendly label. + """ + label = name[:1].upper() + name[1:] + label = label.replace('_', ' ') + return label + + def get_context(self, context): + # Determine which attributes to display in the panel based on only/exclude args + attr_names = set(self._attrs.keys()) + if self.only: + attr_names &= set(self.only) + elif self.exclude: + attr_names -= set(self.exclude) + + ctx = super().get_context(context) + + return { + **ctx, + 'attrs': [ + { + 'label': attr.label or self._name_to_label(name), + 'value': attr.render(ctx['object'], {'name': name}), + } for name, attr in self._attrs.items() if name in attr_names + ], + } + + +class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta): + """ + An ObjectPanel with attributes common to OrganizationalModels. Includes `name` and `description` attributes. + """ + name = attrs.TextAttr('name', label=_('Name')) + description = attrs.TextAttr('description', label=_('Description')) + + +class NestedGroupObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta): + """ + An ObjectPanel with attributes common to NestedGroupObjects. Includes the `parent` attribute. + """ + parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) + name = attrs.TextAttr('name', label=_('Name')) + 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): + super().__init__(**kwargs) + self.field_name = field_name + + def get_context(self, context): + return { + **super().get_context(context), + 'comments': getattr(context['object'], self.field_name), + } + + +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): + 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 { + **super().get_context(context), + 'data': getattr(context['object'], self.field_name), + 'field_name': self.field_name, + } + + +# +# Miscellaneous panels +# + +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 { + **super().get_context(context), + 'related_models': context.get('related_models'), + } + + +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): + super().__init__(**kwargs) + + # Resolve the model class from its app.name label + 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): + url_params = { + k: v(context) if callable(v) else v for k, v in self.filters.items() + } + if 'return_url' not in url_params and 'object' in context: + url_params['return_url'] = context['object'].get_absolute_url() + return { + **super().get_context(context), + 'viewname': get_viewname(self.model, 'list'), + 'url_params': dict_to_querydict(url_params), + } + + +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): + super().__init__(**kwargs) + self.template_name = template_name + + def render(self, context): + # Pass the entire context to the template + return render_to_string(self.template_name, context.flatten()) + + +class PluginContentPanel(Panel): + """ + A panel which displays embedded plugin content. + + Parameters: + method (str): The name of the plugin method to render (e.g. "left_page") + """ + def __init__(self, method, **kwargs): + super().__init__(**kwargs) + self.method = method + + def render(self, context): + obj = context.get('object') + return _get_registered_content(obj, self.method, context) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 89719159209..88a3456f746 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -44,9 +44,11 @@ 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) """ + layout = None tab = None actions = (CloneObject, EditObject, DeleteObject) @@ -81,6 +83,7 @@ def get(self, request, **kwargs): 'object': instance, 'actions': actions, 'tab': self.tab, + 'layout': self.layout, **self.get_extra_context(request, instance), }) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index f8b8e95c2c1..9719d6df149 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -1,375 +1 @@ {% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} -{% load buttons %} -{% load static %} -{% load helpers %} -{% load plugins %} -{% load i18n %} -{% load l10n %} -{% load mptt %} - -{% block content %} -
-
-
-

{% trans "Device" %}

- - - - - - - - - - - - - - {% if object.virtual_chassis %} - - - - - {% endif %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Region" %}{% nested_tree object.site.region %}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Virtual Chassis" %}{{ object.virtual_chassis|linkify }}
{% trans "Rack" %} - {% if object.rack %} - {{ object.rack|linkify }} - - - - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Position" %} - {% if object.parent_bay %} - {% with object.parent_bay.device as parent %} - {{ parent|linkify }} / {{ object.parent_bay }} - {% if parent.position %} - (U{{ parent.position|floatformat }} / {{ parent.get_face_display }}) - {% endif %} - {% endwith %} - {% elif object.rack and object.position %} - U{{ object.position|floatformat }} / {{ object.get_face_display }} - {% elif object.rack and object.device_type.u_height %} - {% trans "Not racked" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "GPS Coordinates" %} - {% if object.latitude and object.longitude %} - {% if config.MAPS_URL %} - - {% endif %} - {{ object.latitude }}, {{ object.longitude }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Device Type" %} - {{ object.device_type|linkify:"full_name" }} ({{ object.device_type.u_height|floatformat }}U) -
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Airflow" %} - {{ object.get_airflow_display|placeholder }} -
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Config Template" %}{{ object.config_template|linkify|placeholder }}
-
- {% if vc_members %} -
-

- {% trans "Virtual Chassis" %} - -

- - - - - - - - - - - {% for vc_member in vc_members %} - - - - - - - {% endfor %} - -
{% trans "Device" %}{% trans "Position" %}{% trans "Master" %}{% trans "Priority" %}
{{ vc_member|linkify }}{% badge vc_member.vc_position show_empty=True %} - {% if object.virtual_chassis.master == vc_member %} - {% checkmark True %} - {% else %} - {{ ''|placeholder }} - {% endif %} - {{ vc_member.vc_priority|placeholder }}
-
- {% endif %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} -
-

- {% trans "Virtual Device Contexts" %} - {% if perms.dcim.add_virtualdevicecontext %} - - {% endif %} -

- {% htmx_table 'dcim:virtualdevicecontext_list' device_id=object.pk %} -
- {% plugin_left_page object %} -
-
-
-

{% trans "Management" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - {% if object.cluster %} - - - - - {% endif %} -
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Role" %}{{ object.role|linkify }}
{% trans "Platform" %}{{ object.platform|linkify|placeholder }}
{% trans "Primary IPv4" %} - {% if object.primary_ip4 %} - {{ object.primary_ip4.address.ip }} - {% if object.primary_ip4.nat_inside %} - ({% trans "NAT for" %} {{ object.primary_ip4.nat_inside.address.ip }}) - {% elif object.primary_ip4.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "primary_ip4" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Primary IPv6" %} - {% if object.primary_ip6 %} - {{ object.primary_ip6.address.ip }} - {% if object.primary_ip6.nat_inside %} - ({% trans "NAT for" %} {{ object.primary_ip6.nat_inside.address.ip }}) - {% elif object.primary_ip6.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "primary_ip6" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
Out-of-band IP - {% if object.oob_ip %} - {{ object.oob_ip.address.ip }} - {% if object.oob_ip.nat_inside %} - ({% trans "NAT for" %} {{ object.oob_ip.nat_inside.address.ip }}) - {% elif object.oob_ip.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.oob_ip.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "oob_ip" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Cluster" %} - {% if object.cluster.group %} - {{ object.cluster.group|linkify }} / - {% endif %} - {{ object.cluster|linkify }} -
-
- {% if object.powerports.exists and object.poweroutlets.exists %} -
-

{% trans "Power Utilization" %}

- - - - - - - - - - - {% for powerport in object.powerports.all %} - {% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoints.0 %} - - - - - {% if powerfeed.available_power %} - - - {% else %} - - - {% endif %} - - {% for leg in utilization.legs %} - - - - - {% if powerfeed.available_power %} - {% with phase_available=powerfeed.available_power|divide:3 %} - - - {% endwith %} - {% else %} - - - {% endif %} - - {% endfor %} - {% endwith %} - {% endfor %} -
{% trans "Input" %}{% trans "Outlets" %}{% trans "Allocated" %}{% trans "Available" %}{% trans "Utilization" %}
{{ powerport }}{{ utilization.outlet_count }}{{ utilization.allocated }}{% trans "VA" %}{{ powerfeed.available_power }}{% trans "VA" %}{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
- {% trans "Leg" context "Leg of a power feed" %} {{ leg.name }} - {{ leg.outlet_count }}{{ leg.allocated }}{{ phase_available }}{% trans "VA" %}{% utilization_graph leg.allocated|percentage:phase_available %}
-
- {% endif %} -
-

- {% trans "Application Services" %} - {% if perms.ipam.add_service %} - - {% endif %} -

- {% htmx_table 'ipam:service_list' device_id=object.pk %} -
- {% include 'inc/panels/image_attachments.html' %} -
-

{% trans "Dimensions" %}

- - - - - - - - - -
{% trans "Height" %} - {{ object.device_type.u_height }}U -
{% trans "Weight" %} - {% if object.total_weight %} - {{ object.total_weight|floatformat }} {% trans "Kilograms" %} - ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %}) - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
- {% if object.rack and object.position %} -
-
- {{ object.rack.name }} - {% if object.rack.role %} -
{{ object.rack.role }} - {% endif %} - {% if object.rack.facility_id %} -
{{ object.rack.facility_id }} - {% endif %} -
-
-
-

{% trans "Front" %}

- {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %} -
-
-
-
-

{% trans "Rear" %}

- {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %} -
-
-
- {% endif %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/device/attrs/ipaddress.html b/netbox/templates/dcim/device/attrs/ipaddress.html new file mode 100644 index 00000000000..7b434565760 --- /dev/null +++ b/netbox/templates/dcim/device/attrs/ipaddress.html @@ -0,0 +1,10 @@ +{% load i18n %} +{{ value.address.ip }} +{% if value.nat_inside %} + ({% trans "NAT for" %} {{ value.nat_inside.address.ip }}) +{% elif value.nat_outside.exists %} + ({% trans "NAT" %}: {% for nat in value.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) +{% endif %} + + + diff --git a/netbox/templates/dcim/device/attrs/parent_device.html b/netbox/templates/dcim/device/attrs/parent_device.html new file mode 100644 index 00000000000..6351f792a2f --- /dev/null +++ b/netbox/templates/dcim/device/attrs/parent_device.html @@ -0,0 +1,10 @@ +{% load i18n %} + +{% if value.device.position %} + + + +{% endif %} diff --git a/netbox/templates/dcim/device/attrs/rack.html b/netbox/templates/dcim/device/attrs/rack.html new file mode 100644 index 00000000000..d939e9ca3ba --- /dev/null +++ b/netbox/templates/dcim/device/attrs/rack.html @@ -0,0 +1,14 @@ +{% load i18n %} + + {{ value|linkify }} + {% if value and object.position %} + (U{{ object.position|floatformat }} / {{ object.get_face_display }}) + {% elif value and object.device_type.u_height %} + {% trans "Not racked" %} + {% endif %} + +{% if object.position %} + + + +{% endif %} diff --git a/netbox/templates/dcim/device/attrs/total_weight.html b/netbox/templates/dcim/device/attrs/total_weight.html new file mode 100644 index 00000000000..73ac54ef551 --- /dev/null +++ b/netbox/templates/dcim/device/attrs/total_weight.html @@ -0,0 +1,3 @@ +{% load helpers i18n %} +{{ value|floatformat }} {% trans "Kilograms" %} +({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %}) diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 909b5276e73..4c9c678dcbf 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -1,112 +1 @@ {% extends 'dcim/devicetype/base.html' %} -{% load buttons %} -{% load helpers %} -{% load plugins %} -{% load i18n %} - -{% block content %} -
-
-
-

{% trans "Chassis" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Manufacturer" %}{{ object.manufacturer|linkify }}
{% trans "Model Name" %} - {{ object.model }}
- {{ object.slug }} -
{% trans "Part Number" %}{{ object.part_number|placeholder }}
{% trans "Default Platform" %}{{ object.default_platform|linkify }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Height (U)" %}{{ object.u_height|floatformat }}
{% trans "Exclude From Utilization" %}{% checkmark object.exclude_from_utilization %}
{% trans "Full Depth" %}{% checkmark object.is_full_depth %}
{% trans "Weight" %} - {% if object.weight %} - {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Parent/Child" %} - {{ object.get_subdevice_role_display|placeholder }} -
{% trans "Airflow" %} - {{ object.get_airflow_display|placeholder }} -
{% trans "Front Image" %} - {% if object.front_image %} - - {{ object.front_image.name }} - - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Rear Image" %} - {% if object.rear_image %} - - {{ object.rear_image.name }} - - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index dfd0c32b31a..07c64305a0b 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -1,8 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} -{% load i18n %} {% block breadcrumbs %} {{ block.super }} @@ -10,96 +6,3 @@ {% endfor %} {% endblock %} - -{% block extra_controls %} - {% if perms.dcim.add_location %} - - {% trans "Add Child Location" %} - - {% endif %} -{% endblock extra_controls %} - -{% block content %} -
-
-
-

{% trans "Location" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Facility" %}{{ object.facility|placeholder }}
-
- {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} -
-
-
-
-
-

- {% trans "Child Locations" %} - {% if perms.dcim.add_location %} - - {% endif %} -

- {% htmx_table 'dcim:location_list' parent_id=object.pk %} -
-
-

- {% trans "Non-Racked Devices" %} - {% if perms.dcim.add_device %} - - {% endif %} -

- {% htmx_table 'dcim:device_list' location_id=object.pk rack_id='null' parent_bay_id='null' %} -
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index cb6251f63e0..f6bd7dfc3b0 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -1,7 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} {% load i18n %} {% block extra_controls %} @@ -25,35 +22,3 @@ {% endif %} {% endblock extra_controls %} - -{% block content %} -
-
-
-

{% trans "Manufacturer" %}

- - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/moduletypeprofile.html b/netbox/templates/dcim/moduletypeprofile.html deleted file mode 100644 index 87e576bda9b..00000000000 --- a/netbox/templates/dcim/moduletypeprofile.html +++ /dev/null @@ -1,59 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load helpers %} -{% load plugins %} -{% load i18n %} - -{% block title %}{{ object.name }}{% endblock %} - -{% block content %} -
-
-
-

{% trans "Module Type Profile" %}

- - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
-
- {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
-
-

- {% trans "Schema" %} - {% copy_content 'profile_schema' %} -

-
{{ object.schema|json }}
-
- {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} -
-
-
-
-
-

- {% trans "Module Types" %} - {% if perms.dcim.add_moduletype %} - - {% endif %} -

- {% htmx_table 'dcim:moduletype_list' profile_id=object.pk %} -
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/panels/device_rack_elevations.html b/netbox/templates/dcim/panels/device_rack_elevations.html new file mode 100644 index 00000000000..1816be5c986 --- /dev/null +++ b/netbox/templates/dcim/panels/device_rack_elevations.html @@ -0,0 +1,26 @@ +{% load i18n %} +{% if object.rack and object.position %} +
+
+ {{ object.rack.name }} + {% if object.rack.role %} +
{{ object.rack.role }} + {% endif %} + {% if object.rack.facility_id %} +
{{ object.rack.facility_id }} + {% endif %} +
+
+
+

{% trans "Front" %}

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %} +
+
+
+
+

{% trans "Rear" %}

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %} +
+
+
+{% endif %} diff --git a/netbox/templates/dcim/panels/power_utilization.html b/netbox/templates/dcim/panels/power_utilization.html new file mode 100644 index 00000000000..b716ed2c98d --- /dev/null +++ b/netbox/templates/dcim/panels/power_utilization.html @@ -0,0 +1,50 @@ +{% extends "ui/panels/_base.html" %} +{% load helpers i18n %} + +{% block panel_content %} + + + + + + + + + + + {% for powerport in object.powerports.all %} + {% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoints.0 %} + + + + + {% if powerfeed.available_power %} + + + {% else %} + + + {% endif %} + + {% for leg in utilization.legs %} + + + + + {% if powerfeed.available_power %} + {% with phase_available=powerfeed.available_power|divide:3 %} + + + {% endwith %} + {% else %} + + + {% endif %} + + {% endfor %} + {% endwith %} + {% endfor %} +
{% trans "Input" %}{% trans "Outlets" %}{% trans "Allocated" %}{% trans "Available" %}{% trans "Utilization" %}
{{ powerport }}{{ utilization.outlet_count }}{{ utilization.allocated }}{% trans "VA" %}{{ powerfeed.available_power }}{% trans "VA" %}{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
+ {% trans "Leg" context "Leg of a power feed" %} {{ leg.name }} + {{ leg.outlet_count }}{{ leg.allocated }}{{ phase_available }}{% trans "VA" %}{% utilization_graph leg.allocated|percentage:phase_available %}
+{% endblock panel_content %} diff --git a/netbox/templates/dcim/panels/rack_elevations.html b/netbox/templates/dcim/panels/rack_elevations.html new file mode 100644 index 00000000000..550f54f3b34 --- /dev/null +++ b/netbox/templates/dcim/panels/rack_elevations.html @@ -0,0 +1,22 @@ +{% load i18n %} +
+ +
+
+
+
+

{% trans "Front" %}

+ {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %} +
+
+
+
+

{% trans "Rear" %}

+ {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %} +
+
+
diff --git a/netbox/templates/dcim/panels/rack_reservation_elevations.html b/netbox/templates/dcim/panels/rack_reservation_elevations.html new file mode 100644 index 00000000000..dab57f2426a --- /dev/null +++ b/netbox/templates/dcim/panels/rack_reservation_elevations.html @@ -0,0 +1,15 @@ +{% load i18n %} +
+
+
+

{% trans "Front" %}

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %} +
+
+
+
+

{% trans "Rear" %}

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %} +
+
+
diff --git a/netbox/templates/dcim/panels/virtual_chassis_members.html b/netbox/templates/dcim/panels/virtual_chassis_members.html new file mode 100644 index 00000000000..29e422ea64e --- /dev/null +++ b/netbox/templates/dcim/panels/virtual_chassis_members.html @@ -0,0 +1,31 @@ +{% extends "ui/panels/_base.html" %} +{% load i18n %} + +{% block panel_content %} + + + + + + + + + + + {% for vc_member in vc_members %} + + + + + + + {% endfor %} + +
{% trans "Device" %}{% trans "Position" %}{% trans "Master" %}{% trans "Priority" %}
{{ vc_member|linkify }}{% badge vc_member.vc_position show_empty=True %} + {% if object.virtual_chassis.master == vc_member %} + {% checkmark True %} + {% else %} + {{ ''|placeholder }} + {% endif %} + {{ vc_member.vc_priority|placeholder }}
+{% endblock panel_content %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index eec4d63a583..a8f85e1f145 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -1,153 +1 @@ {% extends 'dcim/rack/base.html' %} -{% load buttons %} -{% load helpers %} -{% load static %} -{% load plugins %} -{% load i18n %} -{% load mptt %} - -{% block content %} -
-
-
-

{% trans "Rack" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Region" %}{% nested_tree object.site.region %}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Facility ID" %}{{ object.facility_id|placeholder }}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Rack Type" %}{{ object.rack_type|linkify:"full_name"|placeholder }}
{% trans "Role" %}{{ object.role|linkify|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Airflow" %}{{ object.get_airflow_display|placeholder }}
{% trans "Space Utilization" %}{% utilization_graph object.get_utilization %}
{% trans "Power Utilization" %}{% utilization_graph object.get_power_utilization %}
-
- {% include 'dcim/inc/panels/racktype_dimensions.html' %} - {% include 'dcim/inc/panels/racktype_numbering.html' %} -
-

{% trans "Weight" %}

- - - - - - - - - - - - - -
{% trans "Rack Weight" %} - {% if object.weight %} - {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Maximum Weight" %} - {% if object.max_weight %} - {{ object.max_weight }} {{ object.get_weight_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Total Weight" %} - {% if object.total_weight %} - {{ object.total_weight|floatformat }} {% trans "Kilograms" %} - ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %}) - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_left_page object %} -
-
-
- -
-
-
-
-

{% trans "Front" %}

- {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %} -
-
-
-
-

{% trans "Rear" %}

- {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %} -
-
-
- {% include 'inc/panels/related_objects.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/rack/attrs/total_weight.html b/netbox/templates/dcim/rack/attrs/total_weight.html new file mode 100644 index 00000000000..2db1d13e87a --- /dev/null +++ b/netbox/templates/dcim/rack/attrs/total_weight.html @@ -0,0 +1,3 @@ +{% load i18n %} +{{ value|floatformat }} {% trans "Kilograms" %} +({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %}) diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 87c4f7e4b68..405117041ef 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -1,99 +1,8 @@ {% extends 'generic/object.html' %} -{% load buttons %} -{% load helpers %} -{% load static %} -{% load plugins %} {% load i18n %} -{% load mptt %} {% block breadcrumbs %} {{ block.super }} {% endblock %} - -{% block content %} -
-
-
-

{% trans "Rack" %}

- - - - - - - - - - - - - - - - - -
{% trans "Region" %} - {% nested_tree object.rack.site.region %} -
{% trans "Site" %}{{ object.rack.site|linkify }}
{% trans "Location" %}{{ object.rack.location|linkify|placeholder }}
{% trans "Rack" %}{{ object.rack|linkify }}
-
-
-

{% trans "Reservation Details" %}

- - - - - - - - - - - - - - - - - - - - - -
{% trans "Units" %}{{ object.unit_list }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "User" %}{{ object.user }}
{% trans "Description" %}{{ object.description }}
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
-
-
-
-

{% trans "Front" %}

- {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' %} -
-
-
-
-

{% trans "Rear" %}

- {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' %} -
-
-
- {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/rackrole.html b/netbox/templates/dcim/rackrole.html index 87341d90e0b..5466eea2165 100644 --- a/netbox/templates/dcim/rackrole.html +++ b/netbox/templates/dcim/rackrole.html @@ -1,7 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} {% load i18n %} {% block extra_controls %} @@ -11,41 +8,3 @@ {% endif %} {% endblock extra_controls %} - -{% block content %} -
-
-
-

{% trans "Rack Role" %}

- - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Color" %} -   -
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/racktype.html b/netbox/templates/dcim/racktype.html deleted file mode 100644 index cfeba02fea9..00000000000 --- a/netbox/templates/dcim/racktype.html +++ /dev/null @@ -1,75 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load helpers %} -{% load static %} -{% load plugins %} -{% load i18n %} -{% load mptt %} - -{% block content %} -
-
-
-

{% trans "Rack Type" %}

- - - - - - - - - - - - - - - - - -
{% trans "Manufacturer" %}{{ object.manufacturer|linkify }}
{% trans "Model" %}{{ object.model }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Airflow" %}{{ object.get_airflow_display|placeholder }}
-
- {% include 'dcim/inc/panels/racktype_dimensions.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
- {% include 'dcim/inc/panels/racktype_numbering.html' %} -
-

{% trans "Weight" %}

- - - - - - - - - -
{% trans "Rack Weight" %} - {% if object.weight %} - {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Maximum Weight" %} - {% if object.max_weight %} - {{ object.max_weight }} {{ object.get_weight_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/related_objects.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index f11868b0a29..8fdf314ae98 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -1,7 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} {% load i18n %} {% block breadcrumbs %} @@ -18,53 +15,3 @@ {% endif %} {% endblock extra_controls %} - -{% block content %} -
-
-
-

{% trans "Region" %}

- - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
-
- {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% plugin_right_page object %} -
-
-
-
-
-

- {% trans "Child Regions" %} - {% if perms.dcim.add_region %} - - {% endif %} -

- {% htmx_table 'dcim:region_list' parent_id=object.pk %} -
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index cf65961d966..3cefe59b15a 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -1,10 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load tz %} -{% load i18n %} -{% load l10n %} -{% load mptt %} {% block breadcrumbs %} {{ block.super }} @@ -20,135 +14,3 @@ {% endif %} {% endblock %} - -{% block content %} -
-
-
-

{% trans "Site" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Region" %} - {% nested_tree object.region %} -
{% trans "Group" %} - {% nested_tree object.group %} -
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Facility" %}{{ object.facility|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Time Zone" %} - {% if object.time_zone %} - {{ object.time_zone }} ({% trans "UTC" %} {{ object.time_zone|tzoffset }})
- {% trans "Site time" %}: {% timezone object.time_zone %}{% now 'Y-m-d H:i' %}{% endtimezone %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Physical Address" %} - {% if object.physical_address %} - {{ object.physical_address|linebreaksbr }} - {% if config.MAPS_URL %} - - {% trans "Map" %} - - {% endif %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Shipping Address" %}{{ object.shipping_address|linebreaksbr|placeholder }}
{% trans "GPS Coordinates" %} - {% if object.latitude and object.longitude %} - {% if config.MAPS_URL %} - - {% endif %} - {{ object.latitude }}, {{ object.longitude }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' with filter_name='site_id' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} -
-
-
-
-
-

- {% trans "Locations" %} - {% if perms.dcim.add_location %} - - {% endif %} -

- {% htmx_table 'dcim:location_list' site_id=object.pk %} -
-
-

- {% trans "Non-Racked Devices" %} - {% if perms.dcim.add_device %} - - {% endif %} -

- {% htmx_table 'dcim:device_list' site_id=object.pk rack_id='null' parent_bay_id='null' %} -
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index dc9aca6f5ae..b25156ea208 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -1,7 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} {% load i18n %} {% block breadcrumbs %} @@ -18,53 +15,3 @@ {% endif %} {% endblock extra_controls %} - -{% block content %} -
-
-
-

{% trans "Site Group" %}

- - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
-
- {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% plugin_right_page object %} -
-
-
-
-
-

- {% trans "Child Groups" %} - {% if perms.dcim.add_sitegroup %} - - {% endif %} -

- {% htmx_table 'dcim:sitegroup_list' parent_id=object.pk %} -
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/extras/panels/custom_fields.html b/netbox/templates/extras/panels/custom_fields.html new file mode 100644 index 00000000000..d0b1c56861f --- /dev/null +++ b/netbox/templates/extras/panels/custom_fields.html @@ -0,0 +1,31 @@ +{% extends "ui/panels/_base.html" %} +{% load i18n %} + +{% block panel_content %} + + {% for group_name, fields in custom_fields.items %} + {% if group_name %} + + + + {% endif %} + {% for field, value in fields.items %} + + + + + {% endfor %} + {% endfor %} +
{{ group_name }}
{{ field }} + {% if field.description %} + + {% endif %} + + {% customfield_value field value %} +
+{% endblock panel_content %} diff --git a/netbox/templates/extras/panels/tags.html b/netbox/templates/extras/panels/tags.html new file mode 100644 index 00000000000..d505dc48de6 --- /dev/null +++ b/netbox/templates/extras/panels/tags.html @@ -0,0 +1,15 @@ +{% extends "ui/panels/_base.html" %} +{% load helpers %} +{% load i18n %} + +{% block panel_content %} +
+ {% with url=object|validated_viewname:"list" %} + {% for tag in object.tags.all %} + {% tag tag url %} + {% empty %} + {% trans "No tags assigned" %} + {% endfor %} + {% endwith %} +
+{% endblock panel_content %} diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index df95a4a42b4..100d5bde775 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -122,7 +122,20 @@ {% plugin_alerts object %} {% endblock alerts %} -{% block content %}{% endblock %} +{% block content %} + {# Render panel layout declared on view class #} + {% for row in layout.rows %} +
+ {% for column in row.columns %} +
+ {% for panel in column.panels %} + {% render panel %} + {% endfor %} +
+ {% endfor %} +
+ {% endfor %} +{% endblock %} {% block modals %} {% include 'inc/htmx_modal.html' %} diff --git a/netbox/templates/ui/actions/copy_content.html b/netbox/templates/ui/actions/copy_content.html new file mode 100644 index 00000000000..67f54354b09 --- /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/actions/link.html b/netbox/templates/ui/actions/link.html new file mode 100644 index 00000000000..11c6b6da9ad --- /dev/null +++ b/netbox/templates/ui/actions/link.html @@ -0,0 +1,6 @@ + + {% if button_icon %} + + {% endif %} + {{ label }} + diff --git a/netbox/templates/ui/attrs/address.html b/netbox/templates/ui/attrs/address.html new file mode 100644 index 00000000000..08f46fc43cd --- /dev/null +++ b/netbox/templates/ui/attrs/address.html @@ -0,0 +1,8 @@ +{% load i18n %} +{% load l10n %} +{{ value|linebreaksbr }} +{% if map_url %} + + {% trans "Map" %} + +{% endif %} diff --git a/netbox/templates/ui/attrs/boolean.html b/netbox/templates/ui/attrs/boolean.html new file mode 100644 index 00000000000..a7087c94f7c --- /dev/null +++ b/netbox/templates/ui/attrs/boolean.html @@ -0,0 +1 @@ +{% checkmark value %} diff --git a/netbox/templates/ui/attrs/choice.html b/netbox/templates/ui/attrs/choice.html new file mode 100644 index 00000000000..197d4c2fc1d --- /dev/null +++ b/netbox/templates/ui/attrs/choice.html @@ -0,0 +1,5 @@ +{% if bg_color %} + {% badge value bg_color=bg_color %} +{% else %} + {{ value }} +{% endif %} diff --git a/netbox/templates/ui/attrs/color.html b/netbox/templates/ui/attrs/color.html new file mode 100644 index 00000000000..78e1cfff3cc --- /dev/null +++ b/netbox/templates/ui/attrs/color.html @@ -0,0 +1 @@ +  diff --git a/netbox/templates/ui/attrs/gps_coordinates.html b/netbox/templates/ui/attrs/gps_coordinates.html new file mode 100644 index 00000000000..8e72f08bb6d --- /dev/null +++ b/netbox/templates/ui/attrs/gps_coordinates.html @@ -0,0 +1,8 @@ +{% load i18n %} +{% load l10n %} +{{ latitude }}, {{ longitude }} +{% if map_url %} + + {% trans "Map" %} + +{% endif %} diff --git a/netbox/templates/ui/attrs/image.html b/netbox/templates/ui/attrs/image.html new file mode 100644 index 00000000000..3c10113c4ed --- /dev/null +++ b/netbox/templates/ui/attrs/image.html @@ -0,0 +1,3 @@ + + {{ value.name }} + diff --git a/netbox/templates/ui/attrs/nested_object.html b/netbox/templates/ui/attrs/nested_object.html new file mode 100644 index 00000000000..8cae0818979 --- /dev/null +++ b/netbox/templates/ui/attrs/nested_object.html @@ -0,0 +1,11 @@ + diff --git a/netbox/templates/ui/attrs/numeric.html b/netbox/templates/ui/attrs/numeric.html new file mode 100644 index 00000000000..5c54f2979dc --- /dev/null +++ b/netbox/templates/ui/attrs/numeric.html @@ -0,0 +1,12 @@ +{% load i18n %} + + {{ value }} + {% if unit %} + {{ unit|lower }} + {% endif %} + +{% if copy_button %} + + + +{% endif %} diff --git a/netbox/templates/ui/attrs/object.html b/netbox/templates/ui/attrs/object.html new file mode 100644 index 00000000000..58fce231652 --- /dev/null +++ b/netbox/templates/ui/attrs/object.html @@ -0,0 +1,14 @@ +{% if group %} + {# Display an object with its parent group #} + +{% else %} + {# Display only the object #} + {% if linkify %}{{ value|linkify }}{% else %}{{ value }}{% endif %} +{% endif %} diff --git a/netbox/templates/ui/attrs/text.html b/netbox/templates/ui/attrs/text.html new file mode 100644 index 00000000000..459c9ab8d4f --- /dev/null +++ b/netbox/templates/ui/attrs/text.html @@ -0,0 +1,7 @@ +{% load i18n %} +{{ value }} +{% if copy_button %} + + + +{% endif %} diff --git a/netbox/templates/ui/attrs/timezone.html b/netbox/templates/ui/attrs/timezone.html new file mode 100644 index 00000000000..7492d72ada9 --- /dev/null +++ b/netbox/templates/ui/attrs/timezone.html @@ -0,0 +1,6 @@ +{% load i18n %} +{% load tz %} +
+ {{ value }} ({% trans "UTC" %} {{ value|tzoffset }})
+ {% trans "Local time" %}: {% timezone value %}{% now 'Y-m-d H:i' %}{% endtimezone %} +
diff --git a/netbox/templates/ui/attrs/utilization.html b/netbox/templates/ui/attrs/utilization.html new file mode 100644 index 00000000000..6e1db73f1b2 --- /dev/null +++ b/netbox/templates/ui/attrs/utilization.html @@ -0,0 +1,2 @@ +{% load helpers %} +{% utilization_graph value %} diff --git a/netbox/templates/ui/panels/_base.html b/netbox/templates/ui/panels/_base.html new file mode 100644 index 00000000000..5128e6428d1 --- /dev/null +++ b/netbox/templates/ui/panels/_base.html @@ -0,0 +1,15 @@ + +
+

+ {{ title }} + {% if actions %} +
+ {% for action in actions %} + {% render action %} + {% endfor %} +
+ {% endif %} +

+ {% block panel_content %}{% endblock %} +
+ diff --git a/netbox/templates/ui/panels/comments.html b/netbox/templates/ui/panels/comments.html new file mode 100644 index 00000000000..de32162ce55 --- /dev/null +++ b/netbox/templates/ui/panels/comments.html @@ -0,0 +1,12 @@ +{% extends "ui/panels/_base.html" %} +{% load i18n %} + +{% block panel_content %} +
+ {% if comments %} + {{ comments|markdown }} + {% else %} + {% trans "None" %} + {% endif %} +
+{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/embedded_table.html b/netbox/templates/ui/panels/embedded_table.html new file mode 100644 index 00000000000..64579705f0c --- /dev/null +++ b/netbox/templates/ui/panels/embedded_table.html @@ -0,0 +1,5 @@ +{% extends "ui/panels/_base.html" %} + +{% block panel_content %} + {% include 'builtins/htmx_table.html' %} +{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/json.html b/netbox/templates/ui/panels/json.html new file mode 100644 index 00000000000..36d3d4d1a42 --- /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 %} diff --git a/netbox/templates/ui/panels/object_attributes.html b/netbox/templates/ui/panels/object_attributes.html new file mode 100644 index 00000000000..399a0081e8a --- /dev/null +++ b/netbox/templates/ui/panels/object_attributes.html @@ -0,0 +1,14 @@ +{% extends "ui/panels/_base.html" %} + +{% block panel_content %} + + {% for attr in attrs %} + + + + + {% endfor %} +
{{ attr.label }} +
{{ attr.value }}
+
+{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/objects_table.html b/netbox/templates/ui/panels/objects_table.html new file mode 100644 index 00000000000..64579705f0c --- /dev/null +++ b/netbox/templates/ui/panels/objects_table.html @@ -0,0 +1,5 @@ +{% extends "ui/panels/_base.html" %} + +{% block panel_content %} + {% include 'builtins/htmx_table.html' %} +{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/related_objects.html b/netbox/templates/ui/panels/related_objects.html new file mode 100644 index 00000000000..29d6dc6c4a4 --- /dev/null +++ b/netbox/templates/ui/panels/related_objects.html @@ -0,0 +1,25 @@ +{% extends "ui/panels/_base.html" %} +{% load helpers %} +{% load i18n %} + +{% block panel_content %} + +{% endblock panel_content %} diff --git a/netbox/utilities/data.py b/netbox/utilities/data.py index 617a31cd6e6..36fd0f7fc4f 100644 --- a/netbox/utilities/data.py +++ b/netbox/utilities/data.py @@ -12,6 +12,7 @@ 'flatten_dict', 'ranges_to_string', 'ranges_to_string_list', + 'resolve_attr_path', 'shallow_compare_dict', 'string_to_ranges', ) @@ -213,3 +214,26 @@ def string_to_ranges(value): return None values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)')) return values + + +# +# Attribute resolution +# + +def resolve_attr_path(obj, path): + """ + Follow a dotted path across attributes and/or dictionary keys and return the final value. + + Parameters: + obj: The starting object + path: The dotted path to follow (e.g. "foo.bar.baz") + """ + cur = obj + for part in path.split('.'): + if cur is None: + return None + try: + cur = getattr(cur, part) if hasattr(cur, part) else cur.get(part) + except AttributeError: + cur = None + return cur diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 8a275f44bc9..663bf564702 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -3,6 +3,7 @@ from django import template from django.templatetags.static import static +from django.utils.safestring import mark_safe from extras.choices import CustomFieldTypeChoices from utilities.querydict import dict_to_querydict @@ -179,3 +180,11 @@ def static_with_params(path, **params): # Reconstruct the URL with the new query string new_parsed = parsed._replace(query=new_query) return urlunparse(new_parsed) + + +@register.simple_tag(takes_context=True) +def render(context, component): + """ + Render a UI component (e.g. a Panel) by calling its render() method and passing the current template context. + """ + return mark_safe(component.render(context))