diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index f5e06e155..29881a548 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -45,6 +45,7 @@ __all__ = [ 'NestedSiteSerializer', 'NestedSiteGroupSerializer', 'NestedVirtualChassisSerializer', + 'NestedVirtualDeviceContextSerializer', ] @@ -466,3 +467,12 @@ class NestedPowerFeedSerializer(WritableNestedSerializer): class Meta: model = models.PowerFeed fields = ['id', 'url', 'display', 'name', 'cable', '_occupied'] + + +class NestedVirtualDeviceContextSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail') + device = NestedDeviceSerializer() + + class Meta: + model = models.VirtualDeviceContext + fields = ['id', 'url', 'display', 'name', 'identifier', 'device'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9317d7c51..33d79612a 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -671,6 +671,22 @@ class DeviceSerializer(NetBoxModelSerializer): return data +class VirtualDeviceContextSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') + device = NestedDeviceSerializer() + tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) + primary_ip = NestedIPAddressSerializer(read_only=True) + primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) + primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) + + class Meta: + model = VirtualDeviceContext + fields = [ + 'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4', + 'primary_ip6', 'status', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ] + + class ModuleSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') device = NestedDeviceSerializer() @@ -823,6 +839,12 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() + vdcs = SerializedPKRelatedField( + queryset=VirtualDeviceContext.objects.all(), + serializer=NestedVirtualDeviceContextSerializer, + required=False, + many=True + ) module = ComponentNestedModuleSerializer( required=False, allow_null=True @@ -859,13 +881,13 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect class Meta: model = Interface fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', - 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', - 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', - 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type', - 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type', - 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', - 'count_fhrp_groups', '_occupied', + 'id', 'url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', + 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', + 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', + 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', + 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] def validate(self, data): diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 47bbfd525..2e16e2786 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -37,6 +37,7 @@ router.register('inventory-item-templates', views.InventoryItemTemplateViewSet) router.register('device-roles', views.DeviceRoleViewSet) router.register('platforms', views.PlatformViewSet) router.register('devices', views.DeviceViewSet) +router.register('vdcs', views.VirtualDeviceContextViewSet) router.register('modules', views.ModuleViewSet) # Device components diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index c18eab01f..3c5a3171f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -538,6 +538,14 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): return Response(response) +class VirtualDeviceContextViewSet(NetBoxModelViewSet): + queryset = VirtualDeviceContext.objects.prefetch_related( + 'device__device_type', 'device', 'tenant', 'tags', + ) + serializer_class = serializers.VirtualDeviceContextSerializer + filterset_class = filtersets.VirtualDeviceContextFilterSet + + class ModuleViewSet(NetBoxModelViewSet): queryset = Module.objects.prefetch_related( 'device', 'module_bay', 'module_type__manufacturer', 'tags', diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 8466d4861..ce637fb3d 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1399,3 +1399,20 @@ class PowerFeedPhaseChoices(ChoiceSet): (PHASE_SINGLE, 'Single phase'), (PHASE_3PHASE, 'Three-phase'), ) + + +# +# VDC +# +class VirtualDeviceContextStatusChoices(ChoiceSet): + key = 'VirtualDeviceContext.status' + + STATUS_PLANNED = 'planned' + STATUS_ACTIVE = 'active' + STATUS_OFFLINE = 'offline' + + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_OFFLINE, 'Offline', 'red'), + ] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 78afd816c..88d84a7ab 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -65,6 +65,7 @@ __all__ = ( 'SiteFilterSet', 'SiteGroupFilterSet', 'VirtualChassisFilterSet', + 'VirtualDeviceContextFilterSet', ) @@ -482,7 +483,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): class Meta: model = DeviceType fields = [ - 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit' + 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', ] def search(self, queryset, name, value): @@ -1009,6 +1010,44 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter return queryset.exclude(devicebays__isnull=value) +class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet): + device_id = django_filters.ModelMultipleChoiceFilter( + field_name='device', + queryset=Device.objects.all(), + label='VDC (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + field_name='device', + queryset=Device.objects.all(), + label='Device model', + ) + status = django_filters.MultipleChoiceFilter( + choices=VirtualDeviceContextStatusChoices + ) + has_primary_ip = django_filters.BooleanFilter( + method='_has_primary_ip', + label='Has a primary IP', + ) + + class Meta: + model = VirtualDeviceContext + fields = ['id', 'device', 'name', ] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(identifier=value.strip()) + ).distinct() + + def _has_primary_ip(self, queryset, name, value): + params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False) + if value: + return queryset.filter(params) + return queryset.exclude(params) + + class ModuleFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='module_type__manufacturer', @@ -1342,6 +1381,23 @@ class InterfaceFilterSet( to_field_name='rd', label='VRF (RD)', ) + vdc_id = django_filters.ModelMultipleChoiceFilter( + field_name='vdcs', + queryset=VirtualDeviceContext.objects.all(), + label='Virtual Device Context', + ) + vdc_identifier = django_filters.ModelMultipleChoiceFilter( + field_name='vdcs__identifier', + queryset=VirtualDeviceContext.objects.all(), + to_field_name='identifier', + label='Virtual Device Context (Identifier)', + ) + vdc = django_filters.ModelMultipleChoiceFilter( + field_name='vdcs__name', + queryset=VirtualDeviceContext.objects.all(), + to_field_name='name', + label='Virtual Device Context', + ) class Meta: model = Interface diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 1e58dd2f7..7a81ae7fb 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -54,6 +54,7 @@ __all__ = ( 'SiteBulkEditForm', 'SiteGroupBulkEditForm', 'VirtualChassisBulkEditForm', + 'VirtualDeviceContextBulkEditForm' ) @@ -1398,3 +1399,24 @@ class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm): (None, ('color', 'description')), ) nullable_fields = ('color', 'description') + + +class VirtualDeviceContextBulkEditForm(NetBoxModelBulkEditForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False + ) + status = forms.ChoiceField( + required=False, + choices=add_blank_choice(VirtualDeviceContextStatusChoices), + widget=StaticSelect() + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + model = VirtualDeviceContext + fieldsets = ( + (None, ('device', 'status', 'tenant')), + ) + nullable_fields = ('device', 'tenant', ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 2b77ef5a9..6073ee6fc 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -43,6 +43,7 @@ __all__ = ( 'SiteCSVForm', 'SiteGroupCSVForm', 'VirtualChassisCSVForm', + 'VirtualDeviceContextCSVForm' ) @@ -1084,3 +1085,25 @@ class PowerFeedCSVForm(NetBoxModelCSVForm): f"location__{self.fields['location'].to_field_name}": data.get('location'), } self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) + + +class VirtualDeviceContextCSVForm(NetBoxModelCSVForm): + + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Assigned role' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + fields = [ + 'name', 'device', 'status', 'tenant', 'identifier', 'comments', + ] + model = VirtualDeviceContext + help_texts = {} diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 905a898df..cc4dd635c 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -50,6 +50,7 @@ __all__ = ( 'SiteFilterForm', 'SiteGroupFilterForm', 'VirtualChassisFilterForm', + 'VirtualDeviceContextFilterForm' ) @@ -728,6 +729,37 @@ class DeviceFilterForm( tag = TagFilterField(model) +class VirtualDeviceContextFilterForm( + TenancyFilterForm, + NetBoxModelFilterSetForm +): + model = VirtualDeviceContext + fieldsets = ( + (None, ('q', 'filter', 'tag')), + ('Hardware', ('device', 'status', )), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Miscellaneous', ('has_primary_ip',)) + ) + device = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Device'), + fetch_trigger='open' + ) + status = MultipleChoiceField( + required=False, + choices=add_blank_choice(VirtualDeviceContextStatusChoices) + ) + has_primary_ip = forms.NullBooleanField( + required=False, + label='Has a primary IP', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = Module fieldsets = ( @@ -1075,9 +1107,18 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): ('Addressing', ('vrf_id', 'mac_address', 'wwn')), ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', + 'device_id', 'vdc_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) + vdc_id = DynamicModelMultipleChoiceField( + queryset=VirtualDeviceContext.objects.all(), + required=False, + query_params={ + 'device_id': '$device_id', + }, + label=_('Virtual Device Context') + ) kind = MultipleChoiceField( choices=InterfaceKindChoices, required=False diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 539c48709..da0148784 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -62,6 +62,7 @@ __all__ = ( 'SiteGroupForm', 'VCMemberSelectForm', 'VirtualChassisForm', + 'VirtualDeviceContextForm' ) INTERFACE_MODE_HELP_TEXT = """ @@ -1378,6 +1379,14 @@ class PowerOutletForm(ModularDeviceComponentForm): class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): + vdcs = DynamicModelMultipleChoiceField( + queryset=VirtualDeviceContext.objects.all(), + required=False, + label='Virtual Device Contexts', + query_params={ + 'device_id': '$device', + } + ) parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -1452,7 +1461,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): ) fieldsets = ( - ('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')), + ('Interface', ('device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')), ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), @@ -1466,7 +1475,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): class Meta: model = Interface fields = [ - 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', + 'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', @@ -1636,3 +1645,74 @@ class InventoryItemRoleForm(NetBoxModelForm): fields = [ 'name', 'slug', 'color', 'description', 'tags', ] + + +class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm): + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + }, + initial_params={ + 'racks': '$rack' + } + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + 'location_id': '$location', + } + ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + query_params={ + 'site_id': '$site', + 'location_id': '$location', + 'rack_id': '$rack', + } + ) + + fieldsets = ( + ('Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')), + ('Virtual Device Context', ('name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', + 'tenant')), + (None, ('tags', )) + ) + + class Meta: + model = VirtualDeviceContext + fields = [ + 'region', 'site_group', 'site', 'location', 'rack', + 'device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', + 'comments', 'tags' + ] + help_texts = {} + widgets = { + 'primary_ip4': StaticSelect(), + 'primary_ip6': StaticSelect(), + } diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 1d5b6a580..eba311420 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -2,6 +2,7 @@ import graphene from netbox.graphql.fields import ObjectField, ObjectListField from .types import * +from .types import VirtualDeviceContextType class DCIMQuery(graphene.ObjectType): @@ -121,3 +122,6 @@ class DCIMQuery(graphene.ObjectType): virtual_chassis = ObjectField(VirtualChassisType) virtual_chassis_list = ObjectListField(VirtualChassisType) + + virtual_device_context = ObjectField(VirtualDeviceContextType) + virtual_device_context_list = ObjectListField(VirtualDeviceContextType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index bb414ed00..41f0092f9 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -500,3 +500,11 @@ class VirtualChassisType(NetBoxObjectType): model = models.VirtualChassis fields = '__all__' filterset_class = filtersets.VirtualChassisFilterSet + + +class VirtualDeviceContextType(NetBoxObjectType): + + class Meta: + model = models.VirtualDeviceContext + fields = '__all__' + filterset_class = filtersets.VirtualDeviceContextFilterSet diff --git a/netbox/dcim/migrations/0166_virtualdevicecontext.py b/netbox/dcim/migrations/0166_virtualdevicecontext.py new file mode 100644 index 000000000..76627307a --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 8855107b3..23c820571 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -531,6 +531,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd max_length=100, blank=True ) + vdcs = models.ManyToManyField( + to='dcim.VirtualDeviceContext', + related_name='interfaces' + ) lag = models.ForeignKey( to='self', on_delete=models.SET_NULL, diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 78282f893..3a14af059 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -34,6 +34,7 @@ __all__ = ( 'ModuleType', 'Platform', 'VirtualChassis', + 'VirtualDeviceContext', ) @@ -119,7 +120,7 @@ class DeviceType(PrimaryModel, WeightMixin): ) clone_fields = ( - 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', + 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit' ) class Meta: @@ -1062,3 +1063,81 @@ class VirtualChassis(PrimaryModel): ) return super().delete(*args, **kwargs) + + +class VirtualDeviceContext(PrimaryModel): + device = models.ForeignKey( + to='Device', + on_delete=models.PROTECT, + related_name='vdcs', + blank=True, + null=True + ) + name = models.CharField( + max_length=64 + ) + status = models.CharField( + max_length=50, + choices=VirtualDeviceContextStatusChoices, + ) + identifier = models.PositiveSmallIntegerField( + help_text='Unique identifier provided by the platform being virtualized (Example: Nexus VDC Identifier)', + blank=True, + null=True, + ) + primary_ip4 = models.OneToOneField( + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name='Primary IPv4' + ) + primary_ip6 = models.OneToOneField( + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name='Primary IPv6' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='vdcs', + blank=True, + null=True + ) + comments = models.TextField( + blank=True + ) + + class Meta: + ordering = ['name'] + constraints = ( + models.UniqueConstraint( + fields=('device', 'identifier',), + name='%(app_label)s_%(class)s_device_identifiers' + ), + models.UniqueConstraint( + fields=('device', 'name',), + name='%(app_label)s_%(class)s_name' + ), + ) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:virtualdevicecontext', kwargs={'pk': self.pk}) + + @property + def primary_ip(self): + if ConfigItem('PREFER_IPV4')() and self.primary_ip4: + return self.primary_ip4 + elif self.primary_ip6: + return self.primary_ip6 + elif self.primary_ip4: + return self.primary_ip4 + else: + return None diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 45a210080..3a089ae93 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -36,6 +36,7 @@ __all__ = ( 'PowerPortTable', 'RearPortTable', 'VirtualChassisTable', + 'VirtualDeviceContextTable' ) @@ -884,3 +885,43 @@ class VirtualChassisTable(NetBoxTable): 'last_updated', ) default_columns = ('pk', 'name', 'domain', 'master', 'member_count') + + +class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable): + name = tables.Column( + linkify=True + ) + device = tables.TemplateColumn( + order_by=('_name',), + template_code=DEVICE_LINK + ) + status = columns.ChoiceFieldColumn() + primary_ip = tables.Column( + linkify=True, + order_by=('primary_ip4', 'primary_ip6'), + verbose_name='IP Address' + ) + primary_ip4 = tables.Column( + linkify=True, + verbose_name='IPv4 Address' + ) + primary_ip6 = tables.Column( + linkify=True, + verbose_name='IPv6 Address' + ) + + comments = columns.MarkdownColumn() + + tags = columns.TagColumn( + url_name='dcim:vdc_list' + ) + + class Meta(NetBoxTable.Meta): + model = models.VirtualDeviceContext + fields = ( + 'pk', 'id', 'name', 'status', 'identifier', 'tenant', 'tenant_group', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'identifier', 'status', 'tenant', 'primary_ip', + ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 2697c29b2..bd3cb3f01 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1485,6 +1485,12 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase ) Interface.objects.bulk_create(interfaces) + vdcs = ( + VirtualDeviceContext(name='VDC 1', identifier=1, device=device), + VirtualDeviceContext(name='VDC 2', identifier=2, device=device) + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + vlans = ( VLAN(name='VLAN 1', vid=1), VLAN(name='VLAN 2', vid=2), @@ -1533,6 +1539,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, { 'device': device.pk, + 'vdcs': [vdcs[0].pk], 'name': 'Interface 6', 'type': 'virtual', 'mode': InterfaceModeChoices.MODE_TAGGED, @@ -1543,6 +1550,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, { 'device': device.pk, + 'vdcs': [vdcs[1].pk], 'name': 'Interface 7', 'type': InterfaceTypeChoices.TYPE_80211A, 'tx_power': 10, @@ -1551,6 +1559,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, { 'device': device.pk, + 'vdcs': [vdcs[1].pk], 'name': 'Interface 8', 'type': InterfaceTypeChoices.TYPE_80211A, 'tx_power': 10, @@ -2163,3 +2172,57 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase): 'type': REDUNDANT, }, ] + + +class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase): + model = VirtualDeviceContext + brief_fields = ['device', 'display', 'id', 'identifier', 'name', 'url'] + bulk_update_data = { + 'status': 'planned', + } + + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name='Test Site', slug='test-site') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type', slug='device-type') + devicerole = DeviceRole.objects.create(name='Device Role', slug='device-role', color='ff0000') + + devices = ( + Device(name='Device 1', device_type=devicetype, device_role=devicerole, site=site), + Device(name='Device 2', device_type=devicetype, device_role=devicerole, site=site), + Device(name='Device 3', device_type=devicetype, device_role=devicerole, site=site), + ) + Device.objects.bulk_create(devices) + + vdcs = ( + VirtualDeviceContext(device=devices[1], name='VDC 1', identifier=1, status='active'), + VirtualDeviceContext(device=devices[1], name='VDC 2', identifier=2, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 1', identifier=1, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 2', identifier=2, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 3', identifier=3, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 4', identifier=4, status='active'), + VirtualDeviceContext(device=devices[2], name='VDC 5', identifier=5, status='active'), + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + + cls.create_data = [ + { + 'device': devices[0].pk, + 'status': 'active', + 'name': 'VDC 1', + 'identifier': 1, + }, + { + 'device': devices[0].pk, + 'status': 'active', + 'name': 'VDC 2', + 'identifier': 2, + }, + { + 'device': devices[1].pk, + 'status': 'active', + 'name': 'VDC 3', + 'identifier': 3, + }, + ] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 92298bd73..f3dff428c 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2681,6 +2681,13 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) VRF.objects.bulk_create(vrfs) + # Virtual Device Context Creation + vdcs = ( + VirtualDeviceContext(device=devices[3], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE), + VirtualDeviceContext(device=devices[3], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED), + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + # VirtualChassis assignment for filtering virtual_chassis = VirtualChassis.objects.create(master=devices[0]) Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1) @@ -2793,6 +2800,12 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) Interface.objects.bulk_create(interfaces) + interfaces[3].vdcs.set([vdcs[0], vdcs[1]]) + interfaces[4].vdcs.set([vdcs[0], vdcs[1]]) + interfaces[5].vdcs.set([vdcs[0]]) + interfaces[6].vdcs.set([vdcs[0]]) + interfaces[7].vdcs.set([vdcs[1]]) + # Cables Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save() Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save() @@ -2997,6 +3010,21 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'vrf': [vrfs[0].rd, vrfs[1].rd]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_vdc(self): + params = {'vdc': ['VDC 1']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + devices = Device.objects.last() + vdc = VirtualDeviceContext.objects.filter(device=devices, name='VDC 2') + params = {'vdc_id': vdc.values_list('pk', flat=True)} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_vdc_identifier(self): + devices = Device.objects.last() + vdc = VirtualDeviceContext.objects.filter(device=devices, name='VDC 2') + params = {'vdc_identifier': vdc.values_list('identifier', flat=True)} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = FrontPort.objects.all() @@ -4254,4 +4282,83 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -# TODO: Connection filters +class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = VirtualDeviceContext.objects.all() + filterset = VirtualDeviceContextFilterSet + + @classmethod + def setUpTestData(cls): + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') + device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + devices = ( + Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]), + Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]), + Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]), + ) + Device.objects.bulk_create(devices) + + vdcs = ( + VirtualDeviceContext(device=devices[0], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE), + VirtualDeviceContext(device=devices[0], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED), + VirtualDeviceContext(device=devices[1], name='VDC 1', status=VirtualDeviceContextStatusChoices.STATUS_OFFLINE), + VirtualDeviceContext(device=devices[1], name='VDC 2', status=VirtualDeviceContextStatusChoices.STATUS_PLANNED), + VirtualDeviceContext(device=devices[2], name='VDC 1', status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE), + VirtualDeviceContext(device=devices[2], name='VDC 2', status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE), + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + + interfaces = ( + Interface(device=devices[0], name='Interface 1', type='virtual'), + Interface(device=devices[0], name='Interface 2', type='virtual'), + ) + Interface.objects.bulk_create(interfaces) + + interfaces[0].vdcs.set([vdcs[0]]) + interfaces[1].vdcs.set([vdcs[1]]) + + addresses = ( + IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'), + IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'), + ) + IPAddress.objects.bulk_create(addresses) + + vdcs[0].primary_ip4 = addresses[0] + vdcs[0].save() + vdcs[1].primary_ip4 = addresses[1] + vdcs[1].save() + + def test_device(self): + params = {'device': ['Device 1', 'Device 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + + def test_status(self): + params = {'status': ['active']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_device_id(self): + devices = Device.objects.filter(name__in=['Device 1', 'Device 2']) + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_has_primary_ip(self): + params = {'has_primary_ip': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'has_primary_ip': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 460a5e252..a64c6d56b 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -588,3 +588,50 @@ class CableTestCase(TestCase): cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface]) with self.assertRaises(ValidationError): cable.clean() + + +class VirtualDeviceContextTestCase(TestCase): + + def setUp(self): + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site + ) + + def test_vdc_and_interface_creation(self): + + vdc = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active') + vdc.full_clean() + vdc.save() + + interface = Interface(device=self.device, name='Eth1/1', type='10gbase-t') + interface.full_clean() + interface.save() + + interface.vdcs.set([vdc]) + + def test_vdc_duplicate_name(self): + vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active') + vdc1.full_clean() + vdc1.save() + + vdc2 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=2, status='active') + with self.assertRaises(ValidationError): + vdc2.full_clean() + + def test_vdc_duplicate_identifier(self): + vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active') + vdc1.full_clean() + vdc1.save() + + vdc2 = VirtualDeviceContext(device=self.device, name="VDC 2", identifier=1, status='active') + with self.assertRaises(ValidationError): + vdc2.full_clean() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index d563dcfd6..300228601 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -3076,3 +3076,48 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk})) self.assertHttpStatus(response, 200) + + +class VirtualDeviceContextTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = VirtualDeviceContext + + @classmethod + def setUpTestData(cls): + devices = [create_test_device(name='Device 1')] + + vdcs = ( + VirtualDeviceContext(name='VDC 1', identifier=1, device=devices[0], status='active'), + VirtualDeviceContext(name='VDC 2', identifier=2, device=devices[0], status='active'), + VirtualDeviceContext(name='VDC 3', identifier=3, device=devices[0], status='active'), + ) + VirtualDeviceContext.objects.bulk_create(vdcs) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'device': devices[0].pk, + 'status': 'active', + 'name': 'VDC 4', + 'identifier': 4, + 'primary_ip4': None, + 'primary_ip6': None, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "device,status,name,identifier", + "Device 1,active,VDC 5,5", + "Device 1,active,VDC 6,6", + "Device 1,active,VDC 7,7", + ) + + cls.csv_update_data = ( + "id,status", + f"{vdcs[0].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}", + f"{vdcs[1].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}", + f"{vdcs[2].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}", + ) + + cls.bulk_edit_data = { + 'status': VirtualDeviceContextStatusChoices.STATUS_OFFLINE, + } diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index ecd2d46c5..33b61309e 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -183,6 +183,14 @@ urlpatterns = [ path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), path('devices//', 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//', 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'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 437162bce..ae621008a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2442,6 +2442,14 @@ class InterfaceView(generic.ObjectView): queryset = Interface.objects.all() def get_extra_context(self, request, instance): + # Get assigned VDC's + vdc_table = tables.VirtualDeviceContextTable( + data=instance.vdcs.restrict(request.user, 'view').prefetch_related('device'), + exclude=('tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', + 'created', 'last_updated', 'actions', ), + orderable=False + ) + # Get assigned IP addresses ipaddress_table = AssignedIPAddressesTable( data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), @@ -2479,6 +2487,7 @@ class InterfaceView(generic.ObjectView): ) return { + 'vdc_table': vdc_table, 'ipaddress_table': ipaddress_table, 'bridge_interfaces_table': bridge_interfaces_tables, 'child_interfaces_table': child_interfaces_tables, @@ -3562,3 +3571,55 @@ class PowerFeedBulkDeleteView(generic.BulkDeleteView): # Trace view register_model_view(PowerFeed, 'trace', kwargs={'model': PowerFeed})(PathTraceView) + + +# VDC +class VirtualDeviceContextListView(generic.ObjectListView): + queryset = VirtualDeviceContext.objects.all() + filterset = filtersets.VirtualDeviceContextFilterSet + filterset_form = forms.VirtualDeviceContextFilterForm + table = tables.VirtualDeviceContextTable + + +@register_model_view(VirtualDeviceContext) +class VirtualDeviceContextView(generic.ObjectView): + queryset = VirtualDeviceContext.objects.all() + + def get_extra_context(self, request, instance): + interfaces_table = tables.InterfaceTable(instance.interfaces, user=request.user) + interfaces_table.configure(request) + + return { + 'interfaces_table': interfaces_table, + 'interface_count': instance.interfaces.count(), + } + + +@register_model_view(VirtualDeviceContext, 'edit') +class VirtualDeviceContextEditView(generic.ObjectEditView): + queryset = VirtualDeviceContext.objects.all() + form = forms.VirtualDeviceContextForm + + +@register_model_view(VirtualDeviceContext, 'delete') +class VirtualDeviceContextDeleteView(generic.ObjectDeleteView): + queryset = VirtualDeviceContext.objects.all() + + +class VirtualDeviceContextBulkImportView(generic.BulkImportView): + queryset = VirtualDeviceContext.objects.all() + model_form = forms.VirtualDeviceContextCSVForm + table = tables.VirtualDeviceContextTable + + +class VirtualDeviceContextBulkEditView(generic.BulkEditView): + queryset = VirtualDeviceContext.objects.all() + filterset = filtersets.VirtualDeviceContextFilterSet + table = tables.VirtualDeviceContextTable + form = forms.VirtualDeviceContextBulkEditForm + + +class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView): + queryset = VirtualDeviceContext.objects.all() + filterset = filtersets.VirtualDeviceContextFilterSet + table = tables.VirtualDeviceContextTable diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 60c0657ae..dcec76d91 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -62,6 +62,7 @@ DEVICES_MENU = Menu( get_model_item('dcim', 'devicerole', 'Device Roles'), get_model_item('dcim', 'platform', 'Platforms'), get_model_item('dcim', 'virtualchassis', 'Virtual Chassis'), + get_model_item('dcim', 'virtualdevicecontext', 'Virtual Device Contexts'), ), ), MenuGroup( diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 887433d7b..73d590d3d 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -116,6 +116,7 @@ {% plugin_left_page object %}
+ {% include 'inc/panel_table.html' with table=vdc_table heading="Virtual Device Contexts" %}
Addressing
diff --git a/netbox/templates/dcim/virtualdevicecontext.html b/netbox/templates/dcim/virtualdevicecontext.html new file mode 100644 index 000000000..f5eb820f0 --- /dev/null +++ b/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 %} + +{% endblock %} + +{% block content %} +
+
+
+
+ Virtual Device Context +
+
+ + + + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Device{{ object.device|linkify }}
Identifier{{ object.identifier|placeholder }}
Primary IPv4 + {{ object.primary_ip4|placeholder }} +
Primary IPv6 + {{ object.primary_ip6|placeholder }} +
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
Interfaces
+
+ {% render_table interfaces_table 'inc/table.html' %} + {% include 'inc/paginator.html' with paginator=interfaces_table.paginator page=interfaces_table.page %} +
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index e8dc4b23a..6fb454391 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -61,6 +61,10 @@

{{ stats.device_count }}

Devices

+
+

{{ stats.vdc_count }}

+

Virtual Device Contexts

+

{{ stats.cable_count }}

Cables

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