From e5153fe280f95610a9c4e59a125a72ad4db23ebc Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Fri, 20 Mar 2026 19:36:18 +0100 Subject: [PATCH] feat(circuits): Add UI layout panels for circuits app Implement comprehensive UI panel layouts for all circuit models using the new panel system. Add panels for providers, circuits, terminations, groups, and virtual circuits with proper attribute rendering and actions. --- netbox/circuits/ui/__init__.py | 0 netbox/circuits/ui/panels.py | 139 +++++++++++ netbox/circuits/views.py | 195 +++++++++++++++- netbox/netbox/tests/test_ui.py | 215 ++++++++++++++++++ netbox/netbox/ui/attrs.py | 116 +++++++++- netbox/netbox/ui/panels.py | 1 + netbox/templates/circuits/circuit.html | 98 -------- .../circuits/circuit/attrs/commit_rate.html | 2 + .../circuits/circuit_terminations_swap.html | 30 --- netbox/templates/circuits/circuitgroup.html | 41 ---- .../circuits/circuitgroupassignment.html | 44 ---- .../circuits/circuittermination.html | 42 ---- netbox/templates/circuits/circuittype.html | 46 ---- .../inc/circuit_termination_fields.html | 40 ++-- .../panels/circuit_circuit_termination.html | 68 ++++++ .../circuits/panels/circuit_termination.html | 18 ++ netbox/templates/circuits/provider.html | 49 ---- .../templates/circuits/provideraccount.html | 48 ---- .../templates/circuits/providernetwork.html | 63 ----- netbox/templates/circuits/virtualcircuit.html | 90 -------- .../circuits/virtualcircuittermination.html | 66 ------ .../circuits/virtualcircuittype.html | 46 ---- netbox/templates/ui/attrs/generic_object.html | 8 +- netbox/templates/ui/attrs/object_list.html | 10 + 24 files changed, 779 insertions(+), 696 deletions(-) create mode 100644 netbox/circuits/ui/__init__.py create mode 100644 netbox/circuits/ui/panels.py create mode 100644 netbox/netbox/tests/test_ui.py create mode 100644 netbox/templates/circuits/circuit/attrs/commit_rate.html delete mode 100644 netbox/templates/circuits/circuit_terminations_swap.html create mode 100644 netbox/templates/circuits/panels/circuit_circuit_termination.html create mode 100644 netbox/templates/circuits/panels/circuit_termination.html create mode 100644 netbox/templates/ui/attrs/object_list.html 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 }} {% endblock %} - -{% block content %} -
    -
    -
    -

    {% trans "Circuit" %}

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    {% 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 }}
    -
    -
    -

    - {% trans "Group Assignments" %} - {% if perms.circuits.add_circuitgroupassignment %} - - {% endif %} -

    - {% 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 "Circuit Group" %}

    - - - - - - - - - - - - - -
    {% 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 "Circuit Group Assignment" %}

    - - - - - - - - - - - - - - - - - -
    {% 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 }} {% endblock %} - -{% block content %} -
    -
    - -
    - {% if object %} - - - - - - - - - - {% include 'circuits/inc/circuit_termination_fields.html' with termination=object %} -
    {% trans "Circuit" %} - {{ object.circuit|linkify }} -
    {% trans "Provider" %} - {{ object.circuit.provider|linkify }} -
    - {% 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 "Circuit Type" %}

    - - - - - - - - - - - - - -
    {% 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 %} + +
    +

    + {% blocktrans %}Termination{% endblocktrans %} {{ side }} +
    + {% if not termination and perms.circuits.add_circuittermination %} + + {% trans "Add" %} + + {% endif %} + {% if termination and perms.circuits.change_circuittermination %} + + {% trans "Edit" %} + + {% endif %} + {% if termination and perms.circuits.delete_circuittermination %} + + {% trans "Delete" %} + + {% endif %} +
    +

    + {% if termination %} + + {% include 'circuits/inc/circuit_termination_fields.html' with termination=termination %} + + + + + {% for group_name, fields in termination.get_custom_fields_by_group.items %} + + + + {% for field, value in fields.items %} + + + + + {% endfor %} + {% endfor %} +
    {% trans "Tags" %} + {% for tag in termination.tags.all %} + {% tag tag %} + {% empty %} + {{ ''|placeholder }} + {% endfor %} +
    + {{ group_name|default:"Custom Fields" }} +
    {{ field }} + {% if field.description %} + + {% endif %} + + {% customfield_value field value %} +
    + {% 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 %} + + + + + + + + + + {% if object %} + {% include 'circuits/inc/circuit_termination_fields.html' with termination=object %} + {% endif %} +
    {% trans "Circuit" %}{{ object.circuit|linkify|placeholder }}
    {% trans "Provider" %}{{ object.circuit.provider|linkify|placeholder }}
    +{% 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 "Provider" %}

    - - - - - - - - - -
    {% 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 %} -
    -
    -
    -
    -
    -

    {% trans "Provider Accounts" %}

    - {% htmx_table 'circuits:provideraccount_list' provider_id=object.pk %} -
    -
    -
    -
    -

    {% trans "Circuits" %}

    - {% 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 }} {% endblock %} - -{% block content %} -
    -
    -
    -

    {% trans "Provider Account" %}

    - - - - - - - - - - - - - - - - - -
    {% 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 %} -
    -
    -
    -

    {% trans "Circuits" %}

    - {% 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 }} {% endblock %} - -{% block content %} -
    -
    -
    -

    {% trans "Provider Network" %}

    - - - - - - - - - - - - - - - - - -
    {% 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 %} -
    -
    -
    -
    -
    -

    {% trans "Circuits" %}

    - {% htmx_table 'circuits:circuit_list' provider_network_id=object.pk %} -
    -
    -

    - {% trans "Virtual Circuits" %} - {% if perms.circuits.add_virtualcircuit %} - - {% endif %} -

    - {% 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 "Virtual circuit" %}

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    {% 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' %} -
    -

    - {% trans "Group Assignments" %} - {% if perms.circuits.add_circuitgroupassignment %} - - {% endif %} -

    - {% htmx_table 'circuits:circuitgroupassignment_list' virtual_circuit_id=object.pk %} -
    - {% plugin_right_page object %} -
    -
    -
    -
    -
    -

    - {% trans "Terminations" %} - {% if perms.circuits.add_virtualcircuittermination %} - - {% endif %} -

    - {% 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 "Virtual Circuit Termination" %}

    - - - - - - - - - - - - - - - - - - - - - -
    {% 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 "Interface" %}

    - - - - - - - - - - - - - - - - - -
    {% 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 "Virtual Circuit Type" %}

    - - - - - - - - - - - - - -
    {% 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 %} +