Răsfoiți Sursa

Closes #13086: Virtual circuits (#17933)

* WIP

* Add API tests

* Add remaining tests

* Add model docs

* Show virtual circuit connections on interfaces

* Misc cleanup per PR feedback

* Renumber migration

* Support nested terminations for virtual circuit bulk import
Jeremy Stretch 1 an în urmă
părinte
comite
d2168b107f
36 a modificat fișierele cu 2164 adăugiri și 15 ștergeri
  1. 33 0
      docs/models/circuits/virtualcircuit.md
  2. 21 0
      docs/models/circuits/virtualcircuittermination.md
  3. 2 0
      mkdocs.yml
  4. 37 2
      netbox/circuits/api/serializers_/circuits.py
  5. 4 0
      netbox/circuits/api/urls.py
  6. 20 0
      netbox/circuits/api/views.py
  7. 16 0
      netbox/circuits/choices.py
  8. 108 1
      netbox/circuits/filtersets.py
  9. 64 1
      netbox/circuits/forms/bulk_edit.py
  10. 74 0
      netbox/circuits/forms/bulk_import.py
  11. 77 1
      netbox/circuits/forms/filtersets.py
  12. 73 3
      netbox/circuits/forms/model_forms.py
  13. 15 1
      netbox/circuits/graphql/filters.py
  14. 6 0
      netbox/circuits/graphql/schema.py
  15. 31 0
      netbox/circuits/graphql/types.py
  16. 67 0
      netbox/circuits/migrations/0050_virtual_circuits.py
  17. 1 0
      netbox/circuits/models/__init__.py
  18. 164 0
      netbox/circuits/models/virtual_circuits.py
  19. 20 0
      netbox/circuits/search.py
  20. 1 0
      netbox/circuits/tables/__init__.py
  21. 95 0
      netbox/circuits/tables/virtual_circuits.py
  22. 239 1
      netbox/circuits/tests/test_api.py
  23. 292 1
      netbox/circuits/tests/test_filtersets.py
  24. 319 2
      netbox/circuits/tests/test_views.py
  25. 16 0
      netbox/circuits/urls.py
  26. 106 0
      netbox/circuits/views.py
  27. 11 1
      netbox/dcim/filtersets.py
  28. 8 0
      netbox/dcim/models/device_components.py
  29. 8 0
      netbox/dcim/tables/devices.py
  30. 14 0
      netbox/dcim/tables/template_code.py
  31. 2 0
      netbox/extras/tests/test_filtersets.py
  32. 7 0
      netbox/netbox/navigation/menu.py
  33. 13 0
      netbox/templates/circuits/providernetwork.html
  34. 84 0
      netbox/templates/circuits/virtualcircuit.html
  35. 81 0
      netbox/templates/circuits/virtualcircuittermination.html
  36. 35 1
      netbox/templates/dcim/interface.html

+ 33 - 0
docs/models/circuits/virtualcircuit.md

@@ -0,0 +1,33 @@
+# Virtual Circuits
+
+A virtual circuit can connect two or more interfaces atop a set of decoupled physical connections. For example, it's very common to form a virtual connection between two virtual interfaces, each of which is bound to a physical interface on its respective device and physically connected to a [provider network](./providernetwork.md) via an independent [physical circuit](./circuit.md).
+
+## Fields
+
+### Provider Network
+
+The [provider network](./providernetwork.md) across which the virtual circuit is formed.
+
+### Provider Account
+
+The [provider account](./provideraccount.md) with which the virtual circuit is associated (if any).
+
+### Circuit ID
+
+The unique identifier assigned to the virtual circuit by its [provider](./provider.md).
+
+### Status
+
+The operational status of the virtual circuit. By default, the following statuses are available:
+
+| Name           |
+|----------------|
+| Planned        |
+| Provisioning   |
+| Active         |
+| Offline        |
+| Deprovisioning |
+| Decommissioned |
+
+!!! tip "Custom circuit statuses"
+    Additional circuit statuses may be defined by setting `Circuit.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.

+ 21 - 0
docs/models/circuits/virtualcircuittermination.md

@@ -0,0 +1,21 @@
+# Virtual Circuit Terminations
+
+This model represents the connection of a virtual [interface](../dcim/interface.md) to a [virtual circuit](./virtualcircuit.md).
+
+## Fields
+
+### Virtual Circuit
+
+The [virtual circuit](./virtualcircuit.md) to which the interface is connected.
+
+### Interface
+
+The [interface](../dcim/interface.md) connected to the virtual circuit.
+
+### Role
+
+The functional role of the termination. This depends on the virtual circuit's topology, which is typically either peer-to-peer or hub-and-spoke (multipoint). Valid choices include:
+
+* Peer
+* Hub
+* Spoke

+ 2 - 0
mkdocs.yml

@@ -174,6 +174,8 @@ nav:
             - Provider: 'models/circuits/provider.md'
             - Provider: 'models/circuits/provider.md'
             - Provider Account: 'models/circuits/provideraccount.md'
             - Provider Account: 'models/circuits/provideraccount.md'
             - Provider Network: 'models/circuits/providernetwork.md'
             - Provider Network: 'models/circuits/providernetwork.md'
+            - Virtual Circuit: 'models/circuits/virtualcircuit.md'
+            - Virtual Circuit Termination: 'models/circuits/virtualcircuittermination.md'
         - Core:
         - Core:
             - DataFile: 'models/core/datafile.md'
             - DataFile: 'models/core/datafile.md'
             - DataSource: 'models/core/datasource.md'
             - DataSource: 'models/core/datasource.md'

+ 37 - 2
netbox/circuits/api/serializers_/circuits.py

@@ -2,9 +2,13 @@ from django.contrib.contenttypes.models import ContentType
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
-from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices
+from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
 from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
 from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
-from circuits.models import Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType
+from circuits.models import (
+    Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType, VirtualCircuit,
+    VirtualCircuitTermination,
+)
+from dcim.api.serializers_.device_components import InterfaceSerializer
 from dcim.api.serializers_.cables import CabledObjectSerializer
 from dcim.api.serializers_.cables import CabledObjectSerializer
 from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
 from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
 from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
 from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
@@ -20,6 +24,8 @@ __all__ = (
     'CircuitGroupSerializer',
     'CircuitGroupSerializer',
     'CircuitTerminationSerializer',
     'CircuitTerminationSerializer',
     'CircuitTypeSerializer',
     'CircuitTypeSerializer',
+    'VirtualCircuitSerializer',
+    'VirtualCircuitTerminationSerializer',
 )
 )
 
 
 
 
@@ -156,3 +162,32 @@ class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
             'id', 'url', 'display_url', 'display', 'group', 'circuit', 'priority', 'tags', 'created', 'last_updated',
             'id', 'url', 'display_url', 'display', 'group', 'circuit', 'priority', 'tags', 'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'group', 'circuit', 'priority')
         brief_fields = ('id', 'url', 'display', 'group', 'circuit', 'priority')
+
+
+class VirtualCircuitSerializer(NetBoxModelSerializer):
+    provider_network = ProviderNetworkSerializer(nested=True)
+    provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
+    status = ChoiceField(choices=CircuitStatusChoices, required=False)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+
+    class Meta:
+        model = VirtualCircuit
+        fields = [
+            'id', 'url', 'display_url', 'display', 'cid', 'provider_network', 'provider_account', 'status', 'tenant',
+            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'provider_network', 'cid', 'description')
+
+
+class VirtualCircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
+    virtual_circuit = VirtualCircuitSerializer(nested=True)
+    role = ChoiceField(choices=VirtualCircuitTerminationRoleChoices, required=False)
+    interface = InterfaceSerializer(nested=True)
+
+    class Meta:
+        model = VirtualCircuitTermination
+        fields = [
+            'id', 'url', 'display_url', 'display', 'virtual_circuit', 'role', 'interface', 'description', 'tags',
+            'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'virtual_circuit', 'role', 'interface', 'description')

+ 4 - 0
netbox/circuits/api/urls.py

@@ -17,5 +17,9 @@ router.register('circuit-terminations', views.CircuitTerminationViewSet)
 router.register('circuit-groups', views.CircuitGroupViewSet)
 router.register('circuit-groups', views.CircuitGroupViewSet)
 router.register('circuit-group-assignments', views.CircuitGroupAssignmentViewSet)
 router.register('circuit-group-assignments', views.CircuitGroupAssignmentViewSet)
 
 
+# Virtual circuits
+router.register('virtual-circuits', views.VirtualCircuitViewSet)
+router.register('virtual-circuit-terminations', views.VirtualCircuitTerminationViewSet)
+
 app_name = 'circuits-api'
 app_name = 'circuits-api'
 urlpatterns = router.urls
 urlpatterns = router.urls

+ 20 - 0
netbox/circuits/api/views.py

@@ -93,3 +93,23 @@ class ProviderNetworkViewSet(NetBoxModelViewSet):
     queryset = ProviderNetwork.objects.all()
     queryset = ProviderNetwork.objects.all()
     serializer_class = serializers.ProviderNetworkSerializer
     serializer_class = serializers.ProviderNetworkSerializer
     filterset_class = filtersets.ProviderNetworkFilterSet
     filterset_class = filtersets.ProviderNetworkFilterSet
+
+
+#
+# Virtual circuits
+#
+
+class VirtualCircuitViewSet(NetBoxModelViewSet):
+    queryset = VirtualCircuit.objects.all()
+    serializer_class = serializers.VirtualCircuitSerializer
+    filterset_class = filtersets.VirtualCircuitFilterSet
+
+
+#
+# Virtual circuit terminations
+#
+
+class VirtualCircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
+    queryset = VirtualCircuitTermination.objects.all()
+    serializer_class = serializers.VirtualCircuitTerminationSerializer
+    filterset_class = filtersets.VirtualCircuitTerminationFilterSet

+ 16 - 0
netbox/circuits/choices.py

@@ -92,3 +92,19 @@ class CircuitPriorityChoices(ChoiceSet):
         (PRIORITY_TERTIARY, _('Tertiary')),
         (PRIORITY_TERTIARY, _('Tertiary')),
         (PRIORITY_INACTIVE, _('Inactive')),
         (PRIORITY_INACTIVE, _('Inactive')),
     ]
     ]
+
+
+#
+# Virtual circuits
+#
+
+class VirtualCircuitTerminationRoleChoices(ChoiceSet):
+    ROLE_PEER = 'peer'
+    ROLE_HUB = 'hub'
+    ROLE_SPOKE = 'spoke'
+
+    CHOICES = [
+        (ROLE_PEER, _('Peer'), 'green'),
+        (ROLE_HUB, _('Hub'), 'blue'),
+        (ROLE_SPOKE, _('Spoke'), 'orange'),
+    ]

+ 108 - 1
netbox/circuits/filtersets.py

@@ -3,7 +3,7 @@ from django.db.models import Q
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from dcim.filtersets import CabledObjectFilterSet
 from dcim.filtersets import CabledObjectFilterSet
-from dcim.models import Location, Region, Site, SiteGroup
+from dcim.models import Interface, Location, Region, Site, SiteGroup
 from ipam.models import ASN
 from ipam.models import ASN
 from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
 from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
 from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
 from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
@@ -20,6 +20,8 @@ __all__ = (
     'ProviderNetworkFilterSet',
     'ProviderNetworkFilterSet',
     'ProviderAccountFilterSet',
     'ProviderAccountFilterSet',
     'ProviderFilterSet',
     'ProviderFilterSet',
+    'VirtualCircuitFilterSet',
+    'VirtualCircuitTerminationFilterSet',
 )
 )
 
 
 
 
@@ -404,3 +406,108 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
             Q(circuit__cid__icontains=value) |
             Q(circuit__cid__icontains=value) |
             Q(group__name__icontains=value)
             Q(group__name__icontains=value)
         )
         )
+
+
+class VirtualCircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+    provider_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='provider_network__provider',
+        queryset=Provider.objects.all(),
+        label=_('Provider (ID)'),
+    )
+    provider = django_filters.ModelMultipleChoiceFilter(
+        field_name='provider_network__provider__slug',
+        queryset=Provider.objects.all(),
+        to_field_name='slug',
+        label=_('Provider (slug)'),
+    )
+    provider_account_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='provider_account',
+        queryset=ProviderAccount.objects.all(),
+        label=_('Provider account (ID)'),
+    )
+    provider_account = django_filters.ModelMultipleChoiceFilter(
+        field_name='provider_account__account',
+        queryset=Provider.objects.all(),
+        to_field_name='account',
+        label=_('Provider account (account)'),
+    )
+    provider_network_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ProviderNetwork.objects.all(),
+        label=_('Provider network (ID)'),
+    )
+    status = django_filters.MultipleChoiceFilter(
+        choices=CircuitStatusChoices,
+        null_value=None
+    )
+
+    class Meta:
+        model = VirtualCircuit
+        fields = ('id', 'cid', 'description')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(cid__icontains=value) |
+            Q(description__icontains=value) |
+            Q(comments__icontains=value)
+        ).distinct()
+
+
+class VirtualCircuitTerminationFilterSet(NetBoxModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label=_('Search'),
+    )
+    virtual_circuit_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=VirtualCircuit.objects.all(),
+        label=_('Virtual circuit'),
+    )
+    role = django_filters.MultipleChoiceFilter(
+        choices=VirtualCircuitTerminationRoleChoices,
+        null_value=None
+    )
+    provider_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='virtual_circuit__provider_network__provider',
+        queryset=Provider.objects.all(),
+        label=_('Provider (ID)'),
+    )
+    provider = django_filters.ModelMultipleChoiceFilter(
+        field_name='virtual_circuit__provider_network__provider__slug',
+        queryset=Provider.objects.all(),
+        to_field_name='slug',
+        label=_('Provider (slug)'),
+    )
+    provider_account_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='virtual_circuit__provider_account',
+        queryset=ProviderAccount.objects.all(),
+        label=_('Provider account (ID)'),
+    )
+    provider_account = django_filters.ModelMultipleChoiceFilter(
+        field_name='virtual_circuit__provider_account__account',
+        queryset=ProviderAccount.objects.all(),
+        to_field_name='account',
+        label=_('Provider account (account)'),
+    )
+    provider_network_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ProviderNetwork.objects.all(),
+        field_name='virtual_circuit__provider_network',
+        label=_('Provider network (ID)'),
+    )
+    interface_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Interface.objects.all(),
+        field_name='interface',
+        label=_('Interface (ID)'),
+    )
+
+    class Meta:
+        model = VirtualCircuitTermination
+        fields = ('id', 'interface_id', 'description')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(virtual_circuit__cid__icontains=value) |
+            Q(description__icontains=value)
+        ).distinct()

+ 64 - 1
netbox/circuits/forms/bulk_edit.py

@@ -3,7 +3,9 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices
+from circuits.choices import (
+    CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices,
+)
 from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
 from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
 from circuits.models import *
 from circuits.models import *
 from dcim.models import Site
 from dcim.models import Site
@@ -28,6 +30,8 @@ __all__ = (
     'ProviderBulkEditForm',
     'ProviderBulkEditForm',
     'ProviderAccountBulkEditForm',
     'ProviderAccountBulkEditForm',
     'ProviderNetworkBulkEditForm',
     'ProviderNetworkBulkEditForm',
+    'VirtualCircuitBulkEditForm',
+    'VirtualCircuitTerminationBulkEditForm',
 )
 )
 
 
 
 
@@ -291,3 +295,62 @@ class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
         FieldSet('circuit', 'priority'),
         FieldSet('circuit', 'priority'),
     )
     )
     nullable_fields = ('priority',)
     nullable_fields = ('priority',)
+
+
+class VirtualCircuitBulkEditForm(NetBoxModelBulkEditForm):
+    provider_network = DynamicModelChoiceField(
+        label=_('Provider network'),
+        queryset=ProviderNetwork.objects.all(),
+        required=False
+    )
+    provider_account = DynamicModelChoiceField(
+        label=_('Provider account'),
+        queryset=ProviderAccount.objects.all(),
+        required=False
+    )
+    status = forms.ChoiceField(
+        label=_('Status'),
+        choices=add_blank_choice(CircuitStatusChoices),
+        required=False,
+        initial=''
+    )
+    tenant = DynamicModelChoiceField(
+        label=_('Tenant'),
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=100,
+        required=False
+    )
+    comments = CommentField()
+
+    model = VirtualCircuit
+    fieldsets = (
+        FieldSet('provider_network', 'provider_account', 'status', 'description', name=_('Virtual circuit')),
+        FieldSet('tenant', name=_('Tenancy')),
+    )
+    nullable_fields = (
+        'provider_account', 'tenant', 'description', 'comments',
+    )
+
+
+class VirtualCircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
+    role = forms.ChoiceField(
+        label=_('Role'),
+        choices=add_blank_choice(VirtualCircuitTerminationRoleChoices),
+        required=False,
+        initial=''
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+
+    model = VirtualCircuitTermination
+    fieldsets = (
+        FieldSet('role', 'description'),
+    )
+    nullable_fields = ('description',)

+ 74 - 0
netbox/circuits/forms/bulk_import.py

@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
 from circuits.choices import *
 from circuits.choices import *
 from circuits.constants import *
 from circuits.constants import *
 from circuits.models import *
 from circuits.models import *
+from dcim.models import Interface
 from netbox.choices import DistanceUnitChoices
 from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -20,6 +21,9 @@ __all__ = (
     'ProviderImportForm',
     'ProviderImportForm',
     'ProviderAccountImportForm',
     'ProviderAccountImportForm',
     'ProviderNetworkImportForm',
     'ProviderNetworkImportForm',
+    'VirtualCircuitImportForm',
+    'VirtualCircuitTerminationImportForm',
+    'VirtualCircuitTerminationImportRelatedForm',
 )
 )
 
 
 
 
@@ -179,3 +183,73 @@ class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = CircuitGroupAssignment
         model = CircuitGroupAssignment
         fields = ('circuit', 'group', 'priority')
         fields = ('circuit', 'group', 'priority')
+
+
+class VirtualCircuitImportForm(NetBoxModelImportForm):
+    provider_network = CSVModelChoiceField(
+        label=_('Provider network'),
+        queryset=ProviderNetwork.objects.all(),
+        to_field_name='name',
+        help_text=_('The network to which this virtual circuit belongs')
+    )
+    provider_account = CSVModelChoiceField(
+        label=_('Provider account'),
+        queryset=ProviderAccount.objects.all(),
+        to_field_name='account',
+        help_text=_('Assigned provider account (if any)'),
+        required=False
+    )
+    status = CSVChoiceField(
+        label=_('Status'),
+        choices=CircuitStatusChoices,
+        help_text=_('Operational status')
+    )
+    tenant = CSVModelChoiceField(
+        label=_('Tenant'),
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Assigned tenant')
+    )
+
+    class Meta:
+        model = VirtualCircuit
+        fields = [
+            'cid', 'provider_network', 'provider_account', 'status', 'tenant', 'description', 'comments', 'tags',
+        ]
+
+
+class BaseVirtualCircuitTerminationImportForm(forms.ModelForm):
+    virtual_circuit = CSVModelChoiceField(
+        label=_('Virtual circuit'),
+        queryset=VirtualCircuit.objects.all(),
+        to_field_name='cid',
+    )
+    role = CSVChoiceField(
+        label=_('Role'),
+        choices=VirtualCircuitTerminationRoleChoices,
+        help_text=_('Operational role')
+    )
+    interface = CSVModelChoiceField(
+        label=_('Interface'),
+        queryset=Interface.objects.all(),
+        to_field_name='pk',
+    )
+
+
+class VirtualCircuitTerminationImportRelatedForm(BaseVirtualCircuitTerminationImportForm):
+
+    class Meta:
+        model = VirtualCircuitTermination
+        fields = [
+            'virtual_circuit', 'role', 'interface', 'description',
+        ]
+
+
+class VirtualCircuitTerminationImportForm(NetBoxModelImportForm, BaseVirtualCircuitTerminationImportForm):
+
+    class Meta:
+        model = VirtualCircuitTermination
+        fields = [
+            'virtual_circuit', 'role', 'interface', 'description', 'tags',
+        ]

+ 77 - 1
netbox/circuits/forms/filtersets.py

@@ -1,7 +1,10 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, CircuitTerminationSideChoices
+from circuits.choices import (
+    CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, CircuitTerminationSideChoices,
+    VirtualCircuitTerminationRoleChoices,
+)
 from circuits.models import *
 from circuits.models import *
 from dcim.models import Location, Region, Site, SiteGroup
 from dcim.models import Location, Region, Site, SiteGroup
 from ipam.models import ASN
 from ipam.models import ASN
@@ -22,6 +25,8 @@ __all__ = (
     'ProviderFilterForm',
     'ProviderFilterForm',
     'ProviderAccountFilterForm',
     'ProviderAccountFilterForm',
     'ProviderNetworkFilterForm',
     'ProviderNetworkFilterForm',
+    'VirtualCircuitFilterForm',
+    'VirtualCircuitTerminationFilterForm',
 )
 )
 
 
 
 
@@ -292,3 +297,74 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
         required=False
         required=False
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
+
+
+class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
+    model = VirtualCircuit
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
+        FieldSet('status', name=_('Attributes')),
+        FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+    )
+    selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
+    provider_id = DynamicModelMultipleChoiceField(
+        queryset=Provider.objects.all(),
+        required=False,
+        label=_('Provider')
+    )
+    provider_account_id = DynamicModelMultipleChoiceField(
+        queryset=ProviderAccount.objects.all(),
+        required=False,
+        query_params={
+            'provider_id': '$provider_id'
+        },
+        label=_('Provider account')
+    )
+    provider_network_id = DynamicModelMultipleChoiceField(
+        queryset=ProviderNetwork.objects.all(),
+        required=False,
+        query_params={
+            'provider_id': '$provider_id'
+        },
+        label=_('Provider network')
+    )
+    status = forms.MultipleChoiceField(
+        label=_('Status'),
+        choices=CircuitStatusChoices,
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+class VirtualCircuitTerminationFilterForm(NetBoxModelFilterSetForm):
+    model = VirtualCircuitTermination
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('virtual_circuit_id', 'role', name=_('Virtual circuit')),
+        FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
+    )
+    virtual_circuit_id = DynamicModelMultipleChoiceField(
+        queryset=VirtualCircuit.objects.all(),
+        required=False,
+        label=_('Virtual circuit')
+    )
+    role = forms.MultipleChoiceField(
+        label=_('Role'),
+        choices=VirtualCircuitTerminationRoleChoices,
+        required=False
+    )
+    provider_network_id = DynamicModelMultipleChoiceField(
+        queryset=ProviderNetwork.objects.all(),
+        required=False,
+        query_params={
+            'provider_id': '$provider_id'
+        },
+        label=_('Provider network')
+    )
+    provider_id = DynamicModelMultipleChoiceField(
+        queryset=Provider.objects.all(),
+        required=False,
+        label=_('Provider')
+    )
+    tag = TagFilterField(model)

+ 73 - 3
netbox/circuits/forms/model_forms.py

@@ -1,16 +1,21 @@
+from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from circuits.choices import CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices
+from circuits.choices import (
+    CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices, VirtualCircuitTerminationRoleChoices,
+)
 from circuits.constants import *
 from circuits.constants import *
 from circuits.models import *
 from circuits.models import *
-from dcim.models import Site
+from dcim.models import Interface, Site
 from ipam.models import ASN
 from ipam.models import ASN
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from utilities.forms import get_field_value
 from utilities.forms import get_field_value
-from utilities.forms.fields import CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
+from utilities.forms.fields import (
+    CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
+)
 from utilities.forms.rendering import FieldSet, InlineFields
 from utilities.forms.rendering import FieldSet, InlineFields
 from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
 from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
 from utilities.templatetags.builtins.filters import bettertitle
 from utilities.templatetags.builtins.filters import bettertitle
@@ -24,6 +29,8 @@ __all__ = (
     'ProviderForm',
     'ProviderForm',
     'ProviderAccountForm',
     'ProviderAccountForm',
     'ProviderNetworkForm',
     'ProviderNetworkForm',
+    'VirtualCircuitForm',
+    'VirtualCircuitTerminationForm',
 )
 )
 
 
 
 
@@ -255,3 +262,66 @@ class CircuitGroupAssignmentForm(NetBoxModelForm):
         fields = [
         fields = [
             'group', 'circuit', 'priority', 'tags',
             'group', 'circuit', 'priority', 'tags',
         ]
         ]
+
+
+class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
+    provider_network = DynamicModelChoiceField(
+        label=_('Provider network'),
+        queryset=ProviderNetwork.objects.all(),
+        selector=True
+    )
+    provider_account = DynamicModelChoiceField(
+        label=_('Provider account'),
+        queryset=ProviderAccount.objects.all(),
+        required=False
+    )
+    comments = CommentField()
+
+    fieldsets = (
+        FieldSet(
+            'provider_network', 'provider_account', 'cid', 'status', 'description', 'tags', name=_('Virtual circuit'),
+        ),
+        FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
+    )
+
+    class Meta:
+        model = VirtualCircuit
+        fields = [
+            'cid', 'provider_network', 'provider_account', 'status', 'description', 'tenant_group', 'tenant',
+            'comments', 'tags',
+        ]
+
+
+class VirtualCircuitTerminationForm(NetBoxModelForm):
+    virtual_circuit = DynamicModelChoiceField(
+        label=_('Virtual circuit'),
+        queryset=VirtualCircuit.objects.all(),
+        selector=True
+    )
+    role = forms.ChoiceField(
+        choices=VirtualCircuitTerminationRoleChoices,
+        widget=HTMXSelect(),
+        label=_('Role')
+    )
+    interface = DynamicModelChoiceField(
+        label=_('Interface'),
+        queryset=Interface.objects.all(),
+        selector=True,
+        query_params={
+            'kind': 'virtual',
+            'virtual_circuit_termination_id': 'null',
+        },
+        context={
+            'parent': 'device',
+        }
+    )
+
+    fieldsets = (
+        FieldSet('virtual_circuit', 'role', 'interface', 'description', 'tags'),
+    )
+
+    class Meta:
+        model = VirtualCircuitTermination
+        fields = [
+            'virtual_circuit', 'role', 'interface', 'description', 'tags',
+        ]

+ 15 - 1
netbox/circuits/graphql/filters.py

@@ -4,14 +4,16 @@ from circuits import filtersets, models
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 
 
 __all__ = (
 __all__ = (
-    'CircuitTerminationFilter',
     'CircuitFilter',
     'CircuitFilter',
     'CircuitGroupAssignmentFilter',
     'CircuitGroupAssignmentFilter',
     'CircuitGroupFilter',
     'CircuitGroupFilter',
+    'CircuitTerminationFilter',
     'CircuitTypeFilter',
     'CircuitTypeFilter',
     'ProviderFilter',
     'ProviderFilter',
     'ProviderAccountFilter',
     'ProviderAccountFilter',
     'ProviderNetworkFilter',
     'ProviderNetworkFilter',
+    'VirtualCircuitFilter',
+    'VirtualCircuitTerminationFilter',
 )
 )
 
 
 
 
@@ -61,3 +63,15 @@ class ProviderAccountFilter(BaseFilterMixin):
 @autotype_decorator(filtersets.ProviderNetworkFilterSet)
 @autotype_decorator(filtersets.ProviderNetworkFilterSet)
 class ProviderNetworkFilter(BaseFilterMixin):
 class ProviderNetworkFilter(BaseFilterMixin):
     pass
     pass
+
+
+@strawberry_django.filter(models.VirtualCircuit, lookups=True)
+@autotype_decorator(filtersets.VirtualCircuitFilterSet)
+class VirtualCircuitFilter(BaseFilterMixin):
+    pass
+
+
+@strawberry_django.filter(models.VirtualCircuitTermination, lookups=True)
+@autotype_decorator(filtersets.VirtualCircuitTerminationFilterSet)
+class VirtualCircuitTerminationFilter(BaseFilterMixin):
+    pass

+ 6 - 0
netbox/circuits/graphql/schema.py

@@ -31,3 +31,9 @@ class CircuitsQuery:
 
 
     provider_network: ProviderNetworkType = strawberry_django.field()
     provider_network: ProviderNetworkType = strawberry_django.field()
     provider_network_list: List[ProviderNetworkType] = strawberry_django.field()
     provider_network_list: List[ProviderNetworkType] = strawberry_django.field()
+
+    virtual_circuit: VirtualCircuitType = strawberry_django.field()
+    virtual_circuit_list: List[VirtualCircuitType] = strawberry_django.field()
+
+    virtual_circuit_termination: VirtualCircuitTerminationType = strawberry_django.field()
+    virtual_circuit_termination_list: List[VirtualCircuitTerminationType] = strawberry_django.field()

+ 31 - 0
netbox/circuits/graphql/types.py

@@ -19,6 +19,8 @@ __all__ = (
     'ProviderType',
     'ProviderType',
     'ProviderAccountType',
     'ProviderAccountType',
     'ProviderNetworkType',
     'ProviderNetworkType',
+    'VirtualCircuitTerminationType',
+    'VirtualCircuitType',
 )
 )
 
 
 
 
@@ -120,3 +122,32 @@ class CircuitGroupType(OrganizationalObjectType):
 class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
 class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
     group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')]
     group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')]
     circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]
     circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]
+
+
+@strawberry_django.type(
+    models.VirtualCircuitTermination,
+    fields='__all__',
+    filters=VirtualCircuitTerminationFilter
+)
+class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
+    virtual_circuit: Annotated[
+        "VirtualCircuitType",
+        strawberry.lazy('circuits.graphql.types')
+    ] = strawberry_django.field(select_related=["virtual_circuit"])
+    interface: Annotated[
+        "InterfaceType",
+        strawberry.lazy('dcim.graphql.types')
+    ] = strawberry_django.field(select_related=["interface"])
+
+
+@strawberry_django.type(
+    models.VirtualCircuit,
+    fields='__all__',
+    filters=VirtualCircuitFilter
+)
+class VirtualCircuitType(NetBoxObjectType):
+    provider_network: ProviderNetworkType = strawberry_django.field(select_related=["provider_network"])
+    provider_account: ProviderAccountType | None
+    tenant: TenantType | None
+
+    terminations: List[VirtualCircuitTerminationType]

+ 67 - 0
netbox/circuits/migrations/0050_virtual_circuits.py

@@ -0,0 +1,67 @@
+import django.db.models.deletion
+import taggit.managers
+from django.db import migrations, models
+
+import utilities.json
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0049_natural_ordering'),
+        ('dcim', '0196_qinq_svlan'),
+        ('extras', '0122_charfield_null_choices'),
+        ('tenancy', '0016_charfield_null_choices'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='VirtualCircuit',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('comments', models.TextField(blank=True)),
+                ('cid', models.CharField(max_length=100)),
+                ('status', models.CharField(default='active', max_length=50)),
+                ('provider_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_circuits', to='circuits.provideraccount')),
+                ('provider_network', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='virtual_circuits', to='circuits.providernetwork')),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+                ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_circuits', to='tenancy.tenant')),
+            ],
+            options={
+                'verbose_name': 'circuit',
+                'verbose_name_plural': 'circuits',
+                'ordering': ['provider_network', 'provider_account', 'cid'],
+            },
+        ),
+        migrations.CreateModel(
+            name='VirtualCircuitTermination',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+                ('role', models.CharField(default='peer', max_length=50)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('interface', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='virtual_circuit_termination', to='dcim.interface')),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+                ('virtual_circuit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='circuits.virtualcircuit')),
+            ],
+            options={
+                'verbose_name': 'virtual circuit termination',
+                'verbose_name_plural': 'virtual circuit terminations',
+                'ordering': ['virtual_circuit', 'role', 'pk'],
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='virtualcircuit',
+            constraint=models.UniqueConstraint(fields=('provider_network', 'cid'), name='circuits_virtualcircuit_unique_provider_network_cid'),
+        ),
+        migrations.AddConstraint(
+            model_name='virtualcircuit',
+            constraint=models.UniqueConstraint(fields=('provider_account', 'cid'), name='circuits_virtualcircuit_unique_provideraccount_cid'),
+        ),
+    ]

+ 1 - 0
netbox/circuits/models/__init__.py

@@ -1,2 +1,3 @@
 from .circuits import *
 from .circuits import *
 from .providers import *
 from .providers import *
+from .virtual_circuits import *

+ 164 - 0
netbox/circuits/models/virtual_circuits.py

@@ -0,0 +1,164 @@
+from functools import cached_property
+
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+from circuits.choices import *
+from netbox.models import ChangeLoggedModel, PrimaryModel
+from netbox.models.features import CustomFieldsMixin, CustomLinksMixin, TagsMixin
+
+__all__ = (
+    'VirtualCircuit',
+    'VirtualCircuitTermination',
+)
+
+
+class VirtualCircuit(PrimaryModel):
+    """
+    A virtual connection between two or more endpoints, delivered across one or more physical circuits.
+    """
+    cid = models.CharField(
+        max_length=100,
+        verbose_name=_('circuit ID'),
+        help_text=_('Unique circuit ID')
+    )
+    provider_network = models.ForeignKey(
+        to='circuits.ProviderNetwork',
+        on_delete=models.PROTECT,
+        related_name='virtual_circuits'
+    )
+    provider_account = models.ForeignKey(
+        to='circuits.ProviderAccount',
+        on_delete=models.PROTECT,
+        related_name='virtual_circuits',
+        blank=True,
+        null=True
+    )
+    status = models.CharField(
+        verbose_name=_('status'),
+        max_length=50,
+        choices=CircuitStatusChoices,
+        default=CircuitStatusChoices.STATUS_ACTIVE
+    )
+    tenant = models.ForeignKey(
+        to='tenancy.Tenant',
+        on_delete=models.PROTECT,
+        related_name='virtual_circuits',
+        blank=True,
+        null=True
+    )
+
+    clone_fields = (
+        'provider_network', 'provider_account', 'status', 'tenant', 'description',
+    )
+    prerequisite_models = (
+        'circuits.ProviderNetwork',
+    )
+
+    class Meta:
+        ordering = ['provider_network', 'provider_account', 'cid']
+        constraints = (
+            models.UniqueConstraint(
+                fields=('provider_network', 'cid'),
+                name='%(app_label)s_%(class)s_unique_provider_network_cid'
+            ),
+            models.UniqueConstraint(
+                fields=('provider_account', 'cid'),
+                name='%(app_label)s_%(class)s_unique_provideraccount_cid'
+            ),
+        )
+        verbose_name = _('virtual circuit')
+        verbose_name_plural = _('virtual circuits')
+
+    def __str__(self):
+        return self.cid
+
+    def get_status_color(self):
+        return CircuitStatusChoices.colors.get(self.status)
+
+    def clean(self):
+        super().clean()
+
+        if self.provider_account and self.provider_network.provider != self.provider_account.provider:
+            raise ValidationError({
+                'provider_account': "The assigned account must belong to the provider of the assigned network."
+            })
+
+    @property
+    def provider(self):
+        return self.provider_network.provider
+
+
+class VirtualCircuitTermination(
+    CustomFieldsMixin,
+    CustomLinksMixin,
+    TagsMixin,
+    ChangeLoggedModel
+):
+    virtual_circuit = models.ForeignKey(
+        to='circuits.VirtualCircuit',
+        on_delete=models.CASCADE,
+        related_name='terminations'
+    )
+    role = models.CharField(
+        verbose_name=_('role'),
+        max_length=50,
+        choices=VirtualCircuitTerminationRoleChoices,
+        default=VirtualCircuitTerminationRoleChoices.ROLE_PEER
+    )
+    interface = models.OneToOneField(
+        to='dcim.Interface',
+        on_delete=models.CASCADE,
+        related_name='virtual_circuit_termination'
+    )
+    description = models.CharField(
+        verbose_name=_('description'),
+        max_length=200,
+        blank=True
+    )
+
+    class Meta:
+        ordering = ['virtual_circuit', 'role', 'pk']
+        verbose_name = _('virtual circuit termination')
+        verbose_name_plural = _('virtual circuit terminations')
+
+    def __str__(self):
+        return f'{self.virtual_circuit}: {self.get_role_display()} termination'
+
+    def get_absolute_url(self):
+        return reverse('circuits:virtualcircuittermination', args=[self.pk])
+
+    def get_role_color(self):
+        return VirtualCircuitTerminationRoleChoices.colors.get(self.role)
+
+    def to_objectchange(self, action):
+        objectchange = super().to_objectchange(action)
+        objectchange.related_object = self.virtual_circuit
+        return objectchange
+
+    @property
+    def parent_object(self):
+        return self.virtual_circuit
+
+    @cached_property
+    def peer_terminations(self):
+        if self.role == VirtualCircuitTerminationRoleChoices.ROLE_PEER:
+            return self.virtual_circuit.terminations.exclude(pk=self.pk).filter(
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER
+            )
+        if self.role == VirtualCircuitTerminationRoleChoices.ROLE_HUB:
+            return self.virtual_circuit.terminations.filter(
+                role=VirtualCircuitTerminationRoleChoices.ROLE_SPOKE
+            )
+        if self.role == VirtualCircuitTerminationRoleChoices.ROLE_SPOKE:
+            return self.virtual_circuit.terminations.filter(
+                role=VirtualCircuitTerminationRoleChoices.ROLE_HUB
+            )
+
+    def clean(self):
+        super().clean()
+
+        if self.interface and not self.interface.is_virtual:
+            raise ValidationError("Virtual circuits may be terminated only to virtual interfaces.")

+ 20 - 0
netbox/circuits/search.py

@@ -80,3 +80,23 @@ class ProviderNetworkIndex(SearchIndex):
         ('comments', 5000),
         ('comments', 5000),
     )
     )
     display_attrs = ('provider', 'service_id', 'description')
     display_attrs = ('provider', 'service_id', 'description')
+
+
+@register_search
+class VirtualCircuitIndex(SearchIndex):
+    model = models.VirtualCircuit
+    fields = (
+        ('cid', 100),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('provider', 'provider_network', 'provider_account', 'status', 'tenant', 'description')
+
+
+@register_search
+class VirtualCircuitTerminationIndex(SearchIndex):
+    model = models.VirtualCircuitTermination
+    fields = (
+        ('description', 500),
+    )
+    display_attrs = ('virtual_circuit', 'role', 'description')

+ 1 - 0
netbox/circuits/tables/__init__.py

@@ -1,3 +1,4 @@
 from .circuits import *
 from .circuits import *
 from .columns import *
 from .columns import *
 from .providers import *
 from .providers import *
+from .virtual_circuits import *

+ 95 - 0
netbox/circuits/tables/virtual_circuits.py

@@ -0,0 +1,95 @@
+import django_tables2 as tables
+from django.utils.translation import gettext_lazy as _
+
+from circuits.models import *
+from netbox.tables import NetBoxTable, columns
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+
+__all__ = (
+    'VirtualCircuitTable',
+    'VirtualCircuitTerminationTable',
+)
+
+
+class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
+    cid = tables.Column(
+        linkify=True,
+        verbose_name=_('Circuit ID')
+    )
+    provider = tables.Column(
+        accessor=tables.A('provider_network__provider'),
+        verbose_name=_('Provider'),
+        linkify=True
+    )
+    provider_network = tables.Column(
+        linkify=True,
+        verbose_name=_('Provider network')
+    )
+    provider_account = tables.Column(
+        linkify=True,
+        verbose_name=_('Account')
+    )
+    status = columns.ChoiceFieldColumn()
+    termination_count = columns.LinkedCountColumn(
+        viewname='circuits:virtualcircuittermination_list',
+        url_params={'virtual_circuit_id': 'pk'},
+        verbose_name=_('Terminations')
+    )
+    comments = columns.MarkdownColumn(
+        verbose_name=_('Comments')
+    )
+    tags = columns.TagColumn(
+        url_name='circuits:virtualcircuit_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = VirtualCircuit
+        fields = (
+            'pk', 'id', 'cid', 'provider', 'provider_account', 'provider_network', 'status', 'tenant', 'tenant_group',
+            'description', 'comments', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'cid', 'provider', 'provider_account', 'provider_network', 'status', 'tenant', 'termination_count',
+            'description',
+        )
+
+
+class VirtualCircuitTerminationTable(NetBoxTable):
+    virtual_circuit = tables.Column(
+        verbose_name=_('Virtual circuit'),
+        linkify=True
+    )
+    provider = tables.Column(
+        accessor=tables.A('virtual_circuit__provider_network__provider'),
+        verbose_name=_('Provider'),
+        linkify=True
+    )
+    provider_network = tables.Column(
+        accessor=tables.A('virtual_circuit__provider_network'),
+        linkify=True,
+        verbose_name=_('Provider network')
+    )
+    provider_account = tables.Column(
+        linkify=True,
+        verbose_name=_('Account')
+    )
+    role = columns.ChoiceFieldColumn()
+    device = tables.Column(
+        accessor=tables.A('interface__device'),
+        linkify=True,
+        verbose_name=_('Device')
+    )
+    interface = tables.Column(
+        verbose_name=_('Interface'),
+        linkify=True
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = VirtualCircuitTermination
+        fields = (
+            'pk', 'id', 'virtual_circuit', 'provider', 'provider_network', 'provider_account', 'role', 'interfaces',
+            'description', 'created', 'last_updated', 'actions',
+        )
+        default_columns = (
+            'pk', 'id', 'virtual_circuit', 'role', 'device', 'interface', 'description',
+        )

+ 239 - 1
netbox/circuits/tests/test_api.py

@@ -2,7 +2,8 @@ from django.urls import reverse
 
 
 from circuits.choices import *
 from circuits.choices import *
 from circuits.models import *
 from circuits.models import *
-from dcim.models import Site
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
 from ipam.models import ASN, RIR
 from ipam.models import ASN, RIR
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
 
 
@@ -397,3 +398,240 @@ class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
             'provider': providers[1].pk,
             'provider': providers[1].pk,
             'description': 'New description',
             'description': 'New description',
         }
         }
+
+
+class VirtualCircuitTest(APIViewTestCases.APIViewTestCase):
+    model = VirtualCircuit
+    brief_fields = ['cid', 'description', 'display', 'id', 'provider_network', 'url']
+    bulk_update_data = {
+        'status': 'planned',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
+        provider_account = ProviderAccount.objects.create(provider=provider, account='Provider Account 1')
+
+        virtual_circuits = (
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 1'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 2'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 3'
+            ),
+        )
+        VirtualCircuit.objects.bulk_create(virtual_circuits)
+
+        cls.create_data = [
+            {
+                'cid': 'Virtual Circuit 4',
+                'provider_network': provider_network.pk,
+                'provider_account': provider_account.pk,
+                'status': CircuitStatusChoices.STATUS_PLANNED,
+            },
+            {
+                'cid': 'Virtual Circuit 5',
+                'provider_network': provider_network.pk,
+                'provider_account': provider_account.pk,
+                'status': CircuitStatusChoices.STATUS_PLANNED,
+            },
+            {
+                'cid': 'Virtual Circuit 6',
+                'provider_network': provider_network.pk,
+                'provider_account': provider_account.pk,
+                'status': CircuitStatusChoices.STATUS_PLANNED,
+            },
+        ]
+
+
+class VirtualCircuitTerminationTest(APIViewTestCases.APIViewTestCase):
+    model = VirtualCircuitTermination
+    brief_fields = ['description', 'display', 'id', 'interface', 'role', 'url', 'virtual_circuit']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
+        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        devices = (
+            Device(site=site, name='hub', device_type=device_type, role=device_role),
+            Device(site=site, name='spoke1', device_type=device_type, role=device_role),
+            Device(site=site, name='spoke2', device_type=device_type, role=device_role),
+            Device(site=site, name='spoke3', device_type=device_type, role=device_role),
+        )
+        Device.objects.bulk_create(devices)
+
+        physical_interfaces = (
+            Interface(device=devices[0], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[1], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[2], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[3], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+        )
+        Interface.objects.bulk_create(physical_interfaces)
+
+        virtual_interfaces = (
+            # Point-to-point VCs
+            Interface(
+                device=devices[0],
+                name='eth0.1',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[0],
+                name='eth0.2',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[0],
+                name='eth0.3',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[1],
+                name='eth0.1',
+                parent=physical_interfaces[1],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[2],
+                name='eth0.1',
+                parent=physical_interfaces[2],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[3],
+                name='eth0.1',
+                parent=physical_interfaces[3],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+
+            # Hub and spoke VCs
+            Interface(
+                device=devices[0],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[1],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[2],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[3],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+        )
+        Interface.objects.bulk_create(virtual_interfaces)
+
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
+        provider_account = ProviderAccount.objects.create(provider=provider, account='Provider Account 1')
+
+        virtual_circuits = (
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 1'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 2'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 3'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 4'
+            ),
+        )
+        VirtualCircuit.objects.bulk_create(virtual_circuits)
+
+        virtual_circuit_terminations = (
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[0],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[0]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[0],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[3]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[1],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[1]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[1],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[4]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[2],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[2]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[2],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[5]
+            ),
+        )
+        VirtualCircuitTermination.objects.bulk_create(virtual_circuit_terminations)
+
+        cls.create_data = [
+            {
+                'virtual_circuit': virtual_circuits[3].pk,
+                'role': VirtualCircuitTerminationRoleChoices.ROLE_HUB,
+                'interface': virtual_interfaces[6].pk
+            },
+            {
+                'virtual_circuit': virtual_circuits[3].pk,
+                'role': VirtualCircuitTerminationRoleChoices.ROLE_SPOKE,
+                'interface': virtual_interfaces[7].pk
+            },
+            {
+                'virtual_circuit': virtual_circuits[3].pk,
+                'role': VirtualCircuitTerminationRoleChoices.ROLE_SPOKE,
+                'interface': virtual_interfaces[8].pk
+            },
+            {
+                'virtual_circuit': virtual_circuits[3].pk,
+                'role': VirtualCircuitTerminationRoleChoices.ROLE_SPOKE,
+                'interface': virtual_interfaces[9].pk
+            },
+        ]

+ 292 - 1
netbox/circuits/tests/test_filtersets.py

@@ -3,7 +3,8 @@ from django.test import TestCase
 from circuits.choices import *
 from circuits.choices import *
 from circuits.filtersets import *
 from circuits.filtersets import *
 from circuits.models import *
 from circuits.models import *
-from dcim.models import Cable, Region, Site, SiteGroup
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Cable, Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site, SiteGroup
 from ipam.models import ASN, RIR
 from ipam.models import ASN, RIR
 from netbox.choices import DistanceUnitChoices
 from netbox.choices import DistanceUnitChoices
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
@@ -678,3 +679,293 @@ class ProviderAccountTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'provider': [providers[0].slug, providers[1].slug]}
         params = {'provider': [providers[0].slug, providers[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = VirtualCircuit.objects.all()
+    filterset = VirtualCircuitFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        tenant_groups = (
+            TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
+            TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
+            TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
+        )
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
+
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
+            Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
+            Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        providers = (
+            Provider(name='Provider 1', slug='provider-1'),
+            Provider(name='Provider 2', slug='provider-2'),
+            Provider(name='Provider 3', slug='provider-3'),
+        )
+        Provider.objects.bulk_create(providers)
+
+        provider_accounts = (
+            ProviderAccount(name='Provider Account 1', provider=providers[0], account='A'),
+            ProviderAccount(name='Provider Account 2', provider=providers[1], account='B'),
+            ProviderAccount(name='Provider Account 3', provider=providers[2], account='C'),
+        )
+        ProviderAccount.objects.bulk_create(provider_accounts)
+
+        provider_networks = (
+            ProviderNetwork(name='Provider Network 1', provider=providers[0]),
+            ProviderNetwork(name='Provider Network 2', provider=providers[1]),
+            ProviderNetwork(name='Provider Network 3', provider=providers[2]),
+        )
+        ProviderNetwork.objects.bulk_create(provider_networks)
+
+        virutal_circuits = (
+            VirtualCircuit(
+                provider_network=provider_networks[0],
+                provider_account=provider_accounts[0],
+                tenant=tenants[0],
+                cid='Virtual Circuit 1',
+                status=CircuitStatusChoices.STATUS_PLANNED,
+                description='virtualcircuit1',
+            ),
+            VirtualCircuit(
+                provider_network=provider_networks[1],
+                provider_account=provider_accounts[1],
+                tenant=tenants[1],
+                cid='Virtual Circuit 2',
+                status=CircuitStatusChoices.STATUS_ACTIVE,
+                description='virtualcircuit2',
+            ),
+            VirtualCircuit(
+                provider_network=provider_networks[2],
+                provider_account=provider_accounts[2],
+                tenant=tenants[2],
+                cid='Virtual Circuit 3',
+                status=CircuitStatusChoices.STATUS_DEPROVISIONING,
+                description='virtualcircuit3',
+            ),
+        )
+        VirtualCircuit.objects.bulk_create(virutal_circuits)
+
+    def test_q(self):
+        params = {'q': 'virtualcircuit1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_cid(self):
+        params = {'cid': ['Virtual Circuit 1', 'Virtual Circuit 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_provider(self):
+        providers = Provider.objects.all()[:2]
+        params = {'provider_id': [providers[0].pk, providers[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'provider': [providers[0].slug, providers[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_provider_account(self):
+        provider_accounts = ProviderAccount.objects.all()[:2]
+        params = {'provider_account_id': [provider_accounts[0].pk, provider_accounts[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_provider_network(self):
+        provider_networks = ProviderNetwork.objects.all()[:2]
+        params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_status(self):
+        params = {'status': [CircuitStatusChoices.STATUS_ACTIVE, CircuitStatusChoices.STATUS_PLANNED]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['virtualcircuit1', 'virtualcircuit2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_tenant(self):
+        tenants = Tenant.objects.all()[:2]
+        params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'tenant': [tenants[0].slug, tenants[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_tenant_group(self):
+        tenant_groups = TenantGroup.objects.all()[:2]
+        params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class VirtualCircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = VirtualCircuitTermination.objects.all()
+    filterset = VirtualCircuitTerminationFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
+        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        devices = (
+            Device(site=site, name='Device 1', device_type=device_type, role=device_role),
+            Device(site=site, name='Device 2', device_type=device_type, role=device_role),
+            Device(site=site, name='Device 3', device_type=device_type, role=device_role),
+        )
+        Device.objects.bulk_create(devices)
+
+        virtual_interfaces = (
+            # Device 1
+            Interface(
+                device=devices[0],
+                name='eth0.1',
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[0],
+                name='eth0.2',
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            # Device 2
+            Interface(
+                device=devices[1],
+                name='eth0.1',
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[1],
+                name='eth0.2',
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            # Device 3
+            Interface(
+                device=devices[2],
+                name='eth0.1',
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[2],
+                name='eth0.2',
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+        )
+        Interface.objects.bulk_create(virtual_interfaces)
+
+        providers = (
+            Provider(name='Provider 1', slug='provider-1'),
+            Provider(name='Provider 2', slug='provider-2'),
+            Provider(name='Provider 3', slug='provider-3'),
+        )
+        Provider.objects.bulk_create(providers)
+        provider_networks = (
+            ProviderNetwork(provider=providers[0], name='Provider Network 1'),
+            ProviderNetwork(provider=providers[1], name='Provider Network 2'),
+            ProviderNetwork(provider=providers[2], name='Provider Network 3'),
+        )
+        ProviderNetwork.objects.bulk_create(provider_networks)
+        provider_accounts = (
+            ProviderAccount(provider=providers[0], account='Provider Account 1'),
+            ProviderAccount(provider=providers[1], account='Provider Account 2'),
+            ProviderAccount(provider=providers[2], account='Provider Account 3'),
+        )
+        ProviderAccount.objects.bulk_create(provider_accounts)
+
+        virtual_circuits = (
+            VirtualCircuit(
+                provider_network=provider_networks[0],
+                provider_account=provider_accounts[0],
+                cid='Virtual Circuit 1'
+            ),
+            VirtualCircuit(
+                provider_network=provider_networks[1],
+                provider_account=provider_accounts[1],
+                cid='Virtual Circuit 2'
+            ),
+            VirtualCircuit(
+                provider_network=provider_networks[2],
+                provider_account=provider_accounts[2],
+                cid='Virtual Circuit 3'
+            ),
+        )
+        VirtualCircuit.objects.bulk_create(virtual_circuits)
+
+        virtual_circuit_terminations = (
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[0],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_HUB,
+                interface=virtual_interfaces[0],
+                description='termination1'
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[0],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_SPOKE,
+                interface=virtual_interfaces[3],
+                description='termination2'
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[1],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[1],
+                description='termination3'
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[1],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[4],
+                description='termination4'
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[2],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[2],
+                description='termination5'
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[2],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[5],
+                description='termination6'
+            ),
+        )
+        VirtualCircuitTermination.objects.bulk_create(virtual_circuit_terminations)
+
+    def test_q(self):
+        params = {'q': 'termination1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_description(self):
+        params = {'description': ['termination1', 'termination2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_virtual_circuit_id(self):
+        virtual_circuits = VirtualCircuit.objects.filter()[:2]
+        params = {'virtual_circuit_id': [virtual_circuits[0].pk, virtual_circuits[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_provider(self):
+        providers = Provider.objects.all()[:2]
+        params = {'provider_id': [providers[0].pk, providers[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'provider': [providers[0].slug, providers[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_provider_network(self):
+        provider_networks = ProviderNetwork.objects.all()[:2]
+        params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_provider_account(self):
+        provider_accounts = ProviderAccount.objects.all()[:2]
+        params = {'provider_account_id': [provider_accounts[0].pk, provider_accounts[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'provider_account': [provider_accounts[0].account, provider_accounts[1].account]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_interface(self):
+        interfaces = Interface.objects.all()[:2]
+        params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 319 - 2
netbox/circuits/tests/test_views.py

@@ -7,7 +7,8 @@ from django.urls import reverse
 from circuits.choices import *
 from circuits.choices import *
 from circuits.models import *
 from circuits.models import *
 from core.models import ObjectType
 from core.models import ObjectType
-from dcim.models import Cable, Interface, Site
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Cable, Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
 from ipam.models import ASN, RIR
 from ipam.models import ASN, RIR
 from netbox.choices import ImportFormatChoices
 from netbox.choices import ImportFormatChoices
 from users.models import ObjectPermission
 from users.models import ObjectPermission
@@ -341,7 +342,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
 
 
-class TestCase(ViewTestCases.PrimaryObjectViewTestCase):
+class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = CircuitTermination
     model = CircuitTermination
 
 
     @classmethod
     @classmethod
@@ -518,3 +519,319 @@ class CircuitGroupAssignmentTestCase(
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
             'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
         }
         }
+
+
+class VirtualCircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = VirtualCircuit
+
+    def setUp(self):
+        super().setUp()
+
+        self.add_permissions(
+            'circuits.add_virtualcircuittermination',
+        )
+
+    @classmethod
+    def setUpTestData(cls):
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        provider_networks = (
+            ProviderNetwork(provider=provider, name='Provider Network 1'),
+            ProviderNetwork(provider=provider, name='Provider Network 2'),
+        )
+        ProviderNetwork.objects.bulk_create(provider_networks)
+        provider_accounts = (
+            ProviderAccount(provider=provider, account='Provider Account 1'),
+            ProviderAccount(provider=provider, account='Provider Account 2'),
+        )
+        ProviderAccount.objects.bulk_create(provider_accounts)
+
+        virtual_circuits = (
+            VirtualCircuit(
+                provider_network=provider_networks[0],
+                provider_account=provider_accounts[0],
+                cid='Virtual Circuit 1'
+            ),
+            VirtualCircuit(
+                provider_network=provider_networks[0],
+                provider_account=provider_accounts[0],
+                cid='Virtual Circuit 2'
+            ),
+            VirtualCircuit(
+                provider_network=provider_networks[0],
+                provider_account=provider_accounts[0],
+                cid='Virtual Circuit 3'
+            ),
+        )
+        VirtualCircuit.objects.bulk_create(virtual_circuits)
+
+        device = create_test_device('Device 1')
+        interfaces = (
+            Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+        )
+        Interface.objects.bulk_create(interfaces)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'cid': 'Virtual Circuit X',
+            'provider_network': provider_networks[1].pk,
+            'provider_account': provider_accounts[1].pk,
+            'status': CircuitStatusChoices.STATUS_PLANNED,
+            'description': 'A new virtual circuit',
+            'comments': 'Some comments',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "cid,provider_network,provider_account,status",
+            f"Virtual Circuit 4,Provider Network 1,Provider Account 1,{CircuitStatusChoices.STATUS_PLANNED}",
+            f"Virtual Circuit 5,Provider Network 1,Provider Account 1,{CircuitStatusChoices.STATUS_PLANNED}",
+            f"Virtual Circuit 6,Provider Network 1,Provider Account 1,{CircuitStatusChoices.STATUS_PLANNED}",
+        )
+
+        cls.csv_update_data = (
+            "id,cid,description,status",
+            f"{virtual_circuits[0].pk},Virtual Circuit A,New description,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
+            f"{virtual_circuits[1].pk},Virtual Circuit B,New description,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
+            f"{virtual_circuits[2].pk},Virtual Circuit C,New description,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
+        )
+
+        cls.bulk_edit_data = {
+            'provider_network': provider_networks[1].pk,
+            'provider_account': provider_accounts[1].pk,
+            'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
+            'description': 'New description',
+            'comments': 'New comments',
+        }
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_import_objects_with_terminations(self):
+        interfaces = Interface.objects.filter(type=InterfaceTypeChoices.TYPE_VIRTUAL)
+        json_data = f"""
+            [
+              {{
+                "cid": "Virtual Circuit 7",
+                "provider_network": "Provider Network 1",
+                "status": "active",
+                "terminations": [
+                  {{
+                    "role": "hub",
+                    "interface": {interfaces[0].pk}
+                  }},
+                  {{
+                    "role": "spoke",
+                    "interface": {interfaces[1].pk}
+                  }},
+                  {{
+                    "role": "spoke",
+                    "interface": {interfaces[2].pk}
+                  }}
+                ]
+              }}
+            ]
+        """
+
+        initial_count = self._get_queryset().count()
+        data = {
+            'data': json_data,
+            'format': ImportFormatChoices.JSON,
+        }
+
+        # Assign model-level permission
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['add']
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+
+        # Try GET with model-level permission
+        self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
+
+        # Test POST with permission
+        self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
+        self.assertEqual(self._get_queryset().count(), initial_count + 1)
+
+
+class VirtualCircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = VirtualCircuitTermination
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
+        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        devices = (
+            Device(site=site, name='hub', device_type=device_type, role=device_role),
+            Device(site=site, name='spoke1', device_type=device_type, role=device_role),
+            Device(site=site, name='spoke2', device_type=device_type, role=device_role),
+            Device(site=site, name='spoke3', device_type=device_type, role=device_role),
+        )
+        Device.objects.bulk_create(devices)
+
+        physical_interfaces = (
+            Interface(device=devices[0], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[1], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[2], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[3], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+        )
+        Interface.objects.bulk_create(physical_interfaces)
+
+        virtual_interfaces = (
+            # Point-to-point VCs
+            Interface(
+                device=devices[0],
+                name='eth0.1',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[0],
+                name='eth0.2',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[0],
+                name='eth0.3',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[1],
+                name='eth0.1',
+                parent=physical_interfaces[1],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[2],
+                name='eth0.1',
+                parent=physical_interfaces[2],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[3],
+                name='eth0.1',
+                parent=physical_interfaces[3],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+
+            # Hub and spoke VCs
+            Interface(
+                device=devices[0],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[1],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[2],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[3],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+        )
+        Interface.objects.bulk_create(virtual_interfaces)
+
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
+        provider_account = ProviderAccount.objects.create(provider=provider, account='Provider Account 1')
+
+        virtual_circuits = (
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 1'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 2'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 3'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 4'
+            ),
+        )
+        VirtualCircuit.objects.bulk_create(virtual_circuits)
+
+        virtual_circuit_terminations = (
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[0],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[0]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[0],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[3]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[1],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[1]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[1],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[4]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[2],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[2]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[2],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[5]
+            ),
+        )
+        VirtualCircuitTermination.objects.bulk_create(virtual_circuit_terminations)
+
+        cls.form_data = {
+            'virtual_circuit': virtual_circuits[3].pk,
+            'role': VirtualCircuitTerminationRoleChoices.ROLE_HUB,
+            'interface': virtual_interfaces[6].pk
+        }
+
+        cls.csv_data = (
+            "virtual_circuit,role,interface,description",
+            f"Virtual Circuit 4,{VirtualCircuitTerminationRoleChoices.ROLE_HUB},{virtual_interfaces[6].pk},Hub",
+            f"Virtual Circuit 4,{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},{virtual_interfaces[7].pk},Spoke 1",
+            f"Virtual Circuit 4,{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},{virtual_interfaces[8].pk},Spoke 2",
+            f"Virtual Circuit 4,{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},{virtual_interfaces[9].pk},Spoke 3",
+        )
+
+        cls.csv_update_data = (
+            "id,role,description",
+            f"{virtual_circuit_terminations[0].pk},{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},New description",
+            f"{virtual_circuit_terminations[1].pk},{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},New description",
+            f"{virtual_circuit_terminations[2].pk},{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},New description",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }

+ 16 - 0
netbox/circuits/urls.py

@@ -70,4 +70,20 @@ urlpatterns = [
     path('circuit-group-assignments/edit/', views.CircuitGroupAssignmentBulkEditView.as_view(), name='circuitgroupassignment_bulk_edit'),
     path('circuit-group-assignments/edit/', views.CircuitGroupAssignmentBulkEditView.as_view(), name='circuitgroupassignment_bulk_edit'),
     path('circuit-group-assignments/delete/', views.CircuitGroupAssignmentBulkDeleteView.as_view(), name='circuitgroupassignment_bulk_delete'),
     path('circuit-group-assignments/delete/', views.CircuitGroupAssignmentBulkDeleteView.as_view(), name='circuitgroupassignment_bulk_delete'),
     path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))),
     path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))),
+
+    # Virtual circuits
+    path('virtual-circuits/', views.VirtualCircuitListView.as_view(), name='virtualcircuit_list'),
+    path('virtual-circuits/add/', views.VirtualCircuitEditView.as_view(), name='virtualcircuit_add'),
+    path('virtual-circuits/import/', views.VirtualCircuitBulkImportView.as_view(), name='virtualcircuit_import'),
+    path('virtual-circuits/edit/', views.VirtualCircuitBulkEditView.as_view(), name='virtualcircuit_bulk_edit'),
+    path('virtual-circuits/delete/', views.VirtualCircuitBulkDeleteView.as_view(), name='virtualcircuit_bulk_delete'),
+    path('virtual-circuits/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuit'))),
+
+    # Virtual circuit terminations
+    path('virtual-circuit-terminations/', views.VirtualCircuitTerminationListView.as_view(), name='virtualcircuittermination_list'),
+    path('virtual-circuit-terminations/add/', views.VirtualCircuitTerminationEditView.as_view(), name='virtualcircuittermination_add'),
+    path('virtual-circuit-terminations/import/', views.VirtualCircuitTerminationBulkImportView.as_view(), name='virtualcircuittermination_import'),
+    path('virtual-circuit-terminations/edit/', views.VirtualCircuitTerminationBulkEditView.as_view(), name='virtualcircuittermination_bulk_edit'),
+    path('virtual-circuit-terminations/delete/', views.VirtualCircuitTerminationBulkDeleteView.as_view(), name='virtualcircuittermination_bulk_delete'),
+    path('virtual-circuit-terminations/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuittermination'))),
 ]
 ]

+ 106 - 0
netbox/circuits/views.py

@@ -537,3 +537,109 @@ class CircuitGroupAssignmentBulkDeleteView(generic.BulkDeleteView):
     queryset = CircuitGroupAssignment.objects.all()
     queryset = CircuitGroupAssignment.objects.all()
     filterset = filtersets.CircuitGroupAssignmentFilterSet
     filterset = filtersets.CircuitGroupAssignmentFilterSet
     table = tables.CircuitGroupAssignmentTable
     table = tables.CircuitGroupAssignmentTable
+
+
+#
+# Virtual circuits
+#
+
+class VirtualCircuitListView(generic.ObjectListView):
+    queryset = VirtualCircuit.objects.annotate(
+        termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
+    )
+    filterset = filtersets.VirtualCircuitFilterSet
+    filterset_form = forms.VirtualCircuitFilterForm
+    table = tables.VirtualCircuitTable
+
+
+@register_model_view(VirtualCircuit)
+class VirtualCircuitView(generic.ObjectView):
+    queryset = VirtualCircuit.objects.all()
+
+
+@register_model_view(VirtualCircuit, 'edit')
+class VirtualCircuitEditView(generic.ObjectEditView):
+    queryset = VirtualCircuit.objects.all()
+    form = forms.VirtualCircuitForm
+
+
+@register_model_view(VirtualCircuit, 'delete')
+class VirtualCircuitDeleteView(generic.ObjectDeleteView):
+    queryset = VirtualCircuit.objects.all()
+
+
+class VirtualCircuitBulkImportView(generic.BulkImportView):
+    queryset = VirtualCircuit.objects.all()
+    model_form = forms.VirtualCircuitImportForm
+    additional_permissions = [
+        'circuits.add_virtualcircuittermination',
+    ]
+    related_object_forms = {
+        'terminations': forms.VirtualCircuitTerminationImportRelatedForm,
+    }
+
+    def prep_related_object_data(self, parent, data):
+        data.update({'virtual_circuit': parent})
+        return data
+
+
+class VirtualCircuitBulkEditView(generic.BulkEditView):
+    queryset = VirtualCircuit.objects.annotate(
+        termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
+    )
+    filterset = filtersets.VirtualCircuitFilterSet
+    table = tables.VirtualCircuitTable
+    form = forms.VirtualCircuitBulkEditForm
+
+
+class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
+    queryset = VirtualCircuit.objects.annotate(
+        termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
+    )
+    filterset = filtersets.VirtualCircuitFilterSet
+    table = tables.VirtualCircuitTable
+
+
+#
+# Virtual circuit terminations
+#
+
+class VirtualCircuitTerminationListView(generic.ObjectListView):
+    queryset = VirtualCircuitTermination.objects.all()
+    filterset = filtersets.VirtualCircuitTerminationFilterSet
+    filterset_form = forms.VirtualCircuitTerminationFilterForm
+    table = tables.VirtualCircuitTerminationTable
+
+
+@register_model_view(VirtualCircuitTermination)
+class VirtualCircuitTerminationView(generic.ObjectView):
+    queryset = VirtualCircuitTermination.objects.all()
+
+
+@register_model_view(VirtualCircuitTermination, 'edit')
+class VirtualCircuitTerminationEditView(generic.ObjectEditView):
+    queryset = VirtualCircuitTermination.objects.all()
+    form = forms.VirtualCircuitTerminationForm
+
+
+@register_model_view(VirtualCircuitTermination, 'delete')
+class VirtualCircuitTerminationDeleteView(generic.ObjectDeleteView):
+    queryset = VirtualCircuitTermination.objects.all()
+
+
+class VirtualCircuitTerminationBulkImportView(generic.BulkImportView):
+    queryset = VirtualCircuitTermination.objects.all()
+    model_form = forms.VirtualCircuitTerminationImportForm
+
+
+class VirtualCircuitTerminationBulkEditView(generic.BulkEditView):
+    queryset = VirtualCircuitTermination.objects.all()
+    filterset = filtersets.VirtualCircuitTerminationFilterSet
+    table = tables.VirtualCircuitTerminationTable
+    form = forms.VirtualCircuitTerminationBulkEditForm
+
+
+class VirtualCircuitTerminationBulkDeleteView(generic.BulkDeleteView):
+    queryset = VirtualCircuitTermination.objects.all()
+    filterset = filtersets.VirtualCircuitTerminationFilterSet
+    table = tables.VirtualCircuitTerminationTable

+ 11 - 1
netbox/dcim/filtersets.py

@@ -4,7 +4,7 @@ from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 
 
-from circuits.models import CircuitTermination
+from circuits.models import CircuitTermination, VirtualCircuit, VirtualCircuitTermination
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
 from ipam.filtersets import PrimaryIPFilterSet
 from ipam.filtersets import PrimaryIPFilterSet
@@ -1842,6 +1842,16 @@ class InterfaceFilterSet(
         queryset=WirelessLink.objects.all(),
         queryset=WirelessLink.objects.all(),
         label=_('Wireless link')
         label=_('Wireless link')
     )
     )
+    virtual_circuit_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='virtual_circuit_termination__virtual_circuit',
+        queryset=VirtualCircuit.objects.all(),
+        label=_('Virtual circuit (ID)'),
+    )
+    virtual_circuit_termination_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='virtual_circuit_termination',
+        queryset=VirtualCircuitTermination.objects.all(),
+        label=_('Virtual circuit termination (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface

+ 8 - 0
netbox/dcim/models/device_components.py

@@ -998,6 +998,14 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
     def l2vpn_termination(self):
     def l2vpn_termination(self):
         return self.l2vpn_terminations.first()
         return self.l2vpn_terminations.first()
 
 
+    @cached_property
+    def connected_endpoints(self):
+        # If this is a virtual interface, return the remote endpoint of the connected
+        # virtual circuit, if any.
+        if self.is_virtual and hasattr(self, 'virtual_circuit_termination'):
+            return self.virtual_circuit_termination.peer_terminations
+        return super().connected_endpoints
+
 
 
 #
 #
 # Pass-through ports
 # Pass-through ports

+ 8 - 0
netbox/dcim/tables/devices.py

@@ -649,6 +649,14 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
         url_name='dcim:interface_list'
         url_name='dcim:interface_list'
     )
     )
 
 
+    # Override PathEndpointTable.connection to accommodate virtual circuits
+    connection = columns.TemplateColumn(
+        accessor='_path__destinations',
+        template_code=INTERFACE_LINKTERMINATION,
+        verbose_name=_('Connection'),
+        orderable=False
+    )
+
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = models.Interface
         model = models.Interface
         fields = (
         fields = (

+ 14 - 0
netbox/dcim/tables/template_code.py

@@ -10,6 +10,20 @@ LINKTERMINATION = """
 {% endfor %}
 {% endfor %}
 """
 """
 
 
+INTERFACE_LINKTERMINATION = """
+{% load i18n %}
+{% if record.is_virtual and record.virtual_circuit_termination %}
+  {% for termination in record.connected_endpoints %}
+    <a href="{{ termination.interface.parent_object.get_absolute_url }}">{{ termination.interface.parent_object }}</a>
+    <i class="mdi mdi-chevron-right"></i>
+    <a href="{{ termination.interface.get_absolute_url }}">{{ termination.interface }}</a>
+    {% trans "via" %}
+    <a href="{{ termination.parent_object.get_absolute_url }}">{{ termination.parent_object }}</a>
+    {% if not forloop.last %}<br />{% endif %}
+  {% endfor %}
+{% else %}""" + LINKTERMINATION + """{% endif %}
+"""
+
 CABLE_LENGTH = """
 CABLE_LENGTH = """
 {% load helpers %}
 {% load helpers %}
 {% if record.length %}{{ record.length|floatformat:"-2" }} {{ record.length_unit }}{% endif %}
 {% if record.length %}{{ record.length|floatformat:"-2" }} {{ record.length_unit }}{% endif %}

+ 2 - 0
netbox/extras/tests/test_filtersets.py

@@ -1168,6 +1168,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         'tunnelgroup',
         'tunnelgroup',
         'tunneltermination',
         'tunneltermination',
         'virtualchassis',
         'virtualchassis',
+        'virtualcircuit',
+        'virtualcircuittermination',
         'virtualdevicecontext',
         'virtualdevicecontext',
         'virtualdisk',
         'virtualdisk',
         'virtualmachine',
         'virtualmachine',

+ 7 - 0
netbox/netbox/navigation/menu.py

@@ -284,6 +284,13 @@ CIRCUITS_MENU = Menu(
                 get_model_item('circuits', 'circuittermination', _('Circuit Terminations')),
                 get_model_item('circuits', 'circuittermination', _('Circuit Terminations')),
             ),
             ),
         ),
         ),
+        MenuGroup(
+            label=_('Virtual Circuits'),
+            items=(
+                get_model_item('circuits', 'virtualcircuit', _('Virtual Circuits')),
+                get_model_item('circuits', 'virtualcircuittermination', _('Virtual Circuit Terminations')),
+            ),
+        ),
         MenuGroup(
         MenuGroup(
             label=_('Providers'),
             label=_('Providers'),
             items=(
             items=(

+ 13 - 0
netbox/templates/circuits/providernetwork.html

@@ -50,6 +50,19 @@
         <h2 class="card-header">{% trans "Circuits" %}</h2>
         <h2 class="card-header">{% trans "Circuits" %}</h2>
         {% htmx_table 'circuits:circuit_list' provider_network_id=object.pk %}
         {% htmx_table 'circuits:circuit_list' provider_network_id=object.pk %}
       </div>
       </div>
+      <div class="card">
+        <h2 class="card-header">
+          {% trans "Virtual Circuits" %}
+          {% if perms.circuits.add_virtualcircuit %}
+            <div class="card-actions">
+              <a href="{% url 'circuits:virtualcircuit_add' %}?provider_network={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Virtual Circuit" %}
+              </a>
+            </div>
+          {% endif %}
+        </h2>
+        {% htmx_table 'circuits:virtualcircuit_list' provider_network_id=object.pk %}
+      </div>
       {% plugin_full_width_page object %}
       {% plugin_full_width_page object %}
     </div>
     </div>
   </div>
   </div>

+ 84 - 0
netbox/templates/circuits/virtualcircuit.html

@@ -0,0 +1,84 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'circuits:virtualcircuit_list' %}?provider_id={{ object.provider.pk }}">{{ object.provider }}</a>
+  </li>
+  <li class="breadcrumb-item">
+    <a href="{% url 'circuits:virtualcircuit_list' %}?provider_network_id={{ object.provider_network.pk }}">{{ object.provider_network }}</a>
+  </li>
+{% endblock %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h2 class="card-header">{% trans "Virtual circuit" %}</h2>
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Provider" %}</th>
+            <td>{{ object.provider|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Provider Network" %}</th>
+            <td>{{ object.provider_network|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Provider account" %}</th>
+            <td>{{ object.provider_account|linkify|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Circuit ID" %}</th>
+            <td>{{ object.cid }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Status" %}</th>
+            <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Tenant" %}</th>
+            <td>
+              {% if object.tenant.group %}
+                {{ object.tenant.group|linkify }} /
+              {% endif %}
+              {{ object.tenant|linkify|placeholder }}
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Description" %}</th>
+            <td>{{ object.description|placeholder }}</td>
+          </tr>
+        </table>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/comments.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      <div class="card">
+        <h2 class="card-header">
+          {% trans "Terminations" %}
+          {% if perms.circuits.add_virtualcircuittermination %}
+            <div class="card-actions">
+              <a href="{% url 'circuits:virtualcircuittermination_add' %}?virtual_circuit={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
+                <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Termination" %}
+              </a>
+            </div>
+          {% endif %}
+        </h2>
+        {% htmx_table 'circuits:virtualcircuittermination_list' virtual_circuit_id=object.pk %}
+      </div>
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 81 - 0
netbox/templates/circuits/virtualcircuittermination.html

@@ -0,0 +1,81 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'circuits:virtualcircuit_list' %}?provider_id={{ object.virtual_circuit.provider.pk }}">{{ object.virtual_circuit.provider }}</a>
+  </li>
+  <li class="breadcrumb-item">
+    <a href="{% url 'circuits:virtualcircuit_list' %}?provider_network_id={{ object.virtual_circuit.provider_network.pk }}">{{ object.virtual_circuit.provider_network }}</a>
+  </li>
+  <li class="breadcrumb-item">
+    <a href="{% url 'circuits:virtualcircuittermination_list' %}?virtual_circuit_id={{ object.virtual_circuit.pk }}">{{ object.virtual_circuit }}</a>
+  </li>
+{% endblock %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h2 class="card-header">{% trans "Virtual Circuit Termination" %}</h2>
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Provider" %}</th>
+            <td>{{ object.virtual_circuit.provider|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Provider Network" %}</th>
+            <td>{{ object.virtual_circuit.provider_network|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Provider account" %}</th>
+            <td>{{ object.virtual_circuit.provider_account|linkify|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Virtual circuit" %}</th>
+            <td>{{ object.virtual_circuit|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Role" %}</th>
+            <td>{% badge object.get_role_display bg_color=object.get_role_color %}</td>
+          </tr>
+        </table>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/custom_fields.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      <div class="card">
+        <h2 class="card-header">{% trans "Interface" %}</h2>
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Device" %}</th>
+            <td>{{ object.interface.device|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Interface" %}</th>
+            <td>{{ object.interface|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Type" %}</th>
+            <td>{{ object.interface.get_type_display }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Description" %}</th>
+            <td>{{ object.interface.description|placeholder }}</td>
+          </tr>
+        </table>
+      </div>
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 35 - 1
netbox/templates/dcim/interface.html

@@ -152,7 +152,41 @@
           </tr>
           </tr>
         </table>
         </table>
       </div>
       </div>
-      {% if not object.is_virtual %}
+      {% if object.is_virtual and object.virtual_circuit_termination %}
+        <div class="card">
+          <h2 class="card-header">{% trans "Virtual Circuit" %}</h2>
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Provider" %}</th>
+              <td>{{ object.virtual_circuit_termination.virtual_circuit.provider|linkify }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Provider Network" %}</th>
+              <td>{{ object.virtual_circuit_termination.virtual_circuit.provider_network|linkify }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Circuit ID" %}</th>
+              <td>{{ object.virtual_circuit_termination.virtual_circuit|linkify }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Role" %}</th>
+              <td>{{ object.virtual_circuit_termination.get_role_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Connections" %}</th>
+              <td>
+                {% for termination in object.virtual_circuit_termination.peer_terminations %}
+                  <a href="{{ termination.interface.parent_object.get_absolute_url }}">{{ termination.interface.parent_object }}</a>
+                  <i class="mdi mdi-chevron-right"></i>
+                  <a href="{{ termination.interface.get_absolute_url }}">{{ termination.interface }}</a>
+                  ({{ termination.get_role_display }})
+                  {% if not forloop.last %}<br />{% endif %}
+                {% endfor %}
+              </td>
+            </tr>
+          </table>
+        </div>
+      {% elif not object.is_virtual %}
         <div class="card">
         <div class="card">
           <h2 class="card-header">{% trans "Connection" %}</h2>
           <h2 class="card-header">{% trans "Connection" %}</h2>
           {% if object.mark_connected %}
           {% if object.mark_connected %}