diff --git a/netbox/circuits/ui/__init__.py b/netbox/circuits/ui/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/circuits/ui/panels.py b/netbox/circuits/ui/panels.py
new file mode 100644
index 000000000..e8d6cf9bb
--- /dev/null
+++ b/netbox/circuits/ui/panels.py
@@ -0,0 +1,139 @@
+from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import gettext_lazy as _
+
+from netbox.ui import actions, attrs, panels
+from utilities.data import resolve_attr_path
+
+
+class CircuitCircuitTerminationPanel(panels.ObjectPanel):
+ """
+ A panel showing the CircuitTermination assigned to the object.
+ """
+
+ template_name = 'circuits/panels/circuit_circuit_termination.html'
+ title = _('Termination')
+
+ def __init__(self, accessor=None, side=None, **kwargs):
+ super().__init__(**kwargs)
+
+ if accessor is not None:
+ self.accessor = accessor
+ if side is not None:
+ self.side = side
+
+ def get_context(self, context):
+ return {
+ **super().get_context(context),
+ 'side': self.side,
+ 'termination': resolve_attr_path(context, f'{self.accessor}.termination_{self.side.lower()}'),
+ }
+
+
+class CircuitGroupAssignmentsPanel(panels.ObjectsTablePanel):
+ """
+ A panel showing all Circuit Groups attached to the object.
+ """
+
+ title = _('Group Assignments')
+ actions = [
+ actions.AddObject(
+ 'circuits.CircuitGroupAssignment',
+ url_params={
+ 'member_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
+ 'member': lambda ctx: ctx['object'].pk,
+ 'return_url': lambda ctx: ctx['object'].get_absolute_url(),
+ },
+ label=_('Assign Group'),
+ ),
+ ]
+
+ def __init__(self, **kwargs):
+ super().__init__(
+ 'circuits.CircuitGroupAssignment',
+ filters={
+ 'member_type_id': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
+ 'member_id': lambda ctx: ctx['object'].pk,
+ },
+ **kwargs,
+ )
+
+
+class CircuitGroupPanel(panels.OrganizationalObjectPanel):
+ tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+
+
+class CircuitGroupAssignmentPanel(panels.ObjectAttributesPanel):
+ group = attrs.RelatedObjectAttr('group', linkify=True)
+ provider = attrs.RelatedObjectAttr('member.provider', linkify=True)
+ member = attrs.GenericForeignKeyAttr('member', linkify=True)
+ priority = attrs.ChoiceAttr('priority')
+
+
+class CircuitPanel(panels.ObjectAttributesPanel):
+ provider = attrs.RelatedObjectAttr('provider', linkify=True)
+ provider_account = attrs.RelatedObjectAttr('provider_account', linkify=True)
+ cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True)
+ type = attrs.RelatedObjectAttr('type', linkify=True)
+ status = attrs.ChoiceAttr('status')
+ distance = attrs.NumericAttr('distance', unit_accessor='get_distance_unit_display')
+ tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+ install_date = attrs.DateTimeAttr('install_date', spec='date')
+ termination_date = attrs.DateTimeAttr('termination_date', spec='date')
+ commit_rate = attrs.TemplatedAttr('commit_rate', template_name='circuits/circuit/attrs/commit_rate.html')
+ description = attrs.TextAttr('description')
+
+
+class CircuitTypePanel(panels.OrganizationalObjectPanel):
+ color = attrs.ColorAttr('color')
+
+
+class ProviderPanel(panels.ObjectAttributesPanel):
+ name = attrs.TextAttr('name')
+ asns = attrs.RelatedObjectListAttr('asns', linkify=True, label=_('ASNs'))
+ description = attrs.TextAttr('description')
+
+
+class ProviderAccountPanel(panels.ObjectAttributesPanel):
+ provider = attrs.RelatedObjectAttr('provider', linkify=True)
+ account = attrs.TextAttr('account', style='font-monospace', copy_button=True)
+ name = attrs.TextAttr('name')
+ description = attrs.TextAttr('description')
+
+
+class ProviderNetworkPanel(panels.ObjectAttributesPanel):
+ provider = attrs.RelatedObjectAttr('provider', linkify=True)
+ name = attrs.TextAttr('name')
+ service_id = attrs.TextAttr('service_id', label=_('Service ID'), style='font-monospace', copy_button=True)
+ description = attrs.TextAttr('description')
+
+
+class VirtualCircuitTypePanel(panels.OrganizationalObjectPanel):
+ color = attrs.ColorAttr('color')
+
+
+class VirtualCircuitPanel(panels.ObjectAttributesPanel):
+ provider = attrs.RelatedObjectAttr('provider', linkify=True)
+ provider_network = attrs.RelatedObjectAttr('provider_network', linkify=True)
+ provider_account = attrs.RelatedObjectAttr('provider_account', linkify=True)
+ cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True)
+ type = attrs.RelatedObjectAttr('type', linkify=True)
+ status = attrs.ChoiceAttr('status')
+ tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+ description = attrs.TextAttr('description')
+
+
+class VirtualCircuitTerminationPanel(panels.ObjectAttributesPanel):
+ provider = attrs.RelatedObjectAttr('virtual_circuit.provider', linkify=True)
+ provider_network = attrs.RelatedObjectAttr('virtual_circuit.provider_network', linkify=True)
+ provider_account = attrs.RelatedObjectAttr('virtual_circuit.provider_account', linkify=True)
+ virtual_circuit = attrs.RelatedObjectAttr('virtual_circuit', linkify=True)
+ role = attrs.ChoiceAttr('role')
+
+
+class VirtualCircuitTerminationInterfacePanel(panels.ObjectAttributesPanel):
+ title = _('Interface')
+
+ device = attrs.RelatedObjectAttr('interface.device', linkify=True)
+ interface = attrs.RelatedObjectAttr('interface', linkify=True)
+ type = attrs.ChoiceAttr('interface.type')
+ description = attrs.TextAttr('interface.description')
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index 970fb8560..ddb4ef358 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -1,13 +1,23 @@
+from django.utils.translation import gettext_lazy as _
from dcim.views import PathTraceView
+from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from ipam.models import ASN
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
+from netbox.ui import actions, layout
+from netbox.ui.panels import (
+ CommentsPanel,
+ ObjectsTablePanel,
+ Panel,
+ RelatedObjectsPanel,
+)
from netbox.views import generic
from utilities.query import count_related
from utilities.views import GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .models import *
+from .ui import panels
#
# Providers
@@ -29,6 +39,35 @@ class ProviderListView(generic.ObjectListView):
@register_model_view(Provider)
class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Provider.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.ProviderPanel(),
+ TagsPanel(),
+ CommentsPanel(),
+ ],
+ right_panels=[
+ RelatedObjectsPanel(),
+ CustomFieldsPanel(),
+ ],
+ bottom_panels=[
+ ObjectsTablePanel(
+ model='circuits.ProviderAccount',
+ filters={'provider_id': lambda ctx: ctx['object'].pk},
+ actions=[
+ actions.AddObject(
+ 'circuits.ProviderAccount', url_params={'provider': lambda ctx: ctx['object'].pk}
+ ),
+ ],
+ ),
+ ObjectsTablePanel(
+ model='circuits.Circuit',
+ filters={'provider_id': lambda ctx: ctx['object'].pk},
+ actions=[
+ actions.AddObject('circuits.Circuit', url_params={'provider': lambda ctx: ctx['object'].pk}),
+ ],
+ ),
+ ],
+ )
def get_extra_context(self, request, instance):
return {
@@ -44,7 +83,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
'provider_id',
),
),
- ),
+ ),
}
@@ -108,6 +147,32 @@ class ProviderAccountListView(generic.ObjectListView):
@register_model_view(ProviderAccount)
class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ProviderAccount.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.ProviderAccountPanel(),
+ TagsPanel(),
+ ],
+ right_panels=[
+ RelatedObjectsPanel(),
+ CommentsPanel(),
+ CustomFieldsPanel(),
+ ],
+ bottom_panels=[
+ ObjectsTablePanel(
+ model='circuits.Circuit',
+ filters={'provider_account_id': lambda ctx: ctx['object'].pk},
+ actions=[
+ actions.AddObject(
+ 'circuits.Circuit',
+ url_params={
+ 'provider': lambda ctx: ctx['object'].provider.pk,
+ 'provider_account': lambda ctx: ctx['object'].pk,
+ },
+ ),
+ ],
+ ),
+ ],
+ )
def get_extra_context(self, request, instance):
return {
@@ -174,6 +239,32 @@ class ProviderNetworkListView(generic.ObjectListView):
@register_model_view(ProviderNetwork)
class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ProviderNetwork.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.ProviderNetworkPanel(),
+ TagsPanel(),
+ ],
+ right_panels=[
+ RelatedObjectsPanel(),
+ CommentsPanel(),
+ CustomFieldsPanel(),
+ ],
+ bottom_panels=[
+ ObjectsTablePanel(
+ model='circuits.Circuit',
+ filters={'provider_network_id': lambda ctx: ctx['object'].pk},
+ ),
+ ObjectsTablePanel(
+ model='circuits.VirtualCircuit',
+ filters={'provider_network_id': lambda ctx: ctx['object'].pk},
+ actions=[
+ actions.AddObject(
+ 'circuits.VirtualCircuit', url_params={'provider_network': lambda ctx: ctx['object'].pk}
+ ),
+ ],
+ ),
+ ],
+ )
def get_extra_context(self, request, instance):
return {
@@ -251,6 +342,17 @@ class CircuitTypeListView(generic.ObjectListView):
@register_model_view(CircuitType)
class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = CircuitType.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.CircuitTypePanel(),
+ TagsPanel(),
+ ],
+ right_panels=[
+ RelatedObjectsPanel(),
+ CommentsPanel(),
+ CustomFieldsPanel(),
+ ],
+ )
def get_extra_context(self, request, instance):
return {
@@ -318,6 +420,20 @@ class CircuitListView(generic.ObjectListView):
@register_model_view(Circuit)
class CircuitView(generic.ObjectView):
queryset = Circuit.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.CircuitPanel(),
+ panels.CircuitGroupAssignmentsPanel(),
+ CustomFieldsPanel(),
+ TagsPanel(),
+ CommentsPanel(),
+ ],
+ right_panels=[
+ panels.CircuitCircuitTerminationPanel(side='A'),
+ panels.CircuitCircuitTerminationPanel(side='Z'),
+ ImageAttachmentsPanel(),
+ ],
+ )
@register_model_view(Circuit, 'add', detail=False)
@@ -390,6 +506,18 @@ class CircuitTerminationListView(generic.ObjectListView):
@register_model_view(CircuitTermination)
class CircuitTerminationView(generic.ObjectView):
queryset = CircuitTermination.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ Panel(
+ template_name='circuits/panels/circuit_termination.html',
+ title=_('Circuit Termination'),
+ )
+ ],
+ right_panels=[
+ CustomFieldsPanel(),
+ TagsPanel(),
+ ],
+ )
@register_model_view(CircuitTermination, 'add', detail=False)
@@ -446,6 +574,17 @@ class CircuitGroupListView(generic.ObjectListView):
@register_model_view(CircuitGroup)
class CircuitGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = CircuitGroup.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.CircuitGroupPanel(),
+ TagsPanel(),
+ ],
+ right_panels=[
+ RelatedObjectsPanel(),
+ CommentsPanel(),
+ CustomFieldsPanel(),
+ ],
+ )
def get_extra_context(self, request, instance):
return {
@@ -508,6 +647,15 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
@register_model_view(CircuitGroupAssignment)
class CircuitGroupAssignmentView(generic.ObjectView):
queryset = CircuitGroupAssignment.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.CircuitGroupAssignmentPanel(),
+ TagsPanel(),
+ ],
+ right_panels=[
+ CustomFieldsPanel(),
+ ],
+ )
@register_model_view(CircuitGroupAssignment, 'add', detail=False)
@@ -560,6 +708,17 @@ class VirtualCircuitTypeListView(generic.ObjectListView):
@register_model_view(VirtualCircuitType)
class VirtualCircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VirtualCircuitType.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.VirtualCircuitTypePanel(),
+ TagsPanel(),
+ ],
+ right_panels=[
+ RelatedObjectsPanel(),
+ CommentsPanel(),
+ CustomFieldsPanel(),
+ ],
+ )
def get_extra_context(self, request, instance):
return {
@@ -627,6 +786,30 @@ class VirtualCircuitListView(generic.ObjectListView):
@register_model_view(VirtualCircuit)
class VirtualCircuitView(generic.ObjectView):
queryset = VirtualCircuit.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.VirtualCircuitPanel(),
+ TagsPanel(),
+ ],
+ right_panels=[
+ CustomFieldsPanel(),
+ CommentsPanel(),
+ panels.CircuitGroupAssignmentsPanel(),
+ ],
+ bottom_panels=[
+ ObjectsTablePanel(
+ model='circuits.VirtualCircuitTermination',
+ title=_('Terminations'),
+ filters={'virtual_circuit_id': lambda ctx: ctx['object'].pk},
+ actions=[
+ actions.AddObject(
+ 'circuits.VirtualCircuitTermination',
+ url_params={'virtual_circuit': lambda ctx: ctx['object'].pk},
+ ),
+ ],
+ ),
+ ],
+ )
@register_model_view(VirtualCircuit, 'add', detail=False)
@@ -698,6 +881,16 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
@register_model_view(VirtualCircuitTermination)
class VirtualCircuitTerminationView(generic.ObjectView):
queryset = VirtualCircuitTermination.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.VirtualCircuitTerminationPanel(),
+ TagsPanel(),
+ CustomFieldsPanel(),
+ ],
+ right_panels=[
+ panels.VirtualCircuitTerminationInterfacePanel(),
+ ],
+ )
@register_model_view(VirtualCircuitTermination, 'edit')
diff --git a/netbox/netbox/tests/test_ui.py b/netbox/netbox/tests/test_ui.py
new file mode 100644
index 000000000..cb76517c1
--- /dev/null
+++ b/netbox/netbox/tests/test_ui.py
@@ -0,0 +1,215 @@
+from django.test import TestCase
+
+from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
+from circuits.models import (
+ Provider,
+ ProviderNetwork,
+ VirtualCircuit,
+ VirtualCircuitTermination,
+ VirtualCircuitType,
+)
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Interface
+from netbox.ui import attrs
+from utilities.testing import create_test_device
+from vpn.choices import (
+ AuthenticationAlgorithmChoices,
+ AuthenticationMethodChoices,
+ DHGroupChoices,
+ EncryptionAlgorithmChoices,
+ IKEModeChoices,
+ IKEVersionChoices,
+ IPSecModeChoices,
+)
+from vpn.models import IKEPolicy, IKEProposal, IPSecPolicy, IPSecProfile
+
+
+class ChoiceAttrTest(TestCase):
+ """
+ Test class for validating the behavior of ChoiceAttr attribute accessor.
+
+ This test class verifies that the ChoiceAttr class correctly handles
+ choice field attributes on Django model instances, including both direct
+ field access and related object field access. It tests the retrieval of
+ display values and associated context information such as color values
+ for choice fields. The test data includes a network topology with devices,
+ interfaces, providers, and virtual circuits to cover various scenarios of
+ choice field access patterns.
+ """
+
+ @classmethod
+ def setUpTestData(cls):
+ device = create_test_device('Device 1')
+ interface = Interface.objects.create(
+ device=device,
+ name='vlan.100',
+ type=InterfaceTypeChoices.TYPE_VIRTUAL,
+ )
+
+ provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+ provider_network = ProviderNetwork.objects.create(
+ provider=provider,
+ name='Provider Network 1',
+ )
+ virtual_circuit_type = VirtualCircuitType.objects.create(
+ name='Virtual Circuit Type 1',
+ slug='virtual-circuit-type-1',
+ )
+ virtual_circuit = VirtualCircuit.objects.create(
+ cid='VC-100',
+ provider_network=provider_network,
+ type=virtual_circuit_type,
+ status=CircuitStatusChoices.STATUS_ACTIVE,
+ )
+
+ cls.termination = VirtualCircuitTermination.objects.create(
+ virtual_circuit=virtual_circuit,
+ role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+ interface=interface,
+ )
+
+ def test_choice_attr_direct_accessor(self):
+ attr = attrs.ChoiceAttr('role')
+
+ self.assertEqual(
+ attr.get_value(self.termination),
+ self.termination.get_role_display(),
+ )
+ self.assertEqual(
+ attr.get_context(self.termination, {}),
+ {'bg_color': self.termination.get_role_color()},
+ )
+
+ def test_choice_attr_related_accessor(self):
+ attr = attrs.ChoiceAttr('interface.type')
+
+ self.assertEqual(
+ attr.get_value(self.termination),
+ self.termination.interface.get_type_display(),
+ )
+ self.assertEqual(
+ attr.get_context(self.termination, {}),
+ {'bg_color': None},
+ )
+
+ def test_choice_attr_related_accessor_with_color(self):
+ attr = attrs.ChoiceAttr('virtual_circuit.status')
+
+ self.assertEqual(
+ attr.get_value(self.termination),
+ self.termination.virtual_circuit.get_status_display(),
+ )
+ self.assertEqual(
+ attr.get_context(self.termination, {}),
+ {'bg_color': self.termination.virtual_circuit.get_status_color()},
+ )
+
+
+class RelatedObjectListAttrTest(TestCase):
+ """
+ Test suite for RelatedObjectListAttr functionality.
+
+ This test class validates the behavior of the RelatedObjectListAttr class,
+ which is used to render related objects as HTML lists. It tests various
+ scenarios including direct accessor access, related accessor access through
+ foreign keys, empty related object sets, and rendering with maximum item
+ limits and overflow indicators. The tests use IKE and IPSec VPN policy
+ models to verify proper rendering of one-to-many and many-to-many
+ relationships between objects.
+ """
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.proposals = (
+ IKEProposal.objects.create(
+ name='IKE Proposal 1',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14,
+ ),
+ IKEProposal.objects.create(
+ name='IKE Proposal 2',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14,
+ ),
+ IKEProposal.objects.create(
+ name='IKE Proposal 3',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14,
+ ),
+ )
+
+ cls.ike_policy = IKEPolicy.objects.create(
+ name='IKE Policy 1',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ )
+ cls.ike_policy.proposals.set(cls.proposals)
+
+ cls.empty_ike_policy = IKEPolicy.objects.create(
+ name='IKE Policy 2',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ )
+
+ cls.ipsec_policy = IPSecPolicy.objects.create(name='IPSec Policy 1')
+
+ cls.profile = IPSecProfile.objects.create(
+ name='IPSec Profile 1',
+ mode=IPSecModeChoices.ESP,
+ ike_policy=cls.ike_policy,
+ ipsec_policy=cls.ipsec_policy,
+ )
+ cls.empty_profile = IPSecProfile.objects.create(
+ name='IPSec Profile 2',
+ mode=IPSecModeChoices.ESP,
+ ike_policy=cls.empty_ike_policy,
+ ipsec_policy=cls.ipsec_policy,
+ )
+
+ def test_related_object_list_attr_direct_accessor(self):
+ attr = attrs.RelatedObjectListAttr('proposals', linkify=False)
+ rendered = attr.render(self.ike_policy, {'name': 'proposals'})
+
+ self.assertIn('list-unstyled mb-0', rendered)
+ self.assertInHTML('
IKE Proposal 1', rendered)
+ self.assertInHTML('IKE Proposal 2', rendered)
+ self.assertInHTML('IKE Proposal 3', rendered)
+ self.assertEqual(rendered.count('IKE Proposal 1', rendered)
+ self.assertInHTML('IKE Proposal 2', rendered)
+ self.assertInHTML('IKE Proposal 3', rendered)
+ self.assertEqual(rendered.count('IKE Proposal 1', rendered)
+ self.assertInHTML('IKE Proposal 2', rendered)
+ self.assertNotIn('IKE Proposal 3', rendered)
+ self.assertIn('…', rendered)
diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py
index b4e2a61ee..e4bd93c4e 100644
--- a/netbox/netbox/ui/attrs.py
+++ b/netbox/netbox/ui/attrs.py
@@ -18,6 +18,7 @@ __all__ = (
'NumericAttr',
'ObjectAttribute',
'RelatedObjectAttr',
+ 'RelatedObjectListAttr',
'TemplatedAttr',
'TextAttr',
'TimezoneAttr',
@@ -145,22 +146,40 @@ 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.
+ The class calls get_FOO_display() on the terminal object resolved by the accessor
+ to retrieve the human-friendly choice label. For example, accessor="interface.type"
+ will call interface.get_type_display().
+ If a get_FOO_color() method exists on that object, it will be used to render a
+ background color for the attribute value.
"""
template_name = 'ui/attrs/choice.html'
+ def _resolve_target(self, obj):
+ if not self.accessor or '.' not in self.accessor:
+ return obj, self.accessor
+
+ object_accessor, field_name = self.accessor.rsplit('.', 1)
+ return resolve_attr_path(obj, object_accessor), field_name
+
def get_value(self, obj):
- try:
- return getattr(obj, f'get_{self.accessor}_display')()
- except AttributeError:
- return resolve_attr_path(obj, self.accessor)
+ target, field_name = self._resolve_target(obj)
+ if target is None:
+ return None
+
+ display = getattr(target, f'get_{field_name}_display', None)
+ if callable(display):
+ return display()
+
+ return resolve_attr_path(target, field_name)
def get_context(self, obj, context):
- try:
- bg_color = getattr(obj, f'get_{self.accessor}_color')()
- except AttributeError:
- bg_color = None
+ target, field_name = self._resolve_target(obj)
+ if target is None:
+ return {'bg_color': None}
+
+ get_color = getattr(target, f'get_{field_name}_color', None)
+ bg_color = get_color() if callable(get_color) else None
+
return {
'bg_color': bg_color,
}
@@ -254,6 +273,83 @@ class RelatedObjectAttr(ObjectAttribute):
}
+class RelatedObjectListAttr(RelatedObjectAttr):
+ """
+ An attribute representing a list of related objects.
+
+ The accessor may resolve to a related manager or queryset.
+
+ Parameters:
+ max_items (int): Maximum number of items to display
+ overflow_indicator (str | None): Marker rendered as a final list item when
+ additional objects exist beyond `max_items`; set to None to suppress it
+ """
+
+ template_name = 'ui/attrs/object_list.html'
+
+ def __init__(self, *args, max_items=None, overflow_indicator='…', **kwargs):
+ super().__init__(*args, **kwargs)
+
+ if max_items is not None and (type(max_items) is not int or max_items < 1):
+ raise ValueError(
+ _('Invalid max_items value: {max_items}! Must be a positive integer or None.').format(
+ max_items=max_items
+ )
+ )
+
+ self.max_items = max_items
+ self.overflow_indicator = overflow_indicator
+
+ def _get_items(self, obj):
+ """
+ Retrieve items from the given object using the accessor path.
+
+ Returns a tuple of (items, has_more) where items is a list of resolved objects
+ and has_more indicates whether additional items exist beyond the max_items limit.
+ """
+ items = resolve_attr_path(obj, self.accessor)
+ if items is None:
+ return [], False
+
+ if hasattr(items, 'all'):
+ items = items.all()
+
+ if self.max_items is None:
+ return list(items), False
+
+ items = list(items[:self.max_items + 1])
+ has_more = len(items) > self.max_items
+
+ return items[:self.max_items], has_more
+
+ def get_context(self, obj, context):
+ items, has_more = self._get_items(obj)
+
+ return {
+ 'linkify': self.linkify,
+ 'items': [
+ {
+ 'value': item,
+ 'group': getattr(item, self.grouped_by, None) if self.grouped_by else None,
+ }
+ for item in items
+ ],
+ 'overflow_indicator': self.overflow_indicator if has_more else None,
+ }
+
+ def render(self, obj, context):
+ context = context or {}
+ context_data = self.get_context(obj, context)
+
+ if not context_data['items']:
+ return self.placeholder
+
+ return render_to_string(self.template_name, {
+ 'name': context.get('name'),
+ **context_data,
+ })
+
+
class NestedObjectAttr(ObjectAttribute):
"""
An attribute representing a related nested object. Similar to `RelatedObjectAttr`, but includes the ancestors of the
diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py
index 9f4be94db..b1ab1e411 100644
--- a/netbox/netbox/ui/panels.py
+++ b/netbox/netbox/ui/panels.py
@@ -67,6 +67,7 @@ class Panel:
return {
'request': context.get('request'),
'object': context.get('object'),
+ 'perms': context.get('perms'),
'title': self.title,
'actions': self.actions,
'panel_class': self.__class__.__name__,
diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html
index 1037000ba..18b00b98d 100644
--- a/netbox/templates/circuits/circuit.html
+++ b/netbox/templates/circuits/circuit.html
@@ -1,104 +1,6 @@
{% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
{{ object.provider }}
{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
- | {% trans "Provider" %} |
- {{ object.provider|linkify }} |
-
-
- | {% trans "Account" %} |
- {{ object.provider_account|linkify|placeholder }} |
-
-
- | {% trans "Circuit ID" %} |
- {{ object.cid }} |
-
-
- | {% trans "Type" %} |
- {{ object.type|linkify }} |
-
-
- | {% trans "Status" %} |
- {% badge object.get_status_display bg_color=object.get_status_color %} |
-
-
- | {% trans "Distance" %} |
-
- {% if object.distance is not None %}
- {{ object.distance|floatformat }} {{ object.get_distance_unit_display }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
-
-
- | {% trans "Tenant" %} |
-
- {% if object.tenant.group %}
- {{ object.tenant.group|linkify }} /
- {% endif %}
- {{ object.tenant|linkify|placeholder }}
- |
-
-
- | {% trans "Install Date" %} |
- {{ object.install_date|isodate|placeholder }} |
-
-
- | {% trans "Termination Date" %} |
- {{ object.termination_date|isodate|placeholder }} |
-
-
- | {% trans "Commit Rate" %} |
- {{ object.commit_rate|humanize_speed|placeholder }} |
-
-
- | {% trans "Description" %} |
- {{ object.description|placeholder }} |
-
-
-
-
-
- {% htmx_table 'circuits:circuitgroupassignment_list' circuit_id=object.pk %}
-
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/tags.html' %}
- {% include 'inc/panels/comments.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
- {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
- {% include 'inc/panels/image_attachments.html' %}
- {% plugin_right_page object %}
-
-
-
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/circuits/circuit/attrs/commit_rate.html b/netbox/templates/circuits/circuit/attrs/commit_rate.html
new file mode 100644
index 000000000..fc4b15d28
--- /dev/null
+++ b/netbox/templates/circuits/circuit/attrs/commit_rate.html
@@ -0,0 +1,2 @@
+{% load helpers %}
+{{ value|humanize_speed }}
diff --git a/netbox/templates/circuits/circuit_terminations_swap.html b/netbox/templates/circuits/circuit_terminations_swap.html
deleted file mode 100644
index 1ddb67bac..000000000
--- a/netbox/templates/circuits/circuit_terminations_swap.html
+++ /dev/null
@@ -1,30 +0,0 @@
-{% extends 'generic/confirmation_form.html' %}
-{% load i18n %}
-
-{% block title %}{% trans "Swap Circuit Terminations" %}{% endblock %}
-
-{% block message %}
-
- {% blocktrans trimmed %}
- Swap these terminations for circuit {{ circuit }}?
- {% endblocktrans %}
-
-
- -
- {% trans "A side" %}:
- {% if termination_a %}
- {{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
- {% else %}
- {% trans "None" %}
- {% endif %}
-
- -
- {% trans "Z side" %}:
- {% if termination_z %}
- {{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %}
- {% else %}
- {% trans "None" %}
- {% endif %}
-
-
-{% endblock %}
diff --git a/netbox/templates/circuits/circuitgroup.html b/netbox/templates/circuits/circuitgroup.html
index 0ada4cc53..16ec51510 100644
--- a/netbox/templates/circuits/circuitgroup.html
+++ b/netbox/templates/circuits/circuitgroup.html
@@ -1,8 +1,4 @@
{% extends 'generic/object.html' %}
-{% load static %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
@@ -17,40 +13,3 @@
{% endif %}
{% endblock extra_controls %}
-
-{% block content %}
-
-
-
-
-
-
- | {% trans "Name" %} |
- {{ object.name }} |
-
-
- | {% trans "Description" %} |
- {{ object.description|placeholder }} |
-
-
- | {% trans "Tenant" %} |
-
- {% if object.tenant.group %}
- {{ object.tenant.group|linkify }} /
- {% endif %}
- {{ object.tenant|linkify|placeholder }}
- |
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/related_objects.html' %}
- {% include 'inc/panels/comments.html' %}
- {% include 'inc/panels/custom_fields.html' %}
- {% plugin_right_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/circuits/circuitgroupassignment.html b/netbox/templates/circuits/circuitgroupassignment.html
index bca3f752a..a80615afd 100644
--- a/netbox/templates/circuits/circuitgroupassignment.html
+++ b/netbox/templates/circuits/circuitgroupassignment.html
@@ -1,9 +1,4 @@
{% extends 'generic/object.html' %}
-{% load static %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
@@ -11,42 +6,3 @@
{{ object.group }}
{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
- | {% trans "Group" %} |
- {{ object.group|linkify }} |
-
-
- | {% trans "Provider" %} |
- {{ object.member.provider|linkify }} |
-
-
- | {% trans "Circuit" %} |
- {{ object.member|linkify }} |
-
-
- | {% trans "Priority" %} |
- {{ object.get_priority_display }} |
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/custom_fields.html' %}
- {% plugin_right_page object %}
-
-
-
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/circuits/circuittermination.html b/netbox/templates/circuits/circuittermination.html
index c4e63495d..4a694c8f1 100644
--- a/netbox/templates/circuits/circuittermination.html
+++ b/netbox/templates/circuits/circuittermination.html
@@ -7,45 +7,3 @@
{{ block.super }}
{{ object.circuit.provider }}
{% endblock %}
-
-{% block content %}
-
-
-
-
- {% if object %}
-
-
- | {% trans "Circuit" %} |
-
- {{ object.circuit|linkify }}
- |
-
-
- | {% trans "Provider" %} |
-
- {{ object.circuit.provider|linkify }}
- |
-
- {% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
-
- {% else %}
-
- {% trans "None" %}
-
- {% endif %}
-
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/tags.html' %}
- {% plugin_right_page object %}
-
-
-
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/circuits/circuittype.html b/netbox/templates/circuits/circuittype.html
index a652ab605..f44bb43f3 100644
--- a/netbox/templates/circuits/circuittype.html
+++ b/netbox/templates/circuits/circuittype.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,46 +8,3 @@
{% endif %}
{% endblock extra_controls %}
-
-{% block content %}
-
-
-
-
-
-
- | {% trans "Name" %} |
- {{ object.name }} |
-
-
- | {% trans "Description" %} |
- {{ object.description|placeholder }} |
-
-
- | {% trans "Color" %} |
-
- {% if object.color %}
-
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/related_objects.html' %}
- {% include 'inc/panels/comments.html' %}
- {% include 'inc/panels/custom_fields.html' %}
- {% plugin_right_page object %}
-
-
-
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/circuits/inc/circuit_termination_fields.html b/netbox/templates/circuits/inc/circuit_termination_fields.html
index efe30e28e..1ea465fca 100644
--- a/netbox/templates/circuits/inc/circuit_termination_fields.html
+++ b/netbox/templates/circuits/inc/circuit_termination_fields.html
@@ -55,31 +55,33 @@
{% trans "Circuit Termination" %}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+
+
+
+ | {% trans "Speed" %} |
+
+ {% if termination.port_speed and termination.upstream_speed %}
+ {{ termination.port_speed|humanize_speed }}
+ {{ termination.upstream_speed|humanize_speed }}
+ {% elif termination.port_speed %}
+ {{ termination.port_speed|humanize_speed }}
+ {% else %}
+ {{ ''|placeholder }}
{% endif %}
|
- | {% trans "Speed" %} |
-
- {% if termination.port_speed and termination.upstream_speed %}
- {{ termination.port_speed|humanize_speed }}
- {{ termination.upstream_speed|humanize_speed }}
- {% elif termination.port_speed %}
- {{ termination.port_speed|humanize_speed }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
+ {% trans "Cross-Connect" %} |
+ {{ termination.xconnect_id|placeholder }} |
- | {% trans "Cross-Connect" %} |
- {{ termination.xconnect_id|placeholder }} |
+ {% trans "Patch Panel/Port" %} |
+ {{ termination.pp_info|placeholder }} |
- | {% trans "Patch Panel/Port" %} |
- {{ termination.pp_info|placeholder }} |
-
-
- | {% trans "Description" %} |
- {{ termination.description|placeholder }} |
+ {% trans "Description" %} |
+ {{ termination.description|placeholder }} |
diff --git a/netbox/templates/circuits/panels/circuit_circuit_termination.html b/netbox/templates/circuits/panels/circuit_circuit_termination.html
new file mode 100644
index 000000000..1d20df0d8
--- /dev/null
+++ b/netbox/templates/circuits/panels/circuit_circuit_termination.html
@@ -0,0 +1,68 @@
+{% load helpers %}
+{% load i18n %}
+
+
+
+ {% if termination %}
+
+ {% include 'circuits/inc/circuit_termination_fields.html' with termination=termination %}
+
+ | {% trans "Tags" %} |
+
+ {% for tag in termination.tags.all %}
+ {% tag tag %}
+ {% empty %}
+ {{ ''|placeholder }}
+ {% endfor %}
+ |
+
+ {% for group_name, fields in termination.get_custom_fields_by_group.items %}
+
+ |
+ {{ group_name|default:"Custom Fields" }}
+ |
+
+ {% for field, value in fields.items %}
+
+ | {{ field }}
+ {% if field.description %}
+
+ {% endif %}
+ |
+
+ {% customfield_value field value %}
+ |
+
+ {% endfor %}
+ {% endfor %}
+
+ {% else %}
+
+ {% trans "None" %}
+
+ {% endif %}
+
diff --git a/netbox/templates/circuits/panels/circuit_termination.html b/netbox/templates/circuits/panels/circuit_termination.html
new file mode 100644
index 000000000..fc56287eb
--- /dev/null
+++ b/netbox/templates/circuits/panels/circuit_termination.html
@@ -0,0 +1,18 @@
+{% extends "ui/panels/_base.html" %}
+{% load helpers i18n %}
+
+{% block panel_content %}
+
+
+ | {% trans "Circuit" %} |
+ {{ object.circuit|linkify|placeholder }} |
+
+
+ | {% trans "Provider" %} |
+ {{ object.circuit.provider|linkify|placeholder }} |
+
+ {% if object %}
+ {% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
+ {% endif %}
+
+{% endblock panel_content %}
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html
index 70f0c5ba5..7349bd9c3 100644
--- a/netbox/templates/circuits/provider.html
+++ b/netbox/templates/circuits/provider.html
@@ -12,52 +12,3 @@
{% endif %}
{% endblock extra_controls %}
-
-{% block content %}
-
-
-
-
-
-
- | {% trans "ASNs" %} |
-
- {% for asn in object.asns.all %}
- {{ asn|linkify }}{% if not forloop.last %}, {% endif %}
- {% empty %}
- {{ ''|placeholder }}
- {% endfor %}
- |
-
-
- | {% trans "Description" %} |
- {{ object.description|placeholder }} |
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% include 'inc/panels/comments.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/related_objects.html' %}
- {% include 'inc/panels/custom_fields.html' %}
- {% plugin_right_page object %}
-
-
-
-
-
-
- {% htmx_table 'circuits:provideraccount_list' provider_id=object.pk %}
-
-
-
-
-
- {% htmx_table 'circuits:circuit_list' provider_id=object.pk %}
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/circuits/provideraccount.html b/netbox/templates/circuits/provideraccount.html
index 41cf8c033..17b8519f2 100644
--- a/netbox/templates/circuits/provideraccount.html
+++ b/netbox/templates/circuits/provideraccount.html
@@ -1,54 +1,6 @@
{% extends 'generic/object.html' %}
-{% load static %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
{{ object.provider }}
{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
- | {% trans "Provider" %} |
- {{ object.provider|linkify }} |
-
-
- | {% trans "Account" %} |
- {{ object.account }} |
-
-
- | {% trans "Name" %} |
- {{ object.name|placeholder }} |
-
-
- | {% trans "Description" %} |
- {{ object.description|placeholder }} |
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/related_objects.html' %}
- {% include 'inc/panels/comments.html' %}
- {% include 'inc/panels/custom_fields.html' %}
- {% plugin_right_page object %}
-
-
-
-
- {% htmx_table 'circuits:circuit_list' provider_account_id=object.pk %}
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html
index 3348a0ae3..92bad10ca 100644
--- a/netbox/templates/circuits/providernetwork.html
+++ b/netbox/templates/circuits/providernetwork.html
@@ -1,69 +1,6 @@
{% extends 'generic/object.html' %}
-{% load static %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
{{ object.provider }}
{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
- | {% trans "Provider" %} |
- {{ object.provider|linkify }} |
-
-
- | {% trans "Name" %} |
- {{ object.name }} |
-
-
- | {% trans "Service ID" %} |
- {{ object.service_id|placeholder }} |
-
-
- | {% trans "Description" %} |
- {{ object.description|placeholder }} |
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/related_objects.html' %}
- {% include 'inc/panels/comments.html' %}
- {% include 'inc/panels/custom_fields.html' %}
- {% plugin_right_page object %}
-
-
-
-
-
-
- {% htmx_table 'circuits:circuit_list' provider_network_id=object.pk %}
-
-
-
- {% htmx_table 'circuits:virtualcircuit_list' provider_network_id=object.pk %}
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/circuits/virtualcircuit.html b/netbox/templates/circuits/virtualcircuit.html
index 1a23c3597..ad65b3342 100644
--- a/netbox/templates/circuits/virtualcircuit.html
+++ b/netbox/templates/circuits/virtualcircuit.html
@@ -1,7 +1,4 @@
{% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
@@ -12,90 +9,3 @@
{{ object.provider_network }}
{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
- | {% trans "Provider" %} |
- {{ object.provider|linkify }} |
-
-
- | {% trans "Provider Network" %} |
- {{ object.provider_network|linkify }} |
-
-
- | {% trans "Provider account" %} |
- {{ object.provider_account|linkify|placeholder }} |
-
-
- | {% trans "Circuit ID" %} |
- {{ object.cid }} |
-
-
- | {% trans "Type" %} |
- {{ object.type|linkify }} |
-
-
- | {% 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 "Description" %} |
- {{ object.description|placeholder }} |
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/comments.html' %}
-
-
- {% htmx_table 'circuits:circuitgroupassignment_list' virtual_circuit_id=object.pk %}
-
- {% plugin_right_page object %}
-
-
-
-
-
-
- {% htmx_table 'circuits:virtualcircuittermination_list' virtual_circuit_id=object.pk %}
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/circuits/virtualcircuittermination.html b/netbox/templates/circuits/virtualcircuittermination.html
index 73eeef403..f3b38eb24 100644
--- a/netbox/templates/circuits/virtualcircuittermination.html
+++ b/netbox/templates/circuits/virtualcircuittermination.html
@@ -1,6 +1,4 @@
{% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
{% load i18n %}
{% block breadcrumbs %}
@@ -15,67 +13,3 @@
{{ object.virtual_circuit }}
{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
- | {% trans "Provider" %} |
- {{ object.virtual_circuit.provider|linkify }} |
-
-
- | {% trans "Provider Network" %} |
- {{ object.virtual_circuit.provider_network|linkify }} |
-
-
- | {% trans "Provider account" %} |
- {{ object.virtual_circuit.provider_account|linkify|placeholder }} |
-
-
- | {% trans "Virtual circuit" %} |
- {{ object.virtual_circuit|linkify }} |
-
-
- | {% trans "Role" %} |
- {% badge object.get_role_display bg_color=object.get_role_color %} |
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% include 'inc/panels/custom_fields.html' %}
- {% plugin_left_page object %}
-
-
-
-
-
-
- | {% trans "Device" %} |
- {{ object.interface.device|linkify }} |
-
-
- | {% trans "Interface" %} |
- {{ object.interface|linkify }} |
-
-
- | {% trans "Type" %} |
- {{ object.interface.get_type_display }} |
-
-
- | {% trans "Description" %} |
- {{ object.interface.description|placeholder }} |
-
-
-
- {% plugin_right_page object %}
-
-
-
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/circuits/virtualcircuittype.html b/netbox/templates/circuits/virtualcircuittype.html
index ea76f272d..bdef180e1 100644
--- a/netbox/templates/circuits/virtualcircuittype.html
+++ b/netbox/templates/circuits/virtualcircuittype.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,46 +8,3 @@
{% endif %}
{% endblock extra_controls %}
-
-{% block content %}
-
-
-
-
-
-
- | {% trans "Name" %} |
- {{ object.name }} |
-
-
- | {% trans "Description" %} |
- {{ object.description|placeholder }} |
-
-
- | {% trans "Color" %} |
-
- {% if object.color %}
-
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/related_objects.html' %}
- {% include 'inc/panels/comments.html' %}
- {% include 'inc/panels/custom_fields.html' %}
- {% plugin_right_page object %}
-
-
-
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/ui/attrs/generic_object.html b/netbox/templates/ui/attrs/generic_object.html
index 6ffabb94a..64b1a6d9a 100644
--- a/netbox/templates/ui/attrs/generic_object.html
+++ b/netbox/templates/ui/attrs/generic_object.html
@@ -1,3 +1,5 @@
-
- {% if linkify %}{{ value|linkify }}{% else %}{{ value }}{% endif %}{% if content_type %} ({{ content_type }}){% endif %}
-
+{% load helpers %}
+
+ {% if linkify %}{{ value|linkify }}{% else %}{{ value }}{% endif %}
+ {% if content_type %}
{{ content_type|bettertitle }}
{% endif %}
+
diff --git a/netbox/templates/ui/attrs/object_list.html b/netbox/templates/ui/attrs/object_list.html
new file mode 100644
index 000000000..6daf3fcf2
--- /dev/null
+++ b/netbox/templates/ui/attrs/object_list.html
@@ -0,0 +1,10 @@
+
+ {% for item in items %}
+ -
+ {% include "ui/attrs/object.html" with value=item.value group=item.group linkify=linkify only %}
+
+ {% endfor %}
+ {% if overflow_indicator %}
+ - {{ overflow_indicator }}
+ {% endif %}
+