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>
This commit is contained in:
Daniel Sheppard 2022-11-11 06:55:49 -06:00 committed by GitHub
parent 653acbf62c
commit b374351154
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 890 additions and 14 deletions

View File

@ -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']

View File

@ -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):

View File

@ -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

View File

@ -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',

View File

@ -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'),
]

View File

@ -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

View File

@ -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', )

View File

@ -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 = {}

View File

@ -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

View File

@ -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(),
}

View File

@ -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)

View File

@ -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

View File

@ -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'),
),
]

View File

@ -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,

View File

@ -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

View File

@ -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',
)

View File

@ -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,
},
]

View File

@ -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)

View File

@ -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()

View File

@ -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,
}

View File

@ -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'),

View File

@ -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

View File

@ -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(

View File

@ -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">

View File

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

View File

@ -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>

View File

@ -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(),