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

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