Initial work on #20204

This commit is contained in:
Jeremy Stretch
2025-10-29 17:14:55 -04:00
parent 068d493cc6
commit fd3a9a0c37
10 changed files with 247 additions and 1 deletions

View File

@@ -16,6 +16,9 @@ from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.object_actions import * from netbox.object_actions import *
from netbox.templates.components import (
AttributesPanel, EmbeddedTemplate, GPSCoordinatesAttr, NestedObjectAttr, ObjectAttr, TextAttr,
)
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -2223,9 +2226,28 @@ class DeviceView(generic.ObjectView):
else: else:
vc_members = [] vc_members = []
device_attrs = AttributesPanel(_('Device'), {
_('Region'): NestedObjectAttr(instance.site.region, linkify=True),
_('Site'): ObjectAttr(instance.site, linkify=True, grouped_by='group'),
_('Location'): ObjectAttr(instance.location, linkify=True),
# TODO: Include position & face of parent device (if applicable)
_('Rack'): EmbeddedTemplate('dcim/device/attrs/rack.html', {'device': instance}),
_('Virtual Chassis'): ObjectAttr(instance.virtual_chassis, linkify=True),
_('Parent Device'): EmbeddedTemplate('dcim/device/attrs/parent_device.html', {'device': instance}),
_('GPS Coordinates'): GPSCoordinatesAttr(instance.latitude, instance.longitude),
_('Tenant'): ObjectAttr(instance.tenant, linkify=True, grouped_by='group'),
_('Device Type'): ObjectAttr(instance.device_type, linkify=True, grouped_by='manufacturer'),
_('Description'): TextAttr(instance.description),
_('Airflow'): TextAttr(instance.get_airflow_display()),
_('Serial Number'): TextAttr(instance.serial, style='font-monospace'),
_('Asset Tag'): TextAttr(instance.asset_tag, style='font-monospace'),
_('Config Template'): ObjectAttr(instance.config_template, linkify=True),
})
return { return {
'vc_members': vc_members, 'vc_members': vc_members,
'svg_extra': f'highlight=id:{instance.pk}' 'svg_extra': f'highlight=id:{instance.pk}',
'device_attrs': device_attrs,
} }

View File

View File

@@ -0,0 +1,139 @@
from abc import ABC, abstractmethod
from django.template.loader import render_to_string
from django.utils.html import escape
from django.utils.safestring import mark_safe
from netbox.config import get_config
class Component(ABC):
@abstractmethod
def render(self):
pass
def __str__(self):
return self.render()
#
# Attributes
#
class Attr(Component):
template_name = None
placeholder = mark_safe('<span class="text-muted">&mdash;</span>')
class TextAttr(Attr):
def __init__(self, value, style=None):
self.value = value
self.style = style
def render(self):
if self.value in (None, ''):
return self.placeholder
if self.style:
return mark_safe(f'<span class="{self.style}">{escape(self.value)}</span>')
return self.value
class ObjectAttr(Attr):
template_name = 'components/object.html'
def __init__(self, obj, linkify=None, grouped_by=None, template_name=None):
self.object = obj
self.linkify = linkify
self.group = getattr(obj, grouped_by, None) if grouped_by else None
self.template_name = template_name or self.template_name
def render(self):
if self.object is None:
return self.placeholder
# Determine object & group URLs
# TODO: Add support for reverse() lookups
if self.linkify and hasattr(self.object, 'get_absolute_url'):
object_url = self.object.get_absolute_url()
else:
object_url = None
if self.linkify and hasattr(self.group, 'get_absolute_url'):
group_url = self.group.get_absolute_url()
else:
group_url = None
return render_to_string(self.template_name, {
'object': self.object,
'object_url': object_url,
'group': self.group,
'group_url': group_url,
})
class NestedObjectAttr(Attr):
template_name = 'components/nested_object.html'
def __init__(self, obj, linkify=None):
self.object = obj
self.linkify = linkify
def render(self):
if not self.object:
return self.placeholder
return render_to_string(self.template_name, {
'nodes': self.object.get_ancestors(include_self=True),
'linkify': self.linkify,
})
class GPSCoordinatesAttr(Attr):
template_name = 'components/gps_coordinates.html'
def __init__(self, latitude, longitude, map_url=True):
self.latitude = latitude
self.longitude = longitude
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):
if not (self.latitude and self.longitude):
return self.placeholder
return render_to_string(self.template_name, {
'latitude': self.latitude,
'longitude': self.longitude,
'map_url': self.map_url,
})
#
# Components
#
class AttributesPanel(Component):
template_name = 'components/attributes_panel.html'
def __init__(self, title, attrs):
self.title = title
self.attrs = attrs
def render(self):
return render_to_string(self.template_name, {
'title': self.title,
'attrs': self.attrs,
})
class EmbeddedTemplate(Component):
def __init__(self, template_name, context=None):
self.template_name = template_name
self.context = context or {}
def render(self):
return render_to_string(self.template_name, self.context)

View File

@@ -0,0 +1,13 @@
<div class="card">
<h2 class="card-header">{{ title }}</h2>
<table class="table table-hover attr-table">
{% for label, attr in attrs.items %}
<tr>
<th scope="row">{{ label }}</th>
<td>
<div class="d-flex justify-content-between align-items-start">{{ attr }}</div>
</td>
</tr>
{% endfor %}
</table>
</div>

View File

@@ -0,0 +1,8 @@
{% load i18n %}
{% load l10n %}
<span>{{ latitude }}, {{ longitude }}</span>
{% if map_url %}
<a href="{{ map_url }}{{ latitude|unlocalize }},{{ longitude|unlocalize }}" target="_blank" class="btn btn-primary btn-sm print-none">
<i class="mdi mdi-map-marker"></i> {% trans "Map" %}
</a>
{% endif %}

View File

@@ -0,0 +1,11 @@
<ol class="breadcrumb" aria-label="breadcrumbs">
{% for node in nodes %}
<li class="breadcrumb-item">
{% if linkify %}
<a href="{{ node.get_absolute_url }}">{{ node }}</a>
{% else %}
{{ node }}
{% endif %}
</li>
{% endfor %}
</ol>

View File

@@ -0,0 +1,26 @@
{% if group %}
{# Display an object with its parent group #}
<ol class="breadcrumb" aria-label="breadcrumbs">
<li class="breadcrumb-item">
{% if group_url %}
<a href="{{ group_url }}">{{ group }}</a>
{% else %}
{{ object.group }}
{% endif %}
</li>
<li class="breadcrumb-item">
{% if object_url %}
<a href="{{ object_url }}">{{ object }}</a>
{% else %}
{{ object }}
{% endif %}
</li>
</ol>
{% else %}
{# Display only the object #}
{% if object_url %}
<a href="{{ object_url }}">{{ object }}</a>
{% else %}
{{ object }}
{% endif %}
{% endif %}

View File

@@ -177,6 +177,7 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-12 col-xl-6"> <div class="col col-12 col-xl-6">
{{ device_attrs }}
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Management" %}</h2> <h2 class="card-header">{% trans "Management" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">

View File

@@ -0,0 +1,8 @@
{% if device.parent_bay %}
<ol class="breadcrumb" aria-label="breadcrumbs">
<li class="breadcrumb-item">{{ device.parent_bay.device|linkify }}</li>
<li class="breadcrumb-item">{{ device.parent_bay }}</li>
</ol>
{% else %}
{{ ''|placeholder }}
{% endif %}

View File

@@ -0,0 +1,18 @@
{% load i18n %}
{% if device.rack %}
<span>
{{ device.rack|linkify }}
{% if device.rack and device.position %}
(U{{ device.position|floatformat }} / {{ device.get_face_display }})
{% elif device.rack and device.device_type.u_height %}
<span class="badge text-bg-warning">{% trans "Not racked" %}</span>
{% endif %}
</span>
{% if device.rack and device.position %}
<a href="{{ device.rack.get_absolute_url }}?device={{ device.pk }}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}">
<i class="mdi mdi-view-day-outline"></i>
</a>
{% endif %}
{% else %}
{{ ''|placeholder }}
{% endif %}