Explorar el Código

Closes: #7854 - Add VDC/Instances/etc (#10787)

* Work on #7854

* Move to new URL scheme.

* Fix PEP8 errors

* Fix PEP8 errors

* Add GraphQL and fix primary_ip missing

* Fix PEP8 on GQL Type

* Fix missing NestedSerializer.

* Fix missing NestedSerializer & rename VDC to VDCs

* Fix migration

* Change Validation for identifier

* Fix missing migration

* Rebase to feature

* Post-review changes

* Remove VDC Type
* Remove M2M Enforcement logic

* Interface related changes

* Add filter fields to filterset for Interface filter
* Add form field to filterset form for Interface filter
* Add VDC display to interface detail template

* Remove VirtualDeviceContextTypeChoices

* Accommodate recent changes in feature branch

* Add tests
Add missing search()

* Update tests, and fix model form

* Update test_api

* Update test_api.InterfaceTest create_data

* Fix issue with tests

* Update interface serializer

* Update serializer and tests

* Update status to be required

* Remove error message for constraint

* Remove extraneous import

* Re-ordered devices menu to place VDC below virtual chassis

* Add helptext for `identifier` field

* Fix breadcrumb link

* Remove add interface link

* Add missing tenant and status fields

* Changes to tests as per Jeremy

* Change for #9623

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>

* Update filterset form for status field

* Remove Rename View

* Change tabs to spaces

* Update netbox/dcim/tables/devices.py

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>

* Update netbox/dcim/tables/devices.py

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>

* Fix tenant in bulk_edit

* Apply suggestions from code review

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>

* Add status field to table.

* Re-order table fields.

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
Daniel Sheppard hace 3 años
padre
commit
b374351154

+ 10 - 0
netbox/dcim/api/nested_serializers.py

@@ -45,6 +45,7 @@ __all__ = [
     'NestedSiteSerializer',
     'NestedSiteGroupSerializer',
     'NestedVirtualChassisSerializer',
+    'NestedVirtualDeviceContextSerializer',
 ]
 
 
@@ -466,3 +467,12 @@ class NestedPowerFeedSerializer(WritableNestedSerializer):
     class Meta:
         model = models.PowerFeed
         fields = ['id', 'url', 'display', 'name', 'cable', '_occupied']
+
+
+class NestedVirtualDeviceContextSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
+    device = NestedDeviceSerializer()
+
+    class Meta:
+        model = models.VirtualDeviceContext
+        fields = ['id', 'url', 'display', 'name', 'identifier', 'device']

+ 29 - 7
netbox/dcim/api/serializers.py

@@ -671,6 +671,22 @@ class DeviceSerializer(NetBoxModelSerializer):
         return data
 
 
+class VirtualDeviceContextSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
+    device = NestedDeviceSerializer()
+    tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
+    primary_ip = NestedIPAddressSerializer(read_only=True)
+    primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
+    primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
+
+    class Meta:
+        model = VirtualDeviceContext
+        fields = [
+            'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4',
+            'primary_ip6', 'status', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+        ]
+
+
 class ModuleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
     device = NestedDeviceSerializer()
@@ -823,6 +839,12 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
 class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     device = NestedDeviceSerializer()
+    vdcs = SerializedPKRelatedField(
+        queryset=VirtualDeviceContext.objects.all(),
+        serializer=NestedVirtualDeviceContextSerializer,
+        required=False,
+        many=True
+    )
     module = ComponentNestedModuleSerializer(
         required=False,
         allow_null=True
@@ -859,13 +881,13 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
     class Meta:
         model = Interface
         fields = [
-            'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag',
-            'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
-            'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
-            'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type',
-            'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type',
-            'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
-            'count_fhrp_groups', '_occupied',
+            'id', 'url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge',
+            'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role',
+            'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
+            'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
+            'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
+            'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
+            'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
         ]
 
     def validate(self, data):

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

@@ -37,6 +37,7 @@ router.register('inventory-item-templates', views.InventoryItemTemplateViewSet)
 router.register('device-roles', views.DeviceRoleViewSet)
 router.register('platforms', views.PlatformViewSet)
 router.register('devices', views.DeviceViewSet)
+router.register('vdcs', views.VirtualDeviceContextViewSet)
 router.register('modules', views.ModuleViewSet)
 
 # Device components

+ 8 - 0
netbox/dcim/api/views.py

@@ -538,6 +538,14 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
         return Response(response)
 
 
+class VirtualDeviceContextViewSet(NetBoxModelViewSet):
+    queryset = VirtualDeviceContext.objects.prefetch_related(
+        'device__device_type', 'device', 'tenant', 'tags',
+    )
+    serializer_class = serializers.VirtualDeviceContextSerializer
+    filterset_class = filtersets.VirtualDeviceContextFilterSet
+
+
 class ModuleViewSet(NetBoxModelViewSet):
     queryset = Module.objects.prefetch_related(
         'device', 'module_bay', 'module_type__manufacturer', 'tags',

+ 17 - 0
netbox/dcim/choices.py

@@ -1399,3 +1399,20 @@ class PowerFeedPhaseChoices(ChoiceSet):
         (PHASE_SINGLE, 'Single phase'),
         (PHASE_3PHASE, 'Three-phase'),
     )
+
+
+#
+# VDC
+#
+class VirtualDeviceContextStatusChoices(ChoiceSet):
+    key = 'VirtualDeviceContext.status'
+
+    STATUS_PLANNED = 'planned'
+    STATUS_ACTIVE = 'active'
+    STATUS_OFFLINE = 'offline'
+
+    CHOICES = [
+        (STATUS_PLANNED, 'Planned', 'cyan'),
+        (STATUS_ACTIVE, 'Active', 'green'),
+        (STATUS_OFFLINE, 'Offline', 'red'),
+    ]

+ 57 - 1
netbox/dcim/filtersets.py

@@ -65,6 +65,7 @@ __all__ = (
     'SiteFilterSet',
     'SiteGroupFilterSet',
     'VirtualChassisFilterSet',
+    'VirtualDeviceContextFilterSet',
 )
 
 
@@ -482,7 +483,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
     class Meta:
         model = DeviceType
         fields = [
-            'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
+            'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
         ]
 
     def search(self, queryset, name, value):
@@ -1009,6 +1010,44 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
         return queryset.exclude(devicebays__isnull=value)
 
 
+class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+    device_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='device',
+        queryset=Device.objects.all(),
+        label='VDC (ID)',
+    )
+    device = django_filters.ModelMultipleChoiceFilter(
+        field_name='device',
+        queryset=Device.objects.all(),
+        label='Device model',
+    )
+    status = django_filters.MultipleChoiceFilter(
+        choices=VirtualDeviceContextStatusChoices
+    )
+    has_primary_ip = django_filters.BooleanFilter(
+        method='_has_primary_ip',
+        label='Has a primary IP',
+    )
+
+    class Meta:
+        model = VirtualDeviceContext
+        fields = ['id', 'device', 'name', ]
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(identifier=value.strip())
+        ).distinct()
+
+    def _has_primary_ip(self, queryset, name, value):
+        params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
+        if value:
+            return queryset.filter(params)
+        return queryset.exclude(params)
+
+
 class ModuleFilterSet(NetBoxModelFilterSet):
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         field_name='module_type__manufacturer',
@@ -1342,6 +1381,23 @@ class InterfaceFilterSet(
         to_field_name='rd',
         label='VRF (RD)',
     )
+    vdc_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='vdcs',
+        queryset=VirtualDeviceContext.objects.all(),
+        label='Virtual Device Context',
+    )
+    vdc_identifier = django_filters.ModelMultipleChoiceFilter(
+        field_name='vdcs__identifier',
+        queryset=VirtualDeviceContext.objects.all(),
+        to_field_name='identifier',
+        label='Virtual Device Context (Identifier)',
+    )
+    vdc = django_filters.ModelMultipleChoiceFilter(
+        field_name='vdcs__name',
+        queryset=VirtualDeviceContext.objects.all(),
+        to_field_name='name',
+        label='Virtual Device Context',
+    )
 
     class Meta:
         model = Interface

+ 22 - 0
netbox/dcim/forms/bulk_edit.py

@@ -54,6 +54,7 @@ __all__ = (
     'SiteBulkEditForm',
     'SiteGroupBulkEditForm',
     'VirtualChassisBulkEditForm',
+    'VirtualDeviceContextBulkEditForm'
 )
 
 
@@ -1398,3 +1399,24 @@ class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm):
         (None, ('color', 'description')),
     )
     nullable_fields = ('color', 'description')
+
+
+class VirtualDeviceContextBulkEditForm(NetBoxModelBulkEditForm):
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False
+    )
+    status = forms.ChoiceField(
+        required=False,
+        choices=add_blank_choice(VirtualDeviceContextStatusChoices),
+        widget=StaticSelect()
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    model = VirtualDeviceContext
+    fieldsets = (
+        (None, ('device', 'status', 'tenant')),
+    )
+    nullable_fields = ('device', 'tenant', )

+ 23 - 0
netbox/dcim/forms/bulk_import.py

@@ -43,6 +43,7 @@ __all__ = (
     'SiteCSVForm',
     'SiteGroupCSVForm',
     'VirtualChassisCSVForm',
+    'VirtualDeviceContextCSVForm'
 )
 
 
@@ -1084,3 +1085,25 @@ class PowerFeedCSVForm(NetBoxModelCSVForm):
                 f"location__{self.fields['location'].to_field_name}": data.get('location'),
             }
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
+
+
+class VirtualDeviceContextCSVForm(NetBoxModelCSVForm):
+
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Assigned role'
+    )
+    tenant = CSVModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Assigned tenant'
+    )
+
+    class Meta:
+        fields = [
+            'name', 'device', 'status', 'tenant', 'identifier', 'comments',
+        ]
+        model = VirtualDeviceContext
+        help_texts = {}

+ 42 - 1
netbox/dcim/forms/filtersets.py

@@ -50,6 +50,7 @@ __all__ = (
     'SiteFilterForm',
     'SiteGroupFilterForm',
     'VirtualChassisFilterForm',
+    'VirtualDeviceContextFilterForm'
 )
 
 
@@ -728,6 +729,37 @@ class DeviceFilterForm(
     tag = TagFilterField(model)
 
 
+class VirtualDeviceContextFilterForm(
+    TenancyFilterForm,
+    NetBoxModelFilterSetForm
+):
+    model = VirtualDeviceContext
+    fieldsets = (
+        (None, ('q', 'filter', 'tag')),
+        ('Hardware', ('device', 'status', )),
+        ('Tenant', ('tenant_group_id', 'tenant_id')),
+        ('Miscellaneous', ('has_primary_ip',))
+    )
+    device = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        label=_('Device'),
+        fetch_trigger='open'
+    )
+    status = MultipleChoiceField(
+        required=False,
+        choices=add_blank_choice(VirtualDeviceContextStatusChoices)
+    )
+    has_primary_ip = forms.NullBooleanField(
+        required=False,
+        label='Has a primary IP',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    tag = TagFilterField(model)
+
+
 class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Module
     fieldsets = (
@@ -1075,9 +1107,18 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         ('Addressing', ('vrf_id', 'mac_address', 'wwn')),
         ('PoE', ('poe_mode', 'poe_type')),
         ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
-        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+        ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id',
+                    'device_id', 'vdc_id')),
         ('Connection', ('cabled', 'connected', 'occupied')),
     )
+    vdc_id = DynamicModelMultipleChoiceField(
+        queryset=VirtualDeviceContext.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device_id',
+        },
+        label=_('Virtual Device Context')
+    )
     kind = MultipleChoiceField(
         choices=InterfaceKindChoices,
         required=False

+ 82 - 2
netbox/dcim/forms/model_forms.py

@@ -62,6 +62,7 @@ __all__ = (
     'SiteGroupForm',
     'VCMemberSelectForm',
     'VirtualChassisForm',
+    'VirtualDeviceContextForm'
 )
 
 INTERFACE_MODE_HELP_TEXT = """
@@ -1378,6 +1379,14 @@ class PowerOutletForm(ModularDeviceComponentForm):
 
 
 class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
+    vdcs = DynamicModelMultipleChoiceField(
+        queryset=VirtualDeviceContext.objects.all(),
+        required=False,
+        label='Virtual Device Contexts',
+        query_params={
+            'device_id': '$device',
+        }
+    )
     parent = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         required=False,
@@ -1452,7 +1461,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
     )
 
     fieldsets = (
-        ('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
+        ('Interface', ('device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
         ('Addressing', ('vrf', 'mac_address', 'wwn')),
         ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
         ('Related Interfaces', ('parent', 'bridge', 'lag')),
@@ -1466,7 +1475,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
     class Meta:
         model = Interface
         fields = [
-            'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
+            'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
             'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
             'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
@@ -1636,3 +1645,74 @@ class InventoryItemRoleForm(NetBoxModelForm):
         fields = [
             'name', 'slug', 'color', 'description', 'tags',
         ]
+
+
+class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
+    site_group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region',
+            'group_id': '$site_group',
+        }
+    )
+    location = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        },
+        initial_params={
+            'racks': '$rack'
+        }
+    )
+    rack = DynamicModelChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site',
+            'location_id': '$location',
+        }
+    )
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        query_params={
+            'site_id': '$site',
+            'location_id': '$location',
+            'rack_id': '$rack',
+        }
+    )
+
+    fieldsets = (
+        ('Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')),
+        ('Virtual Device Context', ('name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group',
+                                    'tenant')),
+        (None, ('tags', ))
+    )
+
+    class Meta:
+        model = VirtualDeviceContext
+        fields = [
+            'region', 'site_group', 'site', 'location', 'rack',
+            'device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant',
+            'comments', 'tags'
+        ]
+        help_texts = {}
+        widgets = {
+            'primary_ip4': StaticSelect(),
+            'primary_ip6': StaticSelect(),
+        }

+ 4 - 0
netbox/dcim/graphql/schema.py

@@ -2,6 +2,7 @@ import graphene
 
 from netbox.graphql.fields import ObjectField, ObjectListField
 from .types import *
+from .types import VirtualDeviceContextType
 
 
 class DCIMQuery(graphene.ObjectType):
@@ -121,3 +122,6 @@ class DCIMQuery(graphene.ObjectType):
 
     virtual_chassis = ObjectField(VirtualChassisType)
     virtual_chassis_list = ObjectListField(VirtualChassisType)
+
+    virtual_device_context = ObjectField(VirtualDeviceContextType)
+    virtual_device_context_list = ObjectListField(VirtualDeviceContextType)

+ 8 - 0
netbox/dcim/graphql/types.py

@@ -500,3 +500,11 @@ class VirtualChassisType(NetBoxObjectType):
         model = models.VirtualChassis
         fields = '__all__'
         filterset_class = filtersets.VirtualChassisFilterSet
+
+
+class VirtualDeviceContextType(NetBoxObjectType):
+
+    class Meta:
+        model = models.VirtualDeviceContext
+        fields = '__all__'
+        filterset_class = filtersets.VirtualDeviceContextFilterSet

+ 54 - 0
netbox/dcim/migrations/0166_virtualdevicecontext.py

@@ -0,0 +1,54 @@
+# Generated by Django 4.1.2 on 2022-11-10 16:56
+
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+import utilities.json
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0063_standardize_description_comments'),
+        ('extras', '0083_savedfilter'),
+        ('tenancy', '0009_standardize_description_comments'),
+        ('dcim', '0165_standardize_description_comments'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='VirtualDeviceContext',
+            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)),
+                ('name', models.CharField(max_length=64)),
+                ('status', models.CharField(max_length=50)),
+                ('identifier', models.PositiveSmallIntegerField(blank=True, null=True)),
+                ('comments', models.TextField(blank=True)),
+                ('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vdcs', to='dcim.device')),
+                ('primary_ip4', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')),
+                ('primary_ip6', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')),
+                ('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='vdcs', to='tenancy.tenant')),
+            ],
+            options={
+                'ordering': ['name'],
+            },
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='vdcs',
+            field=models.ManyToManyField(related_name='interfaces', to='dcim.virtualdevicecontext'),
+        ),
+        migrations.AddConstraint(
+            model_name='virtualdevicecontext',
+            constraint=models.UniqueConstraint(fields=('device', 'identifier'), name='dcim_virtualdevicecontext_device_identifiers'),
+        ),
+        migrations.AddConstraint(
+            model_name='virtualdevicecontext',
+            constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_virtualdevicecontext_name'),
+        ),
+    ]

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

@@ -531,6 +531,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         max_length=100,
         blank=True
     )
+    vdcs = models.ManyToManyField(
+        to='dcim.VirtualDeviceContext',
+        related_name='interfaces'
+    )
     lag = models.ForeignKey(
         to='self',
         on_delete=models.SET_NULL,

+ 80 - 1
netbox/dcim/models/devices.py

@@ -34,6 +34,7 @@ __all__ = (
     'ModuleType',
     'Platform',
     'VirtualChassis',
+    'VirtualDeviceContext',
 )
 
 
@@ -119,7 +120,7 @@ class DeviceType(PrimaryModel, WeightMixin):
     )
 
     clone_fields = (
-        'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
+        'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
     )
 
     class Meta:
@@ -1062,3 +1063,81 @@ class VirtualChassis(PrimaryModel):
             )
 
         return super().delete(*args, **kwargs)
+
+
+class VirtualDeviceContext(PrimaryModel):
+    device = models.ForeignKey(
+        to='Device',
+        on_delete=models.PROTECT,
+        related_name='vdcs',
+        blank=True,
+        null=True
+    )
+    name = models.CharField(
+        max_length=64
+    )
+    status = models.CharField(
+        max_length=50,
+        choices=VirtualDeviceContextStatusChoices,
+    )
+    identifier = models.PositiveSmallIntegerField(
+        help_text='Unique identifier provided by the platform being virtualized (Example: Nexus VDC Identifier)',
+        blank=True,
+        null=True,
+    )
+    primary_ip4 = models.OneToOneField(
+        to='ipam.IPAddress',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True,
+        verbose_name='Primary IPv4'
+    )
+    primary_ip6 = models.OneToOneField(
+        to='ipam.IPAddress',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True,
+        verbose_name='Primary IPv6'
+    )
+    tenant = models.ForeignKey(
+        to='tenancy.Tenant',
+        on_delete=models.PROTECT,
+        related_name='vdcs',
+        blank=True,
+        null=True
+    )
+    comments = models.TextField(
+        blank=True
+    )
+
+    class Meta:
+        ordering = ['name']
+        constraints = (
+            models.UniqueConstraint(
+                fields=('device', 'identifier',),
+                name='%(app_label)s_%(class)s_device_identifiers'
+            ),
+            models.UniqueConstraint(
+                fields=('device', 'name',),
+                name='%(app_label)s_%(class)s_name'
+            ),
+        )
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('dcim:virtualdevicecontext', kwargs={'pk': self.pk})
+
+    @property
+    def primary_ip(self):
+        if ConfigItem('PREFER_IPV4')() and self.primary_ip4:
+            return self.primary_ip4
+        elif self.primary_ip6:
+            return self.primary_ip6
+        elif self.primary_ip4:
+            return self.primary_ip4
+        else:
+            return None

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

@@ -36,6 +36,7 @@ __all__ = (
     'PowerPortTable',
     'RearPortTable',
     'VirtualChassisTable',
+    'VirtualDeviceContextTable'
 )
 
 
@@ -884,3 +885,43 @@ class VirtualChassisTable(NetBoxTable):
             'last_updated',
         )
         default_columns = ('pk', 'name', 'domain', 'master', 'member_count')
+
+
+class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
+    name = tables.Column(
+        linkify=True
+    )
+    device = tables.TemplateColumn(
+        order_by=('_name',),
+        template_code=DEVICE_LINK
+    )
+    status = columns.ChoiceFieldColumn()
+    primary_ip = tables.Column(
+        linkify=True,
+        order_by=('primary_ip4', 'primary_ip6'),
+        verbose_name='IP Address'
+    )
+    primary_ip4 = tables.Column(
+        linkify=True,
+        verbose_name='IPv4 Address'
+    )
+    primary_ip6 = tables.Column(
+        linkify=True,
+        verbose_name='IPv6 Address'
+    )
+
+    comments = columns.MarkdownColumn()
+
+    tags = columns.TagColumn(
+        url_name='dcim:vdc_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = models.VirtualDeviceContext
+        fields = (
+            'pk', 'id', 'name', 'status', 'identifier', 'tenant', 'tenant_group',
+            'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'identifier', 'status', 'tenant', 'primary_ip',
+        )

+ 63 - 0
netbox/dcim/tests/test_api.py

@@ -1485,6 +1485,12 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
         )
         Interface.objects.bulk_create(interfaces)
 
+        vdcs = (
+            VirtualDeviceContext(name='VDC 1', identifier=1, device=device),
+            VirtualDeviceContext(name='VDC 2', identifier=2, device=device)
+        )
+        VirtualDeviceContext.objects.bulk_create(vdcs)
+
         vlans = (
             VLAN(name='VLAN 1', vid=1),
             VLAN(name='VLAN 2', vid=2),
@@ -1533,6 +1539,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
             },
             {
                 'device': device.pk,
+                'vdcs': [vdcs[0].pk],
                 'name': 'Interface 6',
                 'type': 'virtual',
                 'mode': InterfaceModeChoices.MODE_TAGGED,
@@ -1543,6 +1550,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
             },
             {
                 'device': device.pk,
+                'vdcs': [vdcs[1].pk],
                 'name': 'Interface 7',
                 'type': InterfaceTypeChoices.TYPE_80211A,
                 'tx_power': 10,
@@ -1551,6 +1559,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
             },
             {
                 'device': device.pk,
+                'vdcs': [vdcs[1].pk],
                 'name': 'Interface 8',
                 'type': InterfaceTypeChoices.TYPE_80211A,
                 'tx_power': 10,
@@ -2163,3 +2172,57 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase):
                 'type': REDUNDANT,
             },
         ]
+
+
+class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
+    model = VirtualDeviceContext
+    brief_fields = ['device', 'display', 'id', 'identifier', 'name', 'url']
+    bulk_update_data = {
+        'status': 'planned',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        site = Site.objects.create(name='Test Site', slug='test-site')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type', slug='device-type')
+        devicerole = DeviceRole.objects.create(name='Device Role', slug='device-role', color='ff0000')
+
+        devices = (
+            Device(name='Device 1', device_type=devicetype, device_role=devicerole, site=site),
+            Device(name='Device 2', device_type=devicetype, device_role=devicerole, site=site),
+            Device(name='Device 3', device_type=devicetype, device_role=devicerole, site=site),
+        )
+        Device.objects.bulk_create(devices)
+
+        vdcs = (
+            VirtualDeviceContext(device=devices[1], name='VDC 1', identifier=1, status='active'),
+            VirtualDeviceContext(device=devices[1], name='VDC 2', identifier=2, status='active'),
+            VirtualDeviceContext(device=devices[2], name='VDC 1', identifier=1, status='active'),
+            VirtualDeviceContext(device=devices[2], name='VDC 2', identifier=2, status='active'),
+            VirtualDeviceContext(device=devices[2], name='VDC 3', identifier=3, status='active'),
+            VirtualDeviceContext(device=devices[2], name='VDC 4', identifier=4, status='active'),
+            VirtualDeviceContext(device=devices[2], name='VDC 5', identifier=5, status='active'),
+        )
+        VirtualDeviceContext.objects.bulk_create(vdcs)
+
+        cls.create_data = [
+            {
+                'device': devices[0].pk,
+                'status': 'active',
+                'name': 'VDC 1',
+                'identifier': 1,
+            },
+            {
+                'device': devices[0].pk,
+                'status': 'active',
+                'name': 'VDC 2',
+                'identifier': 2,
+            },
+            {
+                'device': devices[1].pk,
+                'status': 'active',
+                'name': 'VDC 3',
+                'identifier': 3,
+            },
+        ]

+ 108 - 1
netbox/dcim/tests/test_filtersets.py

@@ -2681,6 +2681,13 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         VRF.objects.bulk_create(vrfs)
 
+        # Virtual Device Context Creation
+        vdcs = (
+            VirtualDeviceContext(device=devices[3], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
+            VirtualDeviceContext(device=devices[3], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
+        )
+        VirtualDeviceContext.objects.bulk_create(vdcs)
+
         # VirtualChassis assignment for filtering
         virtual_chassis = VirtualChassis.objects.create(master=devices[0])
         Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
@@ -2793,6 +2800,12 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         Interface.objects.bulk_create(interfaces)
 
+        interfaces[3].vdcs.set([vdcs[0], vdcs[1]])
+        interfaces[4].vdcs.set([vdcs[0], vdcs[1]])
+        interfaces[5].vdcs.set([vdcs[0]])
+        interfaces[6].vdcs.set([vdcs[0]])
+        interfaces[7].vdcs.set([vdcs[1]])
+
         # Cables
         Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save()
         Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save()
@@ -2997,6 +3010,21 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_vdc(self):
+        params = {'vdc': ['VDC 1']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+        devices = Device.objects.last()
+        vdc = VirtualDeviceContext.objects.filter(device=devices, name='VDC 2')
+        params = {'vdc_id': vdc.values_list('pk', flat=True)}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_vdc_identifier(self):
+        devices = Device.objects.last()
+        vdc = VirtualDeviceContext.objects.filter(device=devices, name='VDC 2')
+        params = {'vdc_identifier': vdc.values_list('identifier', flat=True)}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
 
 class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = FrontPort.objects.all()
@@ -4254,4 +4282,83 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-# TODO: Connection filters
+class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = VirtualDeviceContext.objects.all()
+    filterset = VirtualDeviceContextFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
+        )
+        Site.objects.bulk_create(sites)
+
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
+        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+        devices = (
+            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
+            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
+            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
+        )
+        Device.objects.bulk_create(devices)
+
+        vdcs = (
+            VirtualDeviceContext(device=devices[0], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
+            VirtualDeviceContext(device=devices[0], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
+            VirtualDeviceContext(device=devices[1], name='VDC 1', status=VirtualDeviceContextStatusChoices.STATUS_OFFLINE),
+            VirtualDeviceContext(device=devices[1], name='VDC 2', status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
+            VirtualDeviceContext(device=devices[2], name='VDC 1', status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
+            VirtualDeviceContext(device=devices[2], name='VDC 2', status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
+        )
+        VirtualDeviceContext.objects.bulk_create(vdcs)
+
+        interfaces = (
+            Interface(device=devices[0], name='Interface 1', type='virtual'),
+            Interface(device=devices[0], name='Interface 2', type='virtual'),
+        )
+        Interface.objects.bulk_create(interfaces)
+
+        interfaces[0].vdcs.set([vdcs[0]])
+        interfaces[1].vdcs.set([vdcs[1]])
+
+        addresses = (
+            IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
+            IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
+        )
+        IPAddress.objects.bulk_create(addresses)
+
+        vdcs[0].primary_ip4 = addresses[0]
+        vdcs[0].save()
+        vdcs[1].primary_ip4 = addresses[1]
+        vdcs[1].save()
+
+    def test_device(self):
+        params = {'device': ['Device 1', 'Device 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+
+    def test_status(self):
+        params = {'status': ['active']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_device_id(self):
+        devices = Device.objects.filter(name__in=['Device 1', 'Device 2'])
+        params = {'device_id': [devices[0].pk, devices[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_has_primary_ip(self):
+        params = {'has_primary_ip': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'has_primary_ip': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)

+ 47 - 0
netbox/dcim/tests/test_models.py

@@ -588,3 +588,50 @@ class CableTestCase(TestCase):
         cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface])
         with self.assertRaises(ValidationError):
             cable.clean()
+
+
+class VirtualDeviceContextTestCase(TestCase):
+
+    def setUp(self):
+
+        site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        devicetype = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+        )
+        devicerole = DeviceRole.objects.create(
+            name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
+        )
+        self.device = Device.objects.create(
+            device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
+        )
+
+    def test_vdc_and_interface_creation(self):
+
+        vdc = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
+        vdc.full_clean()
+        vdc.save()
+
+        interface = Interface(device=self.device, name='Eth1/1', type='10gbase-t')
+        interface.full_clean()
+        interface.save()
+
+        interface.vdcs.set([vdc])
+
+    def test_vdc_duplicate_name(self):
+        vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
+        vdc1.full_clean()
+        vdc1.save()
+
+        vdc2 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=2, status='active')
+        with self.assertRaises(ValidationError):
+            vdc2.full_clean()
+
+    def test_vdc_duplicate_identifier(self):
+        vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
+        vdc1.full_clean()
+        vdc1.save()
+
+        vdc2 = VirtualDeviceContext(device=self.device, name="VDC 2", identifier=1, status='active')
+        with self.assertRaises(ValidationError):
+            vdc2.full_clean()

+ 45 - 0
netbox/dcim/tests/test_views.py

@@ -3076,3 +3076,48 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
         response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk}))
         self.assertHttpStatus(response, 200)
+
+
+class VirtualDeviceContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = VirtualDeviceContext
+
+    @classmethod
+    def setUpTestData(cls):
+        devices = [create_test_device(name='Device 1')]
+
+        vdcs = (
+            VirtualDeviceContext(name='VDC 1', identifier=1, device=devices[0], status='active'),
+            VirtualDeviceContext(name='VDC 2', identifier=2, device=devices[0], status='active'),
+            VirtualDeviceContext(name='VDC 3', identifier=3, device=devices[0], status='active'),
+        )
+        VirtualDeviceContext.objects.bulk_create(vdcs)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'device': devices[0].pk,
+            'status': 'active',
+            'name': 'VDC 4',
+            'identifier': 4,
+            'primary_ip4': None,
+            'primary_ip6': None,
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "device,status,name,identifier",
+            "Device 1,active,VDC 5,5",
+            "Device 1,active,VDC 6,6",
+            "Device 1,active,VDC 7,7",
+        )
+
+        cls.csv_update_data = (
+            "id,status",
+            f"{vdcs[0].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}",
+            f"{vdcs[1].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}",
+            f"{vdcs[2].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}",
+        )
+
+        cls.bulk_edit_data = {
+            'status': VirtualDeviceContextStatusChoices.STATUS_OFFLINE,
+        }

+ 8 - 0
netbox/dcim/urls.py

@@ -183,6 +183,14 @@ urlpatterns = [
     path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
     path('devices/<int:pk>/', include(get_model_urls('dcim', 'device'))),
 
+    # Virtual Device Context
+    path('vdcs/', views.VirtualDeviceContextListView.as_view(), name='virtualdevicecontext_list'),
+    path('vdcs/add/', views.VirtualDeviceContextEditView.as_view(), name='virtualdevicecontext_add'),
+    path('vdcs/import/', views.VirtualDeviceContextBulkImportView.as_view(), name='virtualdevicecontext_import'),
+    path('vdcs/edit/', views.VirtualDeviceContextBulkEditView.as_view(), name='virtualdevicecontext_bulk_edit'),
+    path('vdcs/delete/', views.VirtualDeviceContextBulkDeleteView.as_view(), name='virtualdevicecontext_bulk_delete'),
+    path('vdcs/<int:pk>/', include(get_model_urls('dcim', 'virtualdevicecontext'))),
+
     # Modules
     path('modules/', views.ModuleListView.as_view(), name='module_list'),
     path('modules/add/', views.ModuleEditView.as_view(), name='module_add'),

+ 61 - 0
netbox/dcim/views.py

@@ -2442,6 +2442,14 @@ class InterfaceView(generic.ObjectView):
     queryset = Interface.objects.all()
 
     def get_extra_context(self, request, instance):
+        # Get assigned VDC's
+        vdc_table = tables.VirtualDeviceContextTable(
+            data=instance.vdcs.restrict(request.user, 'view').prefetch_related('device'),
+            exclude=('tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags',
+                     'created', 'last_updated', 'actions', ),
+            orderable=False
+        )
+
         # Get assigned IP addresses
         ipaddress_table = AssignedIPAddressesTable(
             data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
@@ -2479,6 +2487,7 @@ class InterfaceView(generic.ObjectView):
         )
 
         return {
+            'vdc_table': vdc_table,
             'ipaddress_table': ipaddress_table,
             'bridge_interfaces_table': bridge_interfaces_tables,
             'child_interfaces_table': child_interfaces_tables,
@@ -3562,3 +3571,55 @@ class PowerFeedBulkDeleteView(generic.BulkDeleteView):
 
 # Trace view
 register_model_view(PowerFeed, 'trace', kwargs={'model': PowerFeed})(PathTraceView)
+
+
+# VDC
+class VirtualDeviceContextListView(generic.ObjectListView):
+    queryset = VirtualDeviceContext.objects.all()
+    filterset = filtersets.VirtualDeviceContextFilterSet
+    filterset_form = forms.VirtualDeviceContextFilterForm
+    table = tables.VirtualDeviceContextTable
+
+
+@register_model_view(VirtualDeviceContext)
+class VirtualDeviceContextView(generic.ObjectView):
+    queryset = VirtualDeviceContext.objects.all()
+
+    def get_extra_context(self, request, instance):
+        interfaces_table = tables.InterfaceTable(instance.interfaces, user=request.user)
+        interfaces_table.configure(request)
+
+        return {
+            'interfaces_table': interfaces_table,
+            'interface_count': instance.interfaces.count(),
+        }
+
+
+@register_model_view(VirtualDeviceContext, 'edit')
+class VirtualDeviceContextEditView(generic.ObjectEditView):
+    queryset = VirtualDeviceContext.objects.all()
+    form = forms.VirtualDeviceContextForm
+
+
+@register_model_view(VirtualDeviceContext, 'delete')
+class VirtualDeviceContextDeleteView(generic.ObjectDeleteView):
+    queryset = VirtualDeviceContext.objects.all()
+
+
+class VirtualDeviceContextBulkImportView(generic.BulkImportView):
+    queryset = VirtualDeviceContext.objects.all()
+    model_form = forms.VirtualDeviceContextCSVForm
+    table = tables.VirtualDeviceContextTable
+
+
+class VirtualDeviceContextBulkEditView(generic.BulkEditView):
+    queryset = VirtualDeviceContext.objects.all()
+    filterset = filtersets.VirtualDeviceContextFilterSet
+    table = tables.VirtualDeviceContextTable
+    form = forms.VirtualDeviceContextBulkEditForm
+
+
+class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView):
+    queryset = VirtualDeviceContext.objects.all()
+    filterset = filtersets.VirtualDeviceContextFilterSet
+    table = tables.VirtualDeviceContextTable

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

@@ -62,6 +62,7 @@ DEVICES_MENU = Menu(
                 get_model_item('dcim', 'devicerole', 'Device Roles'),
                 get_model_item('dcim', 'platform', 'Platforms'),
                 get_model_item('dcim', 'virtualchassis', 'Virtual Chassis'),
+                get_model_item('dcim', 'virtualdevicecontext', 'Virtual Device Contexts'),
             ),
         ),
         MenuGroup(

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

@@ -116,6 +116,7 @@
       {% plugin_left_page object %}
     </div>
     <div class="col col-md-6">
+      {% include 'inc/panel_table.html' with table=vdc_table heading="Virtual Device Contexts" %}
       <div class="card">
         <h5 class="card-header">Addressing</h5>
         <div class="card-body">

+ 68 - 0
netbox/templates/dcim/virtualdevicecontext.html

@@ -0,0 +1,68 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+
+{% block breadcrumbs %}
+  <li class="breadcrumb-item"><a href="{% url 'dcim:virtualdevicecontext_list' %}">Virtual Device Contexts</a></li>
+{% endblock %}
+
+{% block content %}
+<div class="row mb-3">
+	<div class="col col-md-6">
+    <div class="card">
+      <h5 class="card-header">
+        Virtual Device Context
+      </h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">Name</th>
+            <td>{{ object.name }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Device</th>
+            <td>{{ object.device|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Identifier</th>
+            <td>{{ object.identifier|placeholder }}</td>
+          </tr>
+          </tr>
+          <tr>
+            <th scope="row">Primary IPv4</th>
+            <td>
+              {{ object.primary_ip4|placeholder }}
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">Primary IPv6</th>
+            <td>
+              {{ object.primary_ip6|placeholder }}
+            </td>
+          </tr>
+        </table>
+      </div>
+    </div>
+    {% plugin_left_page object %}
+  </div>
+  <div class="col col-md-6">
+    {% include 'inc/panels/comments.html' %}
+    {% include 'inc/panels/tags.html' %}
+    {% include 'inc/panels/custom_fields.html' %}
+    {% plugin_right_page object %}
+  </div>
+</div>
+<div class="row mb-3">
+  <div class="col col-md-12">
+    <div class="card">
+      <h5 class="card-header">Interfaces</h5>
+      <div class="card-body table-responsive">
+        {% render_table interfaces_table 'inc/table.html' %}
+        {% include 'inc/paginator.html' with paginator=interfaces_table.paginator page=interfaces_table.page %}
+      </div>
+    </div>
+    {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}

+ 4 - 0
netbox/templates/tenancy/tenant.html

@@ -61,6 +61,10 @@
                     <h2><a href="{% url 'dcim:device_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.device_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.device_count }}</a></h2>
                     <p>Devices</p>
                 </div>
+                <div class="col col-md-4 text-center">
+                    <h2><a href="{% url 'dcim:virtualdevicecontext_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.vdc_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vdc_count }}</a></h2>
+                    <p>Virtual Device Contexts</p>
+                </div>
                 <div class="col col-md-4 text-center">
                     <h2><a href="{% url 'dcim:cable_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.cable_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.cable_count }}</a></h2>
                     <p>Cables</p>

+ 2 - 1
netbox/tenancy/views.py

@@ -2,7 +2,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.shortcuts import get_object_or_404
 
 from circuits.models import Circuit
-from dcim.models import Cable, Device, Location, Rack, RackReservation, Site
+from dcim.models import Cable, Device, Location, Rack, RackReservation, Site, VirtualDeviceContext
 from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN
 from netbox.views import generic
 from utilities.utils import count_related
@@ -109,6 +109,7 @@ class TenantView(generic.ObjectView):
             'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
             'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
             'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
+            'vdc_count': VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
             'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
             'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
             'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(),