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',
     'NestedSiteSerializer',
     'NestedSiteGroupSerializer',
     'NestedSiteGroupSerializer',
     'NestedVirtualChassisSerializer',
     'NestedVirtualChassisSerializer',
+    'NestedVirtualDeviceContextSerializer',
 ]
 ]
 
 
 
 
@@ -466,3 +467,12 @@ class NestedPowerFeedSerializer(WritableNestedSerializer):
     class Meta:
     class Meta:
         model = models.PowerFeed
         model = models.PowerFeed
         fields = ['id', 'url', 'display', 'name', 'cable', '_occupied']
         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
         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):
 class ModuleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
@@ -823,6 +839,12 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
 class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
 class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
+    vdcs = SerializedPKRelatedField(
+        queryset=VirtualDeviceContext.objects.all(),
+        serializer=NestedVirtualDeviceContextSerializer,
+        required=False,
+        many=True
+    )
     module = ComponentNestedModuleSerializer(
     module = ComponentNestedModuleSerializer(
         required=False,
         required=False,
         allow_null=True
         allow_null=True
@@ -859,13 +881,13 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         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):
     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('device-roles', views.DeviceRoleViewSet)
 router.register('platforms', views.PlatformViewSet)
 router.register('platforms', views.PlatformViewSet)
 router.register('devices', views.DeviceViewSet)
 router.register('devices', views.DeviceViewSet)
+router.register('vdcs', views.VirtualDeviceContextViewSet)
 router.register('modules', views.ModuleViewSet)
 router.register('modules', views.ModuleViewSet)
 
 
 # Device components
 # Device components

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

@@ -538,6 +538,14 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
         return Response(response)
         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):
 class ModuleViewSet(NetBoxModelViewSet):
     queryset = Module.objects.prefetch_related(
     queryset = Module.objects.prefetch_related(
         'device', 'module_bay', 'module_type__manufacturer', 'tags',
         '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_SINGLE, 'Single phase'),
         (PHASE_3PHASE, 'Three-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',
     'SiteFilterSet',
     'SiteGroupFilterSet',
     'SiteGroupFilterSet',
     'VirtualChassisFilterSet',
     'VirtualChassisFilterSet',
+    'VirtualDeviceContextFilterSet',
 )
 )
 
 
 
 
@@ -482,7 +483,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
         fields = [
         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):
     def search(self, queryset, name, value):
@@ -1009,6 +1010,44 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
         return queryset.exclude(devicebays__isnull=value)
         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):
 class ModuleFilterSet(NetBoxModelFilterSet):
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         field_name='module_type__manufacturer',
         field_name='module_type__manufacturer',
@@ -1342,6 +1381,23 @@ class InterfaceFilterSet(
         to_field_name='rd',
         to_field_name='rd',
         label='VRF (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:
     class Meta:
         model = Interface
         model = Interface

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

@@ -54,6 +54,7 @@ __all__ = (
     'SiteBulkEditForm',
     'SiteBulkEditForm',
     'SiteGroupBulkEditForm',
     'SiteGroupBulkEditForm',
     'VirtualChassisBulkEditForm',
     'VirtualChassisBulkEditForm',
+    'VirtualDeviceContextBulkEditForm'
 )
 )
 
 
 
 
@@ -1398,3 +1399,24 @@ class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm):
         (None, ('color', 'description')),
         (None, ('color', 'description')),
     )
     )
     nullable_fields = ('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',
     'SiteCSVForm',
     'SiteGroupCSVForm',
     'SiteGroupCSVForm',
     'VirtualChassisCSVForm',
     'VirtualChassisCSVForm',
+    'VirtualDeviceContextCSVForm'
 )
 )
 
 
 
 
@@ -1084,3 +1085,25 @@ class PowerFeedCSVForm(NetBoxModelCSVForm):
                 f"location__{self.fields['location'].to_field_name}": data.get('location'),
                 f"location__{self.fields['location'].to_field_name}": data.get('location'),
             }
             }
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
             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',
     'SiteFilterForm',
     'SiteGroupFilterForm',
     'SiteGroupFilterForm',
     'VirtualChassisFilterForm',
     'VirtualChassisFilterForm',
+    'VirtualDeviceContextFilterForm'
 )
 )
 
 
 
 
@@ -728,6 +729,37 @@ class DeviceFilterForm(
     tag = TagFilterField(model)
     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):
 class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Module
     model = Module
     fieldsets = (
     fieldsets = (
@@ -1075,9 +1107,18 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         ('Addressing', ('vrf_id', 'mac_address', 'wwn')),
         ('Addressing', ('vrf_id', 'mac_address', 'wwn')),
         ('PoE', ('poe_mode', 'poe_type')),
         ('PoE', ('poe_mode', 'poe_type')),
         ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
         ('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')),
         ('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(
     kind = MultipleChoiceField(
         choices=InterfaceKindChoices,
         choices=InterfaceKindChoices,
         required=False
         required=False

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

@@ -62,6 +62,7 @@ __all__ = (
     'SiteGroupForm',
     'SiteGroupForm',
     'VCMemberSelectForm',
     'VCMemberSelectForm',
     'VirtualChassisForm',
     'VirtualChassisForm',
+    'VirtualDeviceContextForm'
 )
 )
 
 
 INTERFACE_MODE_HELP_TEXT = """
 INTERFACE_MODE_HELP_TEXT = """
@@ -1378,6 +1379,14 @@ class PowerOutletForm(ModularDeviceComponentForm):
 
 
 
 
 class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
 class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
+    vdcs = DynamicModelMultipleChoiceField(
+        queryset=VirtualDeviceContext.objects.all(),
+        required=False,
+        label='Virtual Device Contexts',
+        query_params={
+            'device_id': '$device',
+        }
+    )
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         required=False,
         required=False,
@@ -1452,7 +1461,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
     )
     )
 
 
     fieldsets = (
     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')),
         ('Addressing', ('vrf', 'mac_address', 'wwn')),
         ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
         ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
         ('Related Interfaces', ('parent', 'bridge', 'lag')),
         ('Related Interfaces', ('parent', 'bridge', 'lag')),
@@ -1466,7 +1475,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         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',
             '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',
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
             'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
             'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
@@ -1636,3 +1645,74 @@ class InventoryItemRoleForm(NetBoxModelForm):
         fields = [
         fields = [
             'name', 'slug', 'color', 'description', 'tags',
             '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 netbox.graphql.fields import ObjectField, ObjectListField
 from .types import *
 from .types import *
+from .types import VirtualDeviceContextType
 
 
 
 
 class DCIMQuery(graphene.ObjectType):
 class DCIMQuery(graphene.ObjectType):
@@ -121,3 +122,6 @@ class DCIMQuery(graphene.ObjectType):
 
 
     virtual_chassis = ObjectField(VirtualChassisType)
     virtual_chassis = ObjectField(VirtualChassisType)
     virtual_chassis_list = ObjectListField(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
         model = models.VirtualChassis
         fields = '__all__'
         fields = '__all__'
         filterset_class = filtersets.VirtualChassisFilterSet
         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,
         max_length=100,
         blank=True
         blank=True
     )
     )
+    vdcs = models.ManyToManyField(
+        to='dcim.VirtualDeviceContext',
+        related_name='interfaces'
+    )
     lag = models.ForeignKey(
     lag = models.ForeignKey(
         to='self',
         to='self',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,

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

@@ -34,6 +34,7 @@ __all__ = (
     'ModuleType',
     'ModuleType',
     'Platform',
     'Platform',
     'VirtualChassis',
     'VirtualChassis',
+    'VirtualDeviceContext',
 )
 )
 
 
 
 
@@ -119,7 +120,7 @@ class DeviceType(PrimaryModel, WeightMixin):
     )
     )
 
 
     clone_fields = (
     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:
     class Meta:
@@ -1062,3 +1063,81 @@ class VirtualChassis(PrimaryModel):
             )
             )
 
 
         return super().delete(*args, **kwargs)
         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',
     'PowerPortTable',
     'RearPortTable',
     'RearPortTable',
     'VirtualChassisTable',
     'VirtualChassisTable',
+    'VirtualDeviceContextTable'
 )
 )
 
 
 
 
@@ -884,3 +885,43 @@ class VirtualChassisTable(NetBoxTable):
             'last_updated',
             'last_updated',
         )
         )
         default_columns = ('pk', 'name', 'domain', 'master', 'member_count')
         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)
         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 = (
         vlans = (
             VLAN(name='VLAN 1', vid=1),
             VLAN(name='VLAN 1', vid=1),
             VLAN(name='VLAN 2', vid=2),
             VLAN(name='VLAN 2', vid=2),
@@ -1533,6 +1539,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
+                'vdcs': [vdcs[0].pk],
                 'name': 'Interface 6',
                 'name': 'Interface 6',
                 'type': 'virtual',
                 'type': 'virtual',
                 'mode': InterfaceModeChoices.MODE_TAGGED,
                 'mode': InterfaceModeChoices.MODE_TAGGED,
@@ -1543,6 +1550,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
+                'vdcs': [vdcs[1].pk],
                 'name': 'Interface 7',
                 'name': 'Interface 7',
                 'type': InterfaceTypeChoices.TYPE_80211A,
                 'type': InterfaceTypeChoices.TYPE_80211A,
                 'tx_power': 10,
                 'tx_power': 10,
@@ -1551,6 +1559,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
+                'vdcs': [vdcs[1].pk],
                 'name': 'Interface 8',
                 'name': 'Interface 8',
                 'type': InterfaceTypeChoices.TYPE_80211A,
                 'type': InterfaceTypeChoices.TYPE_80211A,
                 'tx_power': 10,
                 'tx_power': 10,
@@ -2163,3 +2172,57 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase):
                 'type': REDUNDANT,
                 '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)
         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
         # VirtualChassis assignment for filtering
         virtual_chassis = VirtualChassis.objects.create(master=devices[0])
         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)
         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)
         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
         # Cables
         Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save()
         Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save()
         Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).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]}
         params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
 class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
@@ -4254,4 +4282,83 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         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])
         cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface])
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             cable.clean()
             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}))
         response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk}))
         self.assertHttpStatus(response, 200)
         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/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
     path('devices/<int:pk>/', include(get_model_urls('dcim', 'device'))),
     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
     # Modules
     path('modules/', views.ModuleListView.as_view(), name='module_list'),
     path('modules/', views.ModuleListView.as_view(), name='module_list'),
     path('modules/add/', views.ModuleEditView.as_view(), name='module_add'),
     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()
     queryset = Interface.objects.all()
 
 
     def get_extra_context(self, request, instance):
     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
         # Get assigned IP addresses
         ipaddress_table = AssignedIPAddressesTable(
         ipaddress_table = AssignedIPAddressesTable(
             data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
             data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
@@ -2479,6 +2487,7 @@ class InterfaceView(generic.ObjectView):
         )
         )
 
 
         return {
         return {
+            'vdc_table': vdc_table,
             'ipaddress_table': ipaddress_table,
             'ipaddress_table': ipaddress_table,
             'bridge_interfaces_table': bridge_interfaces_tables,
             'bridge_interfaces_table': bridge_interfaces_tables,
             'child_interfaces_table': child_interfaces_tables,
             'child_interfaces_table': child_interfaces_tables,
@@ -3562,3 +3571,55 @@ class PowerFeedBulkDeleteView(generic.BulkDeleteView):
 
 
 # Trace view
 # Trace view
 register_model_view(PowerFeed, 'trace', kwargs={'model': PowerFeed})(PathTraceView)
 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', 'devicerole', 'Device Roles'),
                 get_model_item('dcim', 'platform', 'Platforms'),
                 get_model_item('dcim', 'platform', 'Platforms'),
                 get_model_item('dcim', 'virtualchassis', 'Virtual Chassis'),
                 get_model_item('dcim', 'virtualchassis', 'Virtual Chassis'),
+                get_model_item('dcim', 'virtualdevicecontext', 'Virtual Device Contexts'),
             ),
             ),
         ),
         ),
         MenuGroup(
         MenuGroup(

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

@@ -116,6 +116,7 @@
       {% plugin_left_page object %}
       {% plugin_left_page object %}
     </div>
     </div>
     <div class="col col-md-6">
     <div class="col col-md-6">
+      {% include 'inc/panel_table.html' with table=vdc_table heading="Virtual Device Contexts" %}
       <div class="card">
       <div class="card">
         <h5 class="card-header">Addressing</h5>
         <h5 class="card-header">Addressing</h5>
         <div class="card-body">
         <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>
                     <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>
                     <p>Devices</p>
                 </div>
                 </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">
                 <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>
                     <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>
                     <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 django.shortcuts import get_object_or_404
 
 
 from circuits.models import Circuit
 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 ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN
 from netbox.views import generic
 from netbox.views import generic
 from utilities.utils import count_related
 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(),
             'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
             'location_count': Location.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(),
             '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(),
             'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
             'aggregate_count': Aggregate.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(),
             'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(),