Sfoglia il codice sorgente

Closes #18153: Introduce virtual circuit types (#18300)

* Closes #18153: Introduce virtual circuit types

* Fix TagTestCase

* Fix GraphQL API test
Jeremy Stretch 1 anno fa
parent
commit
83d62315cc

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

@@ -18,6 +18,10 @@ The [provider account](./provideraccount.md) with which the virtual circuit is a
 
 The unique identifier assigned to the virtual circuit by its [provider](./provider.md).
 
+### Type
+
+The assigned [virtual circuit type](./virtualcircuittype.md).
+
 ### Status
 
 The operational status of the virtual circuit. By default, the following statuses are available:

+ 13 - 0
docs/models/circuits/virtualcircuittype.md

@@ -0,0 +1,13 @@
+# Virtual Circuit Types
+
+Like physical [circuits](./circuit.md), [virtual circuits](./virtualcircuit.md) are classified by functional type. These types are completely customizable, and can help categorize circuits by function or technology.
+
+## Fields
+
+### Name
+
+A unique human-friendly name.
+
+### Slug
+
+A unique URL-friendly identifier. (This value can be used for filtering.)

+ 19 - 3
netbox/circuits/api/serializers_/circuits.py

@@ -6,7 +6,7 @@ from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices, Virtu
 from circuits.constants import CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS, CIRCUIT_TERMINATION_TERMINATION_TYPES
 from circuits.models import (
     Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType, VirtualCircuit,
-    VirtualCircuitTermination,
+    VirtualCircuitTermination, VirtualCircuitType,
 )
 from dcim.api.serializers_.device_components import InterfaceSerializer
 from dcim.api.serializers_.cables import CabledObjectSerializer
@@ -25,6 +25,7 @@ __all__ = (
     'CircuitTypeSerializer',
     'VirtualCircuitSerializer',
     'VirtualCircuitTerminationSerializer',
+    'VirtualCircuitTypeSerializer',
 )
 
 
@@ -175,17 +176,32 @@ class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
         return serializer(obj.member, nested=True, context=context).data
 
 
+class VirtualCircuitTypeSerializer(NetBoxModelSerializer):
+
+    # Related object counts
+    virtual_circuit_count = RelatedObjectCountField('virtual_circuits')
+
+    class Meta:
+        model = VirtualCircuitType
+        fields = [
+            'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
+            'created', 'last_updated', 'virtual_circuit_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'virtual_circuit_count')
+
+
 class VirtualCircuitSerializer(NetBoxModelSerializer):
     provider_network = ProviderNetworkSerializer(nested=True)
     provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
+    type = VirtualCircuitTypeSerializer(nested=True)
     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',
+            'id', 'url', 'display_url', 'display', 'cid', 'provider_network', 'provider_account', 'type', 'status',
+            'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'provider_network', 'cid', 'description')
 

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

@@ -19,6 +19,7 @@ router.register('circuit-group-assignments', views.CircuitGroupAssignmentViewSet
 
 # Virtual circuits
 router.register('virtual-circuits', views.VirtualCircuitViewSet)
+router.register('virtual-circuit-types', views.VirtualCircuitTypeViewSet)
 router.register('virtual-circuit-terminations', views.VirtualCircuitTerminationViewSet)
 
 app_name = 'circuits-api'

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

@@ -95,6 +95,16 @@ class ProviderNetworkViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.ProviderNetworkFilterSet
 
 
+#
+#  Virtual circuit types
+#
+
+class VirtualCircuitTypeViewSet(NetBoxModelViewSet):
+    queryset = VirtualCircuitType.objects.all()
+    serializer_class = serializers.VirtualCircuitTypeSerializer
+    filterset_class = filtersets.VirtualCircuitTypeFilterSet
+
+
 #
 # Virtual circuits
 #

+ 18 - 0
netbox/circuits/filtersets.py

@@ -25,6 +25,7 @@ __all__ = (
     'ProviderFilterSet',
     'VirtualCircuitFilterSet',
     'VirtualCircuitTerminationFilterSet',
+    'VirtualCircuitTypeFilterSet',
 )
 
 
@@ -462,6 +463,13 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
         )
 
 
+class VirtualCircuitTypeFilterSet(OrganizationalModelFilterSet):
+
+    class Meta:
+        model = VirtualCircuitType
+        fields = ('id', 'name', 'slug', 'color', 'description')
+
+
 class VirtualCircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
     provider_id = django_filters.ModelMultipleChoiceFilter(
         field_name='provider_network__provider',
@@ -489,6 +497,16 @@ class VirtualCircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         queryset=ProviderNetwork.objects.all(),
         label=_('Provider network (ID)'),
     )
+    type_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=VirtualCircuitType.objects.all(),
+        label=_('Virtual circuit type (ID)'),
+    )
+    type = django_filters.ModelMultipleChoiceFilter(
+        field_name='type__slug',
+        queryset=VirtualCircuitType.objects.all(),
+        to_field_name='slug',
+        label=_('Virtual circuit type (slug)'),
+    )
     status = django_filters.MultipleChoiceFilter(
         choices=CircuitStatusChoices,
         null_value=None

+ 24 - 0
netbox/circuits/forms/bulk_edit.py

@@ -32,6 +32,7 @@ __all__ = (
     'ProviderNetworkBulkEditForm',
     'VirtualCircuitBulkEditForm',
     'VirtualCircuitTerminationBulkEditForm',
+    'VirtualCircuitTypeBulkEditForm',
 )
 
 
@@ -297,6 +298,24 @@ class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
     nullable_fields = ('priority',)
 
 
+class VirtualCircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
+    color = ColorField(
+        label=_('Color'),
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+
+    model = VirtualCircuitType
+    fieldsets = (
+        FieldSet('color', 'description'),
+    )
+    nullable_fields = ('color', 'description')
+
+
 class VirtualCircuitBulkEditForm(NetBoxModelBulkEditForm):
     provider_network = DynamicModelChoiceField(
         label=_('Provider network'),
@@ -308,6 +327,11 @@ class VirtualCircuitBulkEditForm(NetBoxModelBulkEditForm):
         queryset=ProviderAccount.objects.all(),
         required=False
     )
+    type = DynamicModelChoiceField(
+        label=_('Type'),
+        queryset=VirtualCircuitType.objects.all(),
+        required=False
+    )
     status = forms.ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(CircuitStatusChoices),

+ 17 - 1
netbox/circuits/forms/bulk_import.py

@@ -24,6 +24,7 @@ __all__ = (
     'VirtualCircuitImportForm',
     'VirtualCircuitTerminationImportForm',
     'VirtualCircuitTerminationImportRelatedForm',
+    'VirtualCircuitTypeImportForm',
 )
 
 
@@ -194,6 +195,14 @@ class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
         fields = ('member_type', 'member_id', 'group', 'priority')
 
 
+class VirtualCircuitTypeImportForm(NetBoxModelImportForm):
+    slug = SlugField()
+
+    class Meta:
+        model = VirtualCircuitType
+        fields = ('name', 'slug', 'color', 'description', 'tags')
+
+
 class VirtualCircuitImportForm(NetBoxModelImportForm):
     provider_network = CSVModelChoiceField(
         label=_('Provider network'),
@@ -208,6 +217,12 @@ class VirtualCircuitImportForm(NetBoxModelImportForm):
         help_text=_('Assigned provider account (if any)'),
         required=False
     )
+    type = CSVModelChoiceField(
+        label=_('Type'),
+        queryset=VirtualCircuitType.objects.all(),
+        to_field_name='name',
+        help_text=_('Type of virtual circuit')
+    )
     status = CSVChoiceField(
         label=_('Status'),
         choices=CircuitStatusChoices,
@@ -224,7 +239,8 @@ class VirtualCircuitImportForm(NetBoxModelImportForm):
     class Meta:
         model = VirtualCircuit
         fields = [
-            'cid', 'provider_network', 'provider_account', 'status', 'tenant', 'description', 'comments', 'tags',
+            'cid', 'provider_network', 'provider_account', 'type', 'status', 'tenant', 'description', 'comments',
+            'tags',
         ]
 
 

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

@@ -27,6 +27,7 @@ __all__ = (
     'ProviderNetworkFilterForm',
     'VirtualCircuitFilterForm',
     'VirtualCircuitTerminationFilterForm',
+    'VirtualCircuitTypeFilterForm',
 )
 
 
@@ -302,12 +303,26 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
 
 
+class VirtualCircuitTypeFilterForm(NetBoxModelFilterSetForm):
+    model = VirtualCircuitType
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('color', name=_('Attributes')),
+    )
+    tag = TagFilterField(model)
+
+    color = ColorField(
+        label=_('Color'),
+        required=False
+    )
+
+
 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('type', 'status', name=_('Attributes')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
     )
     selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
@@ -332,6 +347,11 @@ class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBox
         },
         label=_('Provider network')
     )
+    type_id = DynamicModelMultipleChoiceField(
+        queryset=VirtualCircuitType.objects.all(),
+        required=False,
+        label=_('Type')
+    )
     status = forms.MultipleChoiceField(
         label=_('Status'),
         choices=CircuitStatusChoices,

+ 22 - 2
netbox/circuits/forms/model_forms.py

@@ -31,6 +31,7 @@ __all__ = (
     'ProviderNetworkForm',
     'VirtualCircuitForm',
     'VirtualCircuitTerminationForm',
+    'VirtualCircuitTypeForm',
 )
 
 
@@ -305,6 +306,20 @@ class CircuitGroupAssignmentForm(NetBoxModelForm):
         self.instance.member = self.cleaned_data.get('member')
 
 
+class VirtualCircuitTypeForm(NetBoxModelForm):
+    slug = SlugField()
+
+    fieldsets = (
+        FieldSet('name', 'slug', 'color', 'description', 'tags'),
+    )
+
+    class Meta:
+        model = VirtualCircuitType
+        fields = [
+            'name', 'slug', 'color', 'description', 'tags',
+        ]
+
+
 class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
     provider_network = DynamicModelChoiceField(
         label=_('Provider network'),
@@ -316,11 +331,16 @@ class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
         queryset=ProviderAccount.objects.all(),
         required=False
     )
+    type = DynamicModelChoiceField(
+        queryset=VirtualCircuitType.objects.all(),
+        quick_add=True
+    )
     comments = CommentField()
 
     fieldsets = (
         FieldSet(
-            'provider_network', 'provider_account', 'cid', 'status', 'description', 'tags', name=_('Virtual circuit'),
+            'provider_network', 'provider_account', 'cid', 'type', 'status', 'description', 'tags',
+            name=_('Virtual circuit'),
         ),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
@@ -328,7 +348,7 @@ class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
     class Meta:
         model = VirtualCircuit
         fields = [
-            'cid', 'provider_network', 'provider_account', 'status', 'description', 'tenant_group', 'tenant',
+            'cid', 'provider_network', 'provider_account', 'type', 'status', 'description', 'tenant_group', 'tenant',
             'comments', 'tags',
         ]
 

+ 7 - 0
netbox/circuits/graphql/filters.py

@@ -14,6 +14,7 @@ __all__ = (
     'ProviderNetworkFilter',
     'VirtualCircuitFilter',
     'VirtualCircuitTerminationFilter',
+    'VirtualCircuitTypeFilter',
 )
 
 
@@ -65,6 +66,12 @@ class ProviderNetworkFilter(BaseFilterMixin):
     pass
 
 
+@strawberry_django.filter(models.VirtualCircuitType, lookups=True)
+@autotype_decorator(filtersets.VirtualCircuitTypeFilterSet)
+class VirtualCircuitTypeFilter(BaseFilterMixin):
+    pass
+
+
 @strawberry_django.filter(models.VirtualCircuit, lookups=True)
 @autotype_decorator(filtersets.VirtualCircuitFilterSet)
 class VirtualCircuitFilter(BaseFilterMixin):

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

@@ -37,3 +37,6 @@ class CircuitsQuery:
 
     virtual_circuit_termination: VirtualCircuitTerminationType = strawberry_django.field()
     virtual_circuit_termination_list: List[VirtualCircuitTerminationType] = strawberry_django.field()
+
+    virtual_circuit_type: VirtualCircuitTypeType = strawberry_django.field()
+    virtual_circuit_type_list: List[VirtualCircuitTypeType] = strawberry_django.field()

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

@@ -21,6 +21,7 @@ __all__ = (
     'ProviderNetworkType',
     'VirtualCircuitTerminationType',
     'VirtualCircuitType',
+    'VirtualCircuitTypeType',
 )
 
 
@@ -130,6 +131,17 @@ class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
         return self.member
 
 
+@strawberry_django.type(
+    models.VirtualCircuitType,
+    fields='__all__',
+    filters=VirtualCircuitTypeFilter
+)
+class VirtualCircuitTypeType(OrganizationalObjectType):
+    color: str
+
+    virtual_circuits: List[Annotated["VirtualCircuitType", strawberry.lazy('circuits.graphql.types')]]
+
+
 @strawberry_django.type(
     models.VirtualCircuitTermination,
     fields='__all__',
@@ -154,6 +166,9 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
 class VirtualCircuitType(NetBoxObjectType):
     provider_network: ProviderNetworkType = strawberry_django.field(select_related=["provider_network"])
     provider_account: ProviderAccountType | None
+    type: Annotated["VirtualCircuitTypeType", strawberry.lazy('circuits.graphql.types')] = strawberry_django.field(
+        select_related=["type"]
+    )
     tenant: TenantType | None
 
     terminations: List[VirtualCircuitTerminationType]

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

@@ -2,6 +2,7 @@ import django.db.models.deletion
 import taggit.managers
 from django.db import migrations, models
 
+import utilities.fields
 import utilities.json
 
 
@@ -14,6 +15,29 @@ class Migration(migrations.Migration):
     ]
 
     operations = [
+        migrations.CreateModel(
+            name='VirtualCircuitType',
+            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
+                )),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('slug', models.SlugField(max_length=100, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('color', utilities.fields.ColorField(blank=True, max_length=6)),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'virtual circuit type',
+                'verbose_name_plural': 'virtual circuit types',
+                'ordering': ('name',),
+            },
+        ),
         migrations.CreateModel(
             name='VirtualCircuit',
             fields=[
@@ -47,6 +71,14 @@ class Migration(migrations.Migration):
                     ),
                 ),
                 ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+                (
+                    'type',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name='virtual_circuits',
+                        to='circuits.virtualcircuittype'
+                    )
+                ),
                 (
                     'tenant',
                     models.ForeignKey(

+ 23 - 0
netbox/circuits/models/base.py

@@ -0,0 +1,23 @@
+from django.utils.translation import gettext_lazy as _
+
+from netbox.models import OrganizationalModel
+from utilities.fields import ColorField
+
+__all__ = (
+    'BaseCircuitType',
+)
+
+
+class BaseCircuitType(OrganizationalModel):
+    """
+    Abstract base model to represent a type of physical or virtual circuit.
+    Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
+    "Long Haul," "Metro," or "Out-of-Band".
+    """
+    color = ColorField(
+        verbose_name=_('color'),
+        blank=True
+    )
+
+    class Meta:
+        abstract = True

+ 3 - 8
netbox/circuits/models/circuits.py

@@ -13,7 +13,7 @@ from netbox.models.mixins import DistanceMixin
 from netbox.models.features import (
     ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, ImageAttachmentsMixin, TagsMixin,
 )
-from utilities.fields import ColorField
+from .base import BaseCircuitType
 
 __all__ = (
     'Circuit',
@@ -24,16 +24,11 @@ __all__ = (
 )
 
 
-class CircuitType(OrganizationalModel):
+class CircuitType(BaseCircuitType):
     """
     Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
     "Long Haul," "Metro," or "Out-of-Band".
     """
-    color = ColorField(
-        verbose_name=_('color'),
-        blank=True
-    )
-
     class Meta:
         ordering = ('name',)
         verbose_name = _('circuit type')
@@ -64,7 +59,7 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel)
         null=True
     )
     type = models.ForeignKey(
-        to='CircuitType',
+        to='circuits.CircuitType',
         on_delete=models.PROTECT,
         related_name='circuits'
     )

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

@@ -9,13 +9,26 @@ 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
+from .base import BaseCircuitType
 
 __all__ = (
     'VirtualCircuit',
     'VirtualCircuitTermination',
+    'VirtualCircuitType',
 )
 
 
+class VirtualCircuitType(BaseCircuitType):
+    """
+    Like physical circuits, virtual circuits can be organized by their functional role. For example, a user might wish
+    to categorize virtual circuits by their technological nature or by product name.
+    """
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('virtual circuit type')
+        verbose_name_plural = _('virtual circuit types')
+
+
 class VirtualCircuit(PrimaryModel):
     """
     A virtual connection between two or more endpoints, delivered across one or more physical circuits.
@@ -37,6 +50,11 @@ class VirtualCircuit(PrimaryModel):
         blank=True,
         null=True
     )
+    type = models.ForeignKey(
+        to='circuits.VirtualCircuitType',
+        on_delete=models.PROTECT,
+        related_name='virtual_circuits'
+    )
     status = models.CharField(
         verbose_name=_('status'),
         max_length=50,
@@ -63,6 +81,7 @@ class VirtualCircuit(PrimaryModel):
     )
     prerequisite_models = (
         'circuits.ProviderNetwork',
+        'circuits.VirtualCircuitType',
     )
 
     class Meta:

+ 11 - 0
netbox/circuits/search.py

@@ -100,3 +100,14 @@ class VirtualCircuitTerminationIndex(SearchIndex):
         ('description', 500),
     )
     display_attrs = ('virtual_circuit', 'role', 'description')
+
+
+@register_search
+class VirtualCircuitTypeIndex(SearchIndex):
+    model = models.VirtualCircuitType
+    fields = (
+        ('name', 100),
+        ('slug', 110),
+        ('description', 500),
+    )
+    display_attrs = ('description',)

+ 5 - 1
netbox/circuits/tables/circuits.py

@@ -45,7 +45,7 @@ class CircuitTypeTable(NetBoxTable):
             'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'tags', 'created', 'last_updated',
             'actions',
         )
-        default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
+        default_columns = ('pk', 'name', 'circuit_count', 'color', 'description')
 
 
 class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
@@ -61,6 +61,10 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         linkify=True,
         verbose_name=_('Account')
     )
+    type = tables.Column(
+        verbose_name=_('Type'),
+        linkify=True
+    )
     status = columns.ChoiceFieldColumn()
     termination_a = columns.TemplateColumn(
         template_code=CIRCUITTERMINATION_LINK,

+ 33 - 4
netbox/circuits/tables/virtual_circuits.py

@@ -8,9 +8,34 @@ from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
 __all__ = (
     'VirtualCircuitTable',
     'VirtualCircuitTerminationTable',
+    'VirtualCircuitTypeTable',
 )
 
 
+class VirtualCircuitTypeTable(NetBoxTable):
+    name = tables.Column(
+        linkify=True,
+        verbose_name=_('Name'),
+    )
+    color = columns.ColorColumn()
+    tags = columns.TagColumn(
+        url_name='circuits:virtualcircuittype_list'
+    )
+    virtual_circuit_count = columns.LinkedCountColumn(
+        viewname='circuits:virtualcircuit_list',
+        url_params={'type_id': 'pk'},
+        verbose_name=_('Circuits')
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = VirtualCircuitType
+        fields = (
+            'pk', 'id', 'name', 'virtual_circuit_count', 'color', 'description', 'slug', 'tags', 'created',
+            'last_updated', 'actions',
+        )
+        default_columns = ('pk', 'name', 'virtual_circuit_count', 'color', 'description')
+
+
 class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     cid = tables.Column(
         linkify=True,
@@ -29,6 +54,10 @@ class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
         linkify=True,
         verbose_name=_('Account')
     )
+    type = tables.Column(
+        verbose_name=_('Type'),
+        linkify=True
+    )
     status = columns.ChoiceFieldColumn()
     termination_count = columns.LinkedCountColumn(
         viewname='circuits:virtualcircuittermination_list',
@@ -45,12 +74,12 @@ class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
     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',
+            'pk', 'id', 'cid', 'provider', 'provider_account', 'provider_network', 'type', 'status', 'tenant',
+            'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
         )
         default_columns = (
-            'pk', 'cid', 'provider', 'provider_account', 'provider_network', 'status', 'tenant', 'termination_count',
-            'description',
+            'pk', 'cid', 'provider', 'provider_account', 'provider_network', 'type', 'status', 'tenant',
+            'termination_count', 'description',
         )
 
 

+ 54 - 4
netbox/circuits/tests/test_api.py

@@ -409,6 +409,38 @@ class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
         }
 
 
+class VirtualCircuitTypeTest(APIViewTestCases.APIViewTestCase):
+    model = VirtualCircuitType
+    brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url', 'virtual_circuit_count']
+    create_data = (
+        {
+            'name': 'Virtual Circuit Type 4',
+            'slug': 'virtual-circuit-type-4',
+        },
+        {
+            'name': 'Virtual Circuit Type 5',
+            'slug': 'virtual-circuit-type-5',
+        },
+        {
+            'name': 'Virtual Circuit Type 6',
+            'slug': 'virtual-circuit-type-6',
+        },
+    )
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        virtual_circuit_types = (
+            VirtualCircuitType(name='Virtual Circuit Type 1', slug='virtual-circuit-type-1'),
+            VirtualCircuitType(name='Virtual Circuit Type 2', slug='virtual-circuit-type-2'),
+            VirtualCircuitType(name='Virtual Circuit Type 3', slug='virtual-circuit-type-3'),
+        )
+        VirtualCircuitType.objects.bulk_create(virtual_circuit_types)
+
+
 class VirtualCircuitTest(APIViewTestCases.APIViewTestCase):
     model = VirtualCircuit
     brief_fields = ['cid', 'description', 'display', 'id', 'provider_network', 'url']
@@ -421,21 +453,28 @@ class VirtualCircuitTest(APIViewTestCases.APIViewTestCase):
         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_circuit_type = VirtualCircuitType.objects.create(
+            name='Virtual Circuit Type 1',
+            slug='virtual-circuit-type-1'
+        )
 
         virtual_circuits = (
             VirtualCircuit(
                 provider_network=provider_network,
                 provider_account=provider_account,
+                type=virtual_circuit_type,
                 cid='Virtual Circuit 1'
             ),
             VirtualCircuit(
                 provider_network=provider_network,
                 provider_account=provider_account,
+                type=virtual_circuit_type,
                 cid='Virtual Circuit 2'
             ),
             VirtualCircuit(
                 provider_network=provider_network,
                 provider_account=provider_account,
+                type=virtual_circuit_type,
                 cid='Virtual Circuit 3'
             ),
         )
@@ -446,18 +485,21 @@ class VirtualCircuitTest(APIViewTestCases.APIViewTestCase):
                 'cid': 'Virtual Circuit 4',
                 'provider_network': provider_network.pk,
                 'provider_account': provider_account.pk,
+                'type': virtual_circuit_type.pk,
                 'status': CircuitStatusChoices.STATUS_PLANNED,
             },
             {
                 'cid': 'Virtual Circuit 5',
                 'provider_network': provider_network.pk,
                 'provider_account': provider_account.pk,
+                'type': virtual_circuit_type.pk,
                 'status': CircuitStatusChoices.STATUS_PLANNED,
             },
             {
                 'cid': 'Virtual Circuit 6',
                 'provider_network': provider_network.pk,
                 'provider_account': provider_account.pk,
+                'type': virtual_circuit_type.pk,
                 'status': CircuitStatusChoices.STATUS_PLANNED,
             },
         ]
@@ -563,27 +605,35 @@ class VirtualCircuitTerminationTest(APIViewTestCases.APIViewTestCase):
         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_circuit_type = VirtualCircuitType.objects.create(
+            name='Virtual Circuit Type 1',
+            slug='virtual-circuit-type-1'
+        )
 
         virtual_circuits = (
             VirtualCircuit(
                 provider_network=provider_network,
                 provider_account=provider_account,
-                cid='Virtual Circuit 1'
+                cid='Virtual Circuit 1',
+                type=virtual_circuit_type
             ),
             VirtualCircuit(
                 provider_network=provider_network,
                 provider_account=provider_account,
-                cid='Virtual Circuit 2'
+                cid='Virtual Circuit 2',
+                type=virtual_circuit_type
             ),
             VirtualCircuit(
                 provider_network=provider_network,
                 provider_account=provider_account,
-                cid='Virtual Circuit 3'
+                cid='Virtual Circuit 3',
+                type=virtual_circuit_type
             ),
             VirtualCircuit(
                 provider_network=provider_network,
                 provider_account=provider_account,
-                cid='Virtual Circuit 4'
+                cid='Virtual Circuit 4',
+                type=virtual_circuit_type
             ),
         )
         VirtualCircuit.objects.bulk_create(virtual_circuits)

+ 71 - 10
netbox/circuits/tests/test_filtersets.py

@@ -656,12 +656,12 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
             Provider(name='Provider 2', slug='provider-2'),
             Provider(name='Provider 3', slug='provider-3'),
         ))
-        circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
+        circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
 
         circuits = (
-            Circuit(cid='Circuit 1', provider=providers[0], type=circuittype),
-            Circuit(cid='Circuit 2', provider=providers[1], type=circuittype),
-            Circuit(cid='Circuit 3', provider=providers[2], type=circuittype),
+            Circuit(cid='Circuit 1', provider=providers[0], type=circuit_type),
+            Circuit(cid='Circuit 2', provider=providers[1], type=circuit_type),
+            Circuit(cid='Circuit 3', provider=providers[2], type=circuit_type),
         )
         Circuit.objects.bulk_create(circuits)
 
@@ -672,18 +672,25 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         ProviderNetwork.objects.bulk_create(provider_networks)
 
+        virtual_circuit_type = VirtualCircuitType.objects.create(
+            name='Virtual Circuit Type 1',
+            slug='virtual-circuit-type-1'
+        )
         virtual_circuits = (
             VirtualCircuit(
                 provider_network=provider_networks[0],
-                cid='Virtual Circuit 1'
+                cid='Virtual Circuit 1',
+                type=virtual_circuit_type
             ),
             VirtualCircuit(
                 provider_network=provider_networks[1],
-                cid='Virtual Circuit 2'
+                cid='Virtual Circuit 2',
+                type=virtual_circuit_type
             ),
             VirtualCircuit(
                 provider_network=provider_networks[2],
-                cid='Virtual Circuit 3'
+                cid='Virtual Circuit 3',
+                type=virtual_circuit_type
             ),
         )
         VirtualCircuit.objects.bulk_create(virtual_circuits)
@@ -837,6 +844,36 @@ class ProviderAccountTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+class VirtualCircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = VirtualCircuitType.objects.all()
+    filterset = VirtualCircuitTypeFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        VirtualCircuitType.objects.bulk_create((
+            VirtualCircuitType(name='Virtual Circuit Type 1', slug='virtual-circuit-type-1', description='foobar1'),
+            VirtualCircuitType(name='Virtual Circuit Type 2', slug='virtual-circuit-type-2', description='foobar2'),
+            VirtualCircuitType(name='Virtual Circuit Type 3', slug='virtual-circuit-type-3'),
+        ))
+
+    def test_q(self):
+        params = {'q': 'foobar1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_name(self):
+        params = {'name': ['Virtual Circuit Type 1']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_slug(self):
+        params = {'slug': ['virtual-circuit-type-1']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VirtualCircuit.objects.all()
     filterset = VirtualCircuitFilterSet
@@ -880,12 +917,20 @@ class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         ProviderNetwork.objects.bulk_create(provider_networks)
 
+        virtual_circuit_types = (
+            VirtualCircuitType(name='Virtual Circuit Type 1', slug='virtual-circuit-type-1'),
+            VirtualCircuitType(name='Virtual Circuit Type 2', slug='virtual-circuit-type-2'),
+            VirtualCircuitType(name='Virtual Circuit Type 3', slug='virtual-circuit-type-3'),
+        )
+        VirtualCircuitType.objects.bulk_create(virtual_circuit_types)
+
         virutal_circuits = (
             VirtualCircuit(
                 provider_network=provider_networks[0],
                 provider_account=provider_accounts[0],
                 tenant=tenants[0],
                 cid='Virtual Circuit 1',
+                type=virtual_circuit_types[0],
                 status=CircuitStatusChoices.STATUS_PLANNED,
                 description='virtualcircuit1',
             ),
@@ -894,6 +939,7 @@ class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
                 provider_account=provider_accounts[1],
                 tenant=tenants[1],
                 cid='Virtual Circuit 2',
+                type=virtual_circuit_types[1],
                 status=CircuitStatusChoices.STATUS_ACTIVE,
                 description='virtualcircuit2',
             ),
@@ -902,6 +948,7 @@ class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
                 provider_account=provider_accounts[2],
                 tenant=tenants[2],
                 cid='Virtual Circuit 3',
+                type=virtual_circuit_types[2],
                 status=CircuitStatusChoices.STATUS_DEPROVISIONING,
                 description='virtualcircuit3',
             ),
@@ -933,6 +980,13 @@ class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_type(self):
+        virtual_circuit_types = VirtualCircuitType.objects.all()[:2]
+        params = {'type_id': [virtual_circuit_types[0].pk, virtual_circuit_types[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'type': [virtual_circuit_types[0].slug, virtual_circuit_types[1].slug]}
+        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)
@@ -1029,22 +1083,29 @@ class VirtualCircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
             ProviderAccount(provider=providers[2], account='Provider Account 3'),
         )
         ProviderAccount.objects.bulk_create(provider_accounts)
+        virtual_circuit_type = VirtualCircuitType.objects.create(
+            name='Virtual Circuit Type 1',
+            slug='virtual-circuit-type-1'
+        )
 
         virtual_circuits = (
             VirtualCircuit(
                 provider_network=provider_networks[0],
                 provider_account=provider_accounts[0],
-                cid='Virtual Circuit 1'
+                cid='Virtual Circuit 1',
+                type=virtual_circuit_type
             ),
             VirtualCircuit(
                 provider_network=provider_networks[1],
                 provider_account=provider_accounts[1],
-                cid='Virtual Circuit 2'
+                cid='Virtual Circuit 2',
+                type=virtual_circuit_type
             ),
             VirtualCircuit(
                 provider_network=provider_networks[2],
                 provider_account=provider_accounts[2],
-                cid='Virtual Circuit 3'
+                cid='Virtual Circuit 3',
+                type=virtual_circuit_type
             ),
         )
         VirtualCircuit.objects.bulk_create(virtual_circuits)

+ 93 - 15
netbox/circuits/tests/test_views.py

@@ -543,6 +543,47 @@ class CircuitGroupAssignmentTestCase(
         }
 
 
+class VirtualCircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = VirtualCircuitType
+
+    @classmethod
+    def setUpTestData(cls):
+
+        virtual_circuit_types = (
+            VirtualCircuitType(name='Virtual Circuit Type 1', slug='circuit-type-1'),
+            VirtualCircuitType(name='Virtual Circuit Type 2', slug='circuit-type-2'),
+            VirtualCircuitType(name='Virtual Circuit Type 3', slug='circuit-type-3'),
+        )
+        VirtualCircuitType.objects.bulk_create(virtual_circuit_types)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'Virtual Circuit Type X',
+            'slug': 'virtual-circuit-type-x',
+            'description': 'A new virtual circuit type',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,slug",
+            "Virtual Circuit Type 4,circuit-type-4",
+            "Virtual Circuit Type 5,circuit-type-5",
+            "Virtual Circuit Type 6,circuit-type-6",
+        )
+
+        cls.csv_update_data = (
+            "id,name,description",
+            f"{virtual_circuit_types[0].pk},Virtual Circuit Type 7,New description7",
+            f"{virtual_circuit_types[1].pk},Virtual Circuit Type 8,New description8",
+            f"{virtual_circuit_types[2].pk},Virtual Circuit Type 9,New description9",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'Foo',
+        }
+
+
 class VirtualCircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = VirtualCircuit
 
@@ -566,22 +607,30 @@ class VirtualCircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             ProviderAccount(provider=provider, account='Provider Account 2'),
         )
         ProviderAccount.objects.bulk_create(provider_accounts)
+        virtual_circuit_types = (
+            VirtualCircuitType(name='Virtual Circuit Type 1', slug='virtual-circuit-type-1'),
+            VirtualCircuitType(name='Virtual Circuit Type 2', slug='virtual-circuit-type-2'),
+        )
+        VirtualCircuitType.objects.bulk_create(virtual_circuit_types)
 
         virtual_circuits = (
             VirtualCircuit(
                 provider_network=provider_networks[0],
                 provider_account=provider_accounts[0],
-                cid='Virtual Circuit 1'
+                cid='Virtual Circuit 1',
+                type=virtual_circuit_types[0]
             ),
             VirtualCircuit(
                 provider_network=provider_networks[0],
                 provider_account=provider_accounts[0],
-                cid='Virtual Circuit 2'
+                cid='Virtual Circuit 2',
+                type=virtual_circuit_types[0]
             ),
             VirtualCircuit(
                 provider_network=provider_networks[0],
                 provider_account=provider_accounts[0],
-                cid='Virtual Circuit 3'
+                cid='Virtual Circuit 3',
+                type=virtual_circuit_types[0]
             ),
         )
         VirtualCircuit.objects.bulk_create(virtual_circuits)
@@ -600,6 +649,7 @@ class VirtualCircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'cid': 'Virtual Circuit X',
             'provider_network': provider_networks[1].pk,
             'provider_account': provider_accounts[1].pk,
+            'type': virtual_circuit_types[1].pk,
             'status': CircuitStatusChoices.STATUS_PLANNED,
             'description': 'A new virtual circuit',
             'comments': 'Some comments',
@@ -607,22 +657,41 @@ class VirtualCircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         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}",
+            "cid,provider_network,provider_account,type,status",
+            (
+                f"Virtual Circuit 4,Provider Network 1,Provider Account 1,{virtual_circuit_types[0].name},"
+                f"{CircuitStatusChoices.STATUS_PLANNED}"
+            ),
+            (
+                f"Virtual Circuit 5,Provider Network 1,Provider Account 1,{virtual_circuit_types[0].name},"
+                f"{CircuitStatusChoices.STATUS_PLANNED}"
+            ),
+            (
+                f"Virtual Circuit 6,Provider Network 1,Provider Account 1,{virtual_circuit_types[0].name},"
+                f"{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}",
+            "id,cid,description,type,status",
+            (
+                f"{virtual_circuits[0].pk},Virtual Circuit A,New description,{virtual_circuit_types[1].name},"
+                f"{CircuitStatusChoices.STATUS_DECOMMISSIONED}"
+            ),
+            (
+                f"{virtual_circuits[1].pk},Virtual Circuit B,New description,{virtual_circuit_types[1].name},"
+                f"{CircuitStatusChoices.STATUS_DECOMMISSIONED}"
+            ),
+            (
+                f"{virtual_circuits[2].pk},Virtual Circuit C,New description,{virtual_circuit_types[1].name},"
+                f"{CircuitStatusChoices.STATUS_DECOMMISSIONED}"
+            ),
         )
 
         cls.bulk_edit_data = {
             'provider_network': provider_networks[1].pk,
             'provider_account': provider_accounts[1].pk,
+            'type': virtual_circuit_types[1].pk,
             'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
             'description': 'New description',
             'comments': 'New comments',
@@ -636,6 +705,7 @@ class VirtualCircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
               {{
                 "cid": "Virtual Circuit 7",
                 "provider_network": "Provider Network 1",
+                "type": "Virtual Circuit Type 1",
                 "status": "active",
                 "terminations": [
                   {{
@@ -774,27 +844,35 @@ class VirtualCircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase)
         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_circuit_type = VirtualCircuitType.objects.create(
+            name='Virtual Circuit Type 1',
+            slug='virtual-circuit-type-1'
+        )
 
         virtual_circuits = (
             VirtualCircuit(
                 provider_network=provider_network,
                 provider_account=provider_account,
-                cid='Virtual Circuit 1'
+                cid='Virtual Circuit 1',
+                type=virtual_circuit_type
             ),
             VirtualCircuit(
                 provider_network=provider_network,
                 provider_account=provider_account,
-                cid='Virtual Circuit 2'
+                cid='Virtual Circuit 2',
+                type=virtual_circuit_type
             ),
             VirtualCircuit(
                 provider_network=provider_network,
                 provider_account=provider_account,
-                cid='Virtual Circuit 3'
+                cid='Virtual Circuit 3',
+                type=virtual_circuit_type
             ),
             VirtualCircuit(
                 provider_network=provider_network,
                 provider_account=provider_account,
-                cid='Virtual Circuit 4'
+                cid='Virtual Circuit 4',
+                type=virtual_circuit_type
             ),
         )
         VirtualCircuit.objects.bulk_create(virtual_circuits)

+ 3 - 0
netbox/circuits/urls.py

@@ -42,6 +42,9 @@ urlpatterns = [
     path('virtual-circuits/delete/', views.VirtualCircuitBulkDeleteView.as_view(), name='virtualcircuit_bulk_delete'),
     path('virtual-circuits/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuit'))),
 
+    path('virtual-circuit-types/', include(get_model_urls('circuits', 'virtualcircuittype', detail=False))),
+    path('virtual-circuit-types/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuittype'))),
+
     # Virtual circuit terminations
     path(
         'virtual-circuit-terminations/',

+ 61 - 0
netbox/circuits/views.py

@@ -579,6 +579,67 @@ class CircuitGroupAssignmentBulkDeleteView(generic.BulkDeleteView):
     table = tables.CircuitGroupAssignmentTable
 
 
+#
+# Virtual circuit Types
+#
+
+@register_model_view(VirtualCircuitType, 'list', path='', detail=False)
+class VirtualCircuitTypeListView(generic.ObjectListView):
+    queryset = VirtualCircuitType.objects.annotate(
+        virtual_circuit_count=count_related(VirtualCircuit, 'type')
+    )
+    filterset = filtersets.VirtualCircuitTypeFilterSet
+    filterset_form = forms.VirtualCircuitTypeFilterForm
+    table = tables.VirtualCircuitTypeTable
+
+
+@register_model_view(VirtualCircuitType)
+class VirtualCircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
+    queryset = VirtualCircuitType.objects.all()
+
+    def get_extra_context(self, request, instance):
+        return {
+            'related_models': self.get_related_models(request, instance),
+        }
+
+
+@register_model_view(VirtualCircuitType, 'add', detail=False)
+@register_model_view(VirtualCircuitType, 'edit')
+class VirtualCircuitTypeEditView(generic.ObjectEditView):
+    queryset = VirtualCircuitType.objects.all()
+    form = forms.VirtualCircuitTypeForm
+
+
+@register_model_view(VirtualCircuitType, 'delete')
+class VirtualCircuitTypeDeleteView(generic.ObjectDeleteView):
+    queryset = VirtualCircuitType.objects.all()
+
+
+@register_model_view(VirtualCircuitType, 'bulk_import', detail=False)
+class VirtualCircuitTypeBulkImportView(generic.BulkImportView):
+    queryset = VirtualCircuitType.objects.all()
+    model_form = forms.VirtualCircuitTypeImportForm
+
+
+@register_model_view(VirtualCircuitType, 'bulk_edit', path='edit', detail=False)
+class VirtualCircuitTypeBulkEditView(generic.BulkEditView):
+    queryset = VirtualCircuitType.objects.annotate(
+        circuit_count=count_related(Circuit, 'type')
+    )
+    filterset = filtersets.VirtualCircuitTypeFilterSet
+    table = tables.VirtualCircuitTypeTable
+    form = forms.VirtualCircuitTypeBulkEditForm
+
+
+@register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False)
+class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
+    queryset = VirtualCircuitType.objects.annotate(
+        circuit_count=count_related(Circuit, 'type')
+    )
+    filterset = filtersets.VirtualCircuitTypeFilterSet
+    table = tables.VirtualCircuitTypeTable
+
+
 #
 # Virtual circuits
 #

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

@@ -1170,6 +1170,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         'virtualchassis',
         'virtualcircuit',
         'virtualcircuittermination',
+        'virtualcircuittype',
         'virtualdevicecontext',
         'virtualdisk',
         'virtualmachine',

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

@@ -286,6 +286,7 @@ CIRCUITS_MENU = Menu(
             label=_('Virtual Circuits'),
             items=(
                 get_model_item('circuits', 'virtualcircuit', _('Virtual Circuits')),
+                get_model_item('circuits', 'virtualcircuittype', _('Virtual Circuit Types')),
                 get_model_item('circuits', 'virtualcircuittermination', _('Virtual Circuit Terminations')),
             ),
         ),

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

@@ -35,6 +35,10 @@
             <th scope="row">{% trans "Circuit ID" %}</th>
             <td>{{ object.cid }}</td>
           </tr>
+          <tr>
+            <th scope="row">{% trans "Type" %}</th>
+            <td>{{ object.type|linkify }}</td>
+          </tr>
           <tr>
             <th scope="row">{% trans "Status" %}</th>
             <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>

+ 55 - 0
netbox/templates/circuits/virtualcircuittype.html

@@ -0,0 +1,55 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block extra_controls %}
+  {% if perms.circuits.add_virtualcircuit %}
+    <a href="{% url 'circuits:virtualcircuit_add' %}?type={{ object.pk }}" class="btn btn-primary">
+      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Virtual Circuit" %}
+    </a>
+  {% endif %}
+{% endblock extra_controls %}
+
+{% block content %}
+<div class="row mb-3">
+	<div class="col col-md-6">
+    <div class="card">
+      <h2 class="card-header">{% trans "Virtual Circuit Type" %}</h2>
+      <table class="table table-hover attr-table">
+        <tr>
+          <th scope="row">{% trans "Name" %}</th>
+          <td>{{ object.name }}</td>
+        </tr>
+        <tr>
+          <th scope="row">{% trans "Description" %}</th>
+          <td>{{ object.description|placeholder }}</td>
+        </tr>
+        <tr>
+          <th scope="row">{% trans "Color" %}</th>
+          <td>
+            {% if object.color %}
+              <span class="badge color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
+            {% else %}
+              {{ ''|placeholder }}
+            {% endif %}
+          </td>
+        </tr>
+      </table>
+    </div>
+    {% include 'inc/panels/tags.html' %}
+    {% plugin_left_page object %}
+  </div>
+	<div class="col col-md-6">
+    {% include 'inc/panels/related_objects.html' %}
+    {% include 'inc/panels/custom_fields.html' %}
+    {% plugin_right_page object %}
+	</div>
+</div>
+<div class="row mb-3">
+	<div class="col col-md-12">
+    {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}