From aee9d9e0e740d2f2dc53169b4c8d734f09a55552 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 28 Oct 2022 10:50:19 -0500 Subject: [PATCH] Work on #7854 --- netbox/dcim/api/serializers.py | 16 ++++ netbox/dcim/api/urls.py | 1 + netbox/dcim/api/views.py | 8 ++ netbox/dcim/choices.py | 71 ++++++++++++++ netbox/dcim/filtersets.py | 30 +++++- netbox/dcim/forms/bulk_edit.py | 18 ++++ netbox/dcim/forms/bulk_import.py | 23 +++++ netbox/dcim/forms/filtersets.py | 38 +++++++- netbox/dcim/forms/model_forms.py | 94 ++++++++++++++++++- netbox/dcim/models/device_components.py | 4 + netbox/dcim/models/devices.py | 78 ++++++++++++++- netbox/dcim/signals.py | 20 +++- netbox/dcim/tables/devices.py | 43 +++++++++ netbox/dcim/urls.py | 14 +++ netbox/dcim/views.py | 63 +++++++++++++ netbox/netbox/navigation/menu.py | 1 + .../templates/dcim/virtualdevicecontext.html | 75 +++++++++++++++ 17 files changed, 587 insertions(+), 10 deletions(-) create mode 100644 netbox/templates/dcim/virtualdevicecontext.html diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1cf9369ae98..7b49c853cb9 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -672,6 +672,22 @@ def get_parent_device(self, obj): 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', 'tenant', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', + ] + + class ModuleSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') device = NestedDeviceSerializer() diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 47bbfd52571..2e16e278647 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -37,6 +37,7 @@ 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 c18eab01f3d..3c5a3171f39 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -538,6 +538,14 @@ def napalm(self, request, pk): 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 8466d4861e9..e54d5f48160 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1399,3 +1399,74 @@ 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'), + ] + + +class VirtualDeviceContextTypeChoices(ChoiceSet): + + CISCO_NEXUS_VDC = 'cisco-nexus-vdc' + CISCO_ASA_CONTEXT = 'cisco-asa-context' + CISCO_FTD_INSTANCE = 'cisico-ftd-instance' + JUNIPER_VR = 'juniper-virtualrouter' + FORTINET_VDOM = 'fortinet-virtualdomain' + PALOALTO_VSYS = 'paloalto-virtualsystem' + CHECKPOINT_VSYS = 'checkpoint-virtualsystem' + + OTHER_VIRTUALCONTEXT = 'other-virtualcontext' + + CHOICES = ( + ( + 'Cisco', + ( + (CISCO_NEXUS_VDC, 'Nexus VDC'), + (CISCO_ASA_CONTEXT, 'ASA Context'), + (CISCO_FTD_INSTANCE, 'FTD Instance'), + ) + ), + ( + 'Juniper', + ( + (JUNIPER_VR, 'Virtual Router'), + ) + ), + ( + 'Fortinet', + ( + (FORTINET_VDOM, 'Virtual Domain'), + ) + ), + ( + 'Palo Alto', + ( + (PALOALTO_VSYS, 'Virtual System'), + ) + ), + ( + 'Checkpoint', + ( + (CHECKPOINT_VSYS, 'Virtual System'), + ) + ), + ( + 'Other', + ( + (OTHER_VIRTUALCONTEXT, 'Virtual Context'), + ) + ), + ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 78afd816cf7..1c3562c79fe 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -65,6 +65,7 @@ 'SiteFilterSet', 'SiteGroupFilterSet', 'VirtualChassisFilterSet', + 'VirtualDeviceContextFilterSet', ) @@ -434,6 +435,9 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + vdc_type = django_filters.MultipleChoiceFilter( + choices=VirtualDeviceContextTypeChoices + ) has_front_image = django_filters.BooleanFilter( label='Has a front image', method='_has_front_image' @@ -482,7 +486,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', 'vdc_type', ] def search(self, queryset, name, value): @@ -1009,6 +1013,30 @@ def _device_bays(self, queryset, name, 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',] + + class ModuleFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='module_type__manufacturer', diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index e3b69dc810f..b8d45f55d19 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -54,6 +54,7 @@ 'SiteBulkEditForm', 'SiteGroupBulkEditForm', 'VirtualChassisBulkEditForm', + 'VirtualDeviceContextBulkEditForm' ) @@ -1325,3 +1326,20 @@ 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() + ) + model = VirtualDeviceContext + fieldsets = ( + (None, ('device',)), + ) + nullable_fields = ('device', ) \ No newline at end of file diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 13e788e754a..622aac53810 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -43,6 +43,7 @@ 'SiteCSVForm', 'SiteGroupCSVForm', 'VirtualChassisCSVForm', + 'VirtualDeviceContextCSVForm' ) @@ -1083,3 +1084,25 @@ def __init__(self, data=None, *args, **kwargs): 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 818da83e1af..ff10b8449e3 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -50,6 +50,7 @@ 'SiteFilterForm', 'SiteGroupFilterForm', 'VirtualChassisFilterForm', + 'VirtualDeviceContextFilterForm' ) @@ -372,7 +373,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType fieldsets = ( (None, ('q', 'tag')), - ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), + ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow', 'vdc_type')), ('Images', ('has_front_image', 'has_rear_image')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', @@ -396,6 +397,10 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): choices=add_blank_choice(DeviceAirflowChoices), required=False ) + vdc_type = MultipleChoiceField( + choices=add_blank_choice(VirtualDeviceContextTypeChoices), + required=False + ) has_front_image = forms.NullBooleanField( required=False, label='Has a front image', @@ -728,6 +733,37 @@ class DeviceFilterForm( tag = TagFilterField(model) +class VirtualDeviceContextFilterForm( + TenancyFilterForm, + NetBoxModelFilterSetForm +): + model = VirtualDeviceContext + fieldsets = ( + (None, ('q', 'tag')), + ('Hardware', ('device',)), + ('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 = ( diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 0da2f343022..e7264c62007 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -62,6 +62,7 @@ 'SiteGroupForm', 'VCMemberSelectForm', 'VirtualChassisForm', + 'VirtualDeviceContextForm' ) INTERFACE_MODE_HELP_TEXT = """ @@ -386,7 +387,7 @@ class DeviceTypeForm(NetBoxModelForm): 'manufacturer', 'model', 'slug', 'part_number', 'tags', )), ('Chassis', ( - 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'vdc_type' )), ('Attributes', ('weight', 'weight_unit')), ('Images', ('front_image', 'rear_image')), @@ -396,7 +397,7 @@ class Meta: model = DeviceType fields = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags', + 'vdc_type','vdc_type','weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags', ] widgets = { 'airflow': StaticSelect(), @@ -1374,6 +1375,14 @@ class Meta: class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): + vdc = DynamicModelMultipleChoiceField( + queryset=VirtualDeviceContext.objects.all(), + required=False, + label='Virtual Device Contexts', + query_params={ + 'device_id': '$device', + } + ) parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -1448,7 +1457,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): ) fieldsets = ( - ('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')), + ('Interface', ('device', 'module', 'vdc', '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')), @@ -1462,7 +1471,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): class Meta: model = Interface fields = [ - 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', + 'device', 'module', 'vdc', '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', @@ -1486,6 +1495,13 @@ class Meta: 'rf_channel_width': "Populated by selected channel (if set)", } + def clean_vdc(self): + device = self.cleaned_data.get('device') + if device.device_type.vdc_type not in [VirtualDeviceContextTypeChoices.CISCO_ASA_CONTEXT, VirtualDeviceContextTypeChoices.CISCO_FTD_INSTANCE]\ + and len(self.cleaned_data.get('vdc')) > 1: + raise forms.ValidationError(f"You cannot assign more then 1 VDC for {device.device_type}") + return self.cleaned_data.get('vdc') + class FrontPortForm(ModularDeviceComponentForm): rear_port = DynamicModelChoiceField( @@ -1632,3 +1648,73 @@ class Meta: 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', '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' + ] + help_texts = {} + widgets = { + 'primary_ip4': StaticSelect(), + 'primary_ip6': StaticSelect(), + } \ No newline at end of file diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 59d63ef7ba8..6e46d770762 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 ) + vdc = 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 d4646762f17..e1e4fa1106f 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -34,6 +34,7 @@ 'ModuleType', 'Platform', 'VirtualChassis', + 'VirtualDeviceContext', ) @@ -123,6 +124,12 @@ class DeviceType(NetBoxModel, WeightMixin): help_text='Parent devices house child devices in device bays. Leave blank ' 'if this device type is neither a parent nor a child.' ) + vdc_type = models.CharField( + max_length=50, + blank=True, + choices=VirtualDeviceContextTypeChoices, + verbose_name='VDC Type' + ) airflow = models.CharField( max_length=50, choices=DeviceAirflowChoices, @@ -141,7 +148,7 @@ class DeviceType(NetBoxModel, 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', 'vdc_type' ) class Meta: @@ -1129,3 +1136,72 @@ def delete(self, *args, **kwargs): ) return super().delete(*args, **kwargs) + +class VirtualDeviceContext(NetBoxModel): + 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, + blank=True, + choices=VirtualDeviceContextStatusChoices, + ) + identifier = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MaxValueValidator(255)] + ) + 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', + violation_error_message="A VDC with this identifier already exists on this device." + ), + models.UniqueConstraint( + fields=('device', 'name',), + name='%(app_label)s_%(class)s_name', + violation_error_message="A VDC with this name already exists on this device." + ), + ) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:virtualdevicecontext', kwargs={'pk': self.pk}) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index b990daf1aae..9c5ad89a73f 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -1,10 +1,12 @@ import logging -from django.db.models.signals import post_save, post_delete, pre_delete +from django import forms +from django.db.models.signals import post_save, post_delete, pre_delete, m2m_changed from django.dispatch import receiver -from .choices import CableEndChoices, LinkStatusChoices -from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis +from .choices import CableEndChoices, LinkStatusChoices, VirtualDeviceContextTypeChoices +from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, \ + VirtualChassis, VirtualDeviceContext, Interface from .models.cables import trace_paths from .utils import create_cablepath, rebuild_paths @@ -123,3 +125,15 @@ def nullify_connected_endpoints(instance, **kwargs): for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable): cablepath.retrace() + + +@receiver(m2m_changed, sender=Interface.vdc.through) +def enforce_vdc_type_restrictions(instance, **kwargs): + if 'action' == 'post_add': + device = instance.device + if device.device_type.vdc_type not in [VirtualDeviceContextTypeChoices.CISCO_ASA_CONTEXT, VirtualDeviceContextTypeChoices.CISCO_FTD_INSTANCE] \ + and len(instance.vdc) > 1: + print('Error') + raise forms.ValidationError({ + 'vdc': f"You cannot assign more then 1 VDC for {device.device_type}" + }) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 3b129c963d3..63ae35dbdcd 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -15,6 +15,7 @@ PowerPort, RearPort, VirtualChassis, + VirtualDeviceContext, ) from django_tables2.utils import Accessor from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin @@ -52,6 +53,7 @@ 'PowerPortTable', 'RearPortTable', 'VirtualChassisTable', + 'VirtualDeviceContextTable' ) @@ -896,3 +898,44 @@ class Meta(NetBoxTable.Meta): model = VirtualChassis fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'domain', 'master', 'member_count') + + +class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable): + name = tables.Column( + linkify=True + ) + indentifier = tables.Column() + device = tables.TemplateColumn( + order_by=('_name',), + template_code=DEVICE_LINK + ) + status = tables.Column() + 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 = VirtualDeviceContext + fields = ( + 'pk', 'id', 'name', 'identifier', 'tenant', 'tenant_group', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'identifier', 'tenant', 'primary_ip', + ) \ No newline at end of file diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index ecd2d46c508..0301c5d81dd 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -183,6 +183,20 @@ 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/rename/', views.VirtualDeviceContextBulkRenameView.as_view(), name='virtualdevicecontext_bulk_rename'), + path('vdcs/delete/', views.VirtualDeviceContextBulkDeleteView.as_view(), name='virtualdevicecontext_bulk_delete'), + path('vdcs//', views.VirtualDeviceContextView.as_view(), name='virtualdevicecontext'), + path('vdcs//edit/', views.VirtualDeviceContextEditView.as_view(), name='virtualdevicecontext_edit'), + path('vdcs//delete/', views.VirtualDeviceContextDeleteView.as_view(), name='virtualdevicecontext_delete'), + path('vdcs//interfaces/', views.VirtualDeviceContextInterfacesView.as_view(), name='virtualdevicecontext_interfaces'), + path('vdcs//changelog/', ObjectChangeLogView.as_view(), name='virtualdevicecontext_changelog', kwargs={'model': VirtualDeviceContext}), + path('vdcs//journal/', ObjectJournalView.as_view(), name='virtualdevicecontext_journal', kwargs={'model': 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 13e8354aa75..8f6145ba8a0 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3561,3 +3561,66 @@ 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 + + +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(), + } + + +class VirtualDeviceContextEditView(generic.ObjectEditView): + queryset = VirtualDeviceContext.objects.all() + form = forms.VirtualDeviceContextForm + + +class VirtualDeviceContextDeleteView(generic.ObjectDeleteView): + queryset = VirtualDeviceContext.objects.all() + + +class VirtualDeviceContextBulkImportView(generic.BulkImportView): + queryset = VirtualDeviceContext.objects.all() + model_form = forms.VirtualDeviceContextCSVForm + table = tables.VirtualDeviceContextTable + + +class VirtualDeviceContextBulkRenameView(generic.BulkRenameView): + queryset = VirtualDeviceContext.objects.all() + filterset = filtersets.VirtualDeviceContextFilterSet + 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 + + +class VirtualDeviceContextInterfacesView(DeviceComponentsView): + queryset = VirtualDeviceContext.objects.all() + child_model = Interface + table = tables.DeviceInterfaceTable + filterset = filtersets.InterfaceFilterSet + + def get_children(self, request, parent): + return self.child_model.objects.restrict(request.user, 'view').filter(vdcs=parent) diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 65c2ec7fcef..d94b8f3722f 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -58,6 +58,7 @@ label='Devices', items=( get_model_item('dcim', 'device', 'Devices'), + get_model_item('dcim', 'virtualdevicecontext', 'Virtual Device Contexts'), get_model_item('dcim', 'module', 'Modules'), get_model_item('dcim', 'devicerole', 'Device Roles'), get_model_item('dcim', 'platform', 'Platforms'), diff --git a/netbox/templates/dcim/virtualdevicecontext.html b/netbox/templates/dcim/virtualdevicecontext.html new file mode 100644 index 00000000000..91ecd063b31 --- /dev/null +++ b/netbox/templates/dcim/virtualdevicecontext.html @@ -0,0 +1,75 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block extra_controls %} + {% if perms.dcim.add_interface %} + + Add Interface + + {% endif %} +{% endblock extra_controls %} + +{% 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 %}