From a3543e4c7b3ab87ba3155cf42e1c08df729541d3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 30 Nov 2023 16:02:51 -0600 Subject: [PATCH] Add new VLANDeviceMapping model and appropriate views, filters, forms, api and graphql objects. --- netbox/ipam/api/nested_serializers.py | 9 ++ netbox/ipam/api/serializers.py | 14 +++ netbox/ipam/api/urls.py | 1 + netbox/ipam/api/views.py | 8 ++ netbox/ipam/filtersets.py | 68 +++++++++++++- netbox/ipam/forms/bulk_import.py | 20 ++++ netbox/ipam/forms/filtersets.py | 27 ++++++ netbox/ipam/forms/model_forms.py | 19 ++++ netbox/ipam/graphql/schema.py | 6 ++ netbox/ipam/graphql/types.py | 9 ++ netbox/ipam/models/vlans.py | 57 ++++++++++++ netbox/ipam/tables/vlans.py | 25 +++++ netbox/ipam/tests/test_api.py | 51 +++++++++++ netbox/ipam/tests/test_filtersets.py | 91 +++++++++++++++++++ netbox/ipam/tests/test_views.py | 69 ++++++++++++++ netbox/ipam/urls.py | 7 ++ netbox/ipam/views.py | 38 ++++++++ netbox/netbox/navigation/menu.py | 1 + netbox/templates/ipam/vlandevicemapping.html | 38 ++++++++ netbox/templates/vpn/l2vpntermination.html | 4 - .../templates/vpn/l2vpntermination_edit.html | 9 +- netbox/vpn/constants.py | 3 +- netbox/vpn/forms/bulk_import.py | 23 +++-- netbox/vpn/forms/model_forms.py | 17 +++- netbox/vpn/models/l2vpn.py | 4 +- netbox/vpn/tables/l2vpn.py | 9 +- 26 files changed, 601 insertions(+), 26 deletions(-) create mode 100644 netbox/templates/ipam/vlandevicemapping.html diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 17d8d74a7..6ede8ece8 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -21,6 +21,7 @@ __all__ = [ 'NestedServiceTemplateSerializer', 'NestedVLANGroupSerializer', 'NestedVLANSerializer', + 'NestedVLANDeviceMappingSerializer', 'NestedVRFSerializer', ] @@ -159,6 +160,14 @@ class NestedVLANSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'vid', 'name'] +class NestedVLANDeviceMappingSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlandevicemapping-detail') + + class Meta: + model = models.VLANDeviceMapping + fields = ['id', 'url', 'display', ] + + # # Prefixes # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 33aa55a93..cfee23639 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -257,6 +257,20 @@ class VLANSerializer(NetBoxModelSerializer): ] +class VLANDeviceMappingSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlandevicemapping-detail') + device = NestedDeviceSerializer(required=True) + vlan = NestedVLANSerializer(required=True) + l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True) + + class Meta: + model = VLANDeviceMapping + fields = [ + 'id', 'url', 'display', 'device', 'vlan', 'l2vpn_termination', 'description', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + + class AvailableVLANSerializer(serializers.Serializer): """ Representation of a VLAN which does not exist in the database. diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index bae9d8048..d008b820d 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -21,6 +21,7 @@ router.register('fhrp-groups', views.FHRPGroupViewSet) router.register('fhrp-group-assignments', views.FHRPGroupAssignmentViewSet) router.register('vlan-groups', views.VLANGroupViewSet) router.register('vlans', views.VLANViewSet) +router.register('vlandevicemappings', views.VLANDeviceMappingViewSet) router.register('service-templates', views.ServiceTemplateViewSet) router.register('services', views.ServiceViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 688fe42e2..38206a020 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -163,6 +163,14 @@ class VLANViewSet(NetBoxModelViewSet): filterset_class = filtersets.VLANFilterSet +class VLANDeviceMappingViewSet(NetBoxModelViewSet): + queryset = VLANDeviceMapping.objects.prefetch_related( + 'device', 'vlan', 'tags' + ) + serializer_class = serializers.VLANDeviceMappingSerializer + filterset_class = filtersets.VLANDeviceMappingFilterSet + + class ServiceTemplateViewSet(NetBoxModelViewSet): queryset = ServiceTemplate.objects.prefetch_related('tags') serializer_class = serializers.ServiceTemplateSerializer diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 08d22dd23..7c95513f3 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -15,7 +15,7 @@ from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import VirtualMachine, VMInterface -from vpn.models import L2VPN +from vpn.models import L2VPN, L2VPNTermination from .choices import * from .models import * @@ -35,6 +35,7 @@ __all__ = ( 'ServiceFilterSet', 'ServiceTemplateFilterSet', 'VLANFilterSet', + 'VLANDeviceMappingFilterSet', 'VLANGroupFilterSet', 'VRFFilterSet', ) @@ -992,6 +993,71 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): return queryset.get_for_virtualmachine(value) +class VLANDeviceMappingFilterSet(NetBoxModelFilterSet): + device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label=_('Device (ID)'), + ) + device = django_filters.ModelMultipleChoiceFilter( + field_name='device__name', + queryset=Device.objects.all(), + to_field_name='name', + label=_('Device (name)'), + ) + vlan_id = django_filters.ModelMultipleChoiceFilter( + queryset=VLAN.objects.all(), + label=_('VLAN (ID)'), + ) + vlan_vid = django_filters.ModelMultipleChoiceFilter( + field_name='vlan__vid', + queryset=VLAN.objects.all(), + to_field_name='vid', + label=_('VLAN (vid)'), + ) + vlan = django_filters.ModelMultipleChoiceFilter( + field_name='vlan__name', + queryset=VLAN.objects.all(), + to_field_name='name', + label=_('VLAN (name)'), + ) + l2vpn_id = django_filters.ModelMultipleChoiceFilter( + field_name='l2vpn_terminations__l2vpn', + queryset=L2VPN.objects.all(), + label=_('L2VPN (ID)'), + ) + l2vpn_identifier = django_filters.ModelMultipleChoiceFilter( + field_name='l2vpn_terminations__l2vpn__identifier', + queryset=L2VPN.objects.all(), + to_field_name='identifier', + label=_('L2VPN'), + ) + l2vpn = django_filters.ModelMultipleChoiceFilter( + field_name='l2vpn_terminations__l2vpn__name', + queryset=L2VPN.objects.all(), + to_field_name='name', + label=_('L2VPN'), + ) + l2vpn_termination_id = django_filters.ModelMultipleChoiceFilter( + field_name='l2vpn_terminations', + queryset=L2VPNTermination.objects.all(), + label=_('L2VPN Termination (ID)'), + ) + + class Meta: + model = VLANDeviceMapping + fields = ['id', 'description'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(device__name__icontains=value) | Q(vlan__name__icontains=value) + try: + qs_filter |= Q(vlan__vid=int(value.strip())) + except ValueError: + pass + return queryset.filter(qs_filter) + + class ServiceTemplateFilterSet(NetBoxModelFilterSet): port = NumericArrayFilter( field_name='ports', diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 0627a6765..c9f272a19 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -27,6 +27,7 @@ __all__ = ( 'ServiceImportForm', 'ServiceTemplateImportForm', 'VLANImportForm', + 'VLANDeviceMappingImportForm', 'VLANGroupImportForm', 'VRFImportForm', ) @@ -472,6 +473,25 @@ class VLANImportForm(NetBoxModelImportForm): fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments', 'tags') +class VLANDeviceMappingImportForm(NetBoxModelImportForm): + device = CSVModelChoiceField( + label=_('Device'), + queryset=Device.objects.all(), + to_field_name='name', + help_text=_('Assigned device') + ) + vlan = CSVModelChoiceField( + label=_('VLAN'), + queryset=VLANGroup.objects.all(), + to_field_name='name', + help_text=_('Assigned VLAN') + ) + + class Meta: + model = VLANDeviceMapping + fields = ('device', 'vlan', 'description', 'comments', 'tags') + + class ServiceTemplateImportForm(NetBoxModelImportForm): protocol = CSVChoiceField( label=_('Protocol'), diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index c7dad372d..00e152b6f 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -26,6 +26,7 @@ __all__ = ( 'ServiceFilterForm', 'ServiceTemplateFilterForm', 'VLANFilterForm', + 'VLANDeviceMappingFilterForm', 'VLANGroupFilterForm', 'VRFFilterForm', ) @@ -499,6 +500,32 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): tag = TagFilterField(model) +class VLANDeviceMappingFilterForm(NetBoxModelFilterSetForm): + model = VLANDeviceMapping + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Device'), ('device_id', )), + (_('VLAN'), ('vlan_id', )), + (_('L2VPN'), ('l2vpn_id', )), + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Device') + ) + vlan_id = DynamicModelMultipleChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label=_('VLAN') + ) + l2vpn_id = DynamicModelMultipleChoiceField( + queryset=L2VPN.objects.all(), + required=False, + label=_('L2VPN') + ) + tag = TagFilterField(model) + + class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): model = ServiceTemplate fieldsets = ( diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 6c445ef27..252e51a6f 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -37,6 +37,7 @@ __all__ = ( 'ServiceCreateForm', 'ServiceTemplateForm', 'VLANForm', + 'VLANDeviceMappingForm', 'VLANGroupForm', 'VRFForm', ) @@ -658,6 +659,24 @@ class VLANForm(TenancyForm, NetBoxModelForm): ] +class VLANDeviceMappingForm(NetBoxModelForm): + vlan = DynamicModelChoiceField( + label=_('VLAN'), + queryset=VLAN.objects.all() + ) + device = DynamicModelChoiceField( + label=_('Device'), + queryset=Device.objects.all() + ) + comments = CommentField() + + class Meta: + model = VLANDeviceMapping + fields = [ + 'device', 'vlan', 'description', 'comments', 'tags', + ] + + class ServiceTemplateForm(NetBoxModelForm): ports = NumericArrayField( label=_('Ports'), diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 6627c540e..35e3dea37 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -97,6 +97,12 @@ class IPAMQuery(graphene.ObjectType): def resolve_vlan_group_list(root, info, **kwargs): return gql_query_optimizer(models.VLANGroup.objects.all(), info) + vlan_device_mapping = ObjectField(VLANDeviceMappingType) + vlan_device_mapping_list = ObjectListField(VLANDeviceMappingType) + + def resolve_vlan_device_mapping_list(root, info, **kwargs): + return gql_query_optimizer(models.VLANDeviceMapping.objects.all(), info) + vrf = ObjectField(VRFType) vrf_list = ObjectListField(VRFType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index b4350f9f2..887147aae 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -20,6 +20,7 @@ __all__ = ( 'ServiceTemplateType', 'VLANType', 'VLANGroupType', + 'VLANDeviceMappingType', 'VRFType', ) @@ -179,6 +180,14 @@ class VLANGroupType(OrganizationalObjectType): filterset_class = filtersets.VLANGroupFilterSet +class VLANDeviceMappingType(NetBoxObjectType): + + class Meta: + model = models.VLANDeviceMapping + fields = '__all__' + filterset_class = filtersets.VLANDeviceMappingFilterSet + + class VRFType(NetBoxObjectType): class Meta: diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 1327a6e9d..2cc8454ce 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -15,6 +15,7 @@ from virtualization.models import VMInterface __all__ = ( 'VLAN', 'VLANGroup', + 'VLANDeviceMapping', ) @@ -256,3 +257,59 @@ class VLAN(PrimaryModel): @property def l2vpn_termination(self): return self.l2vpn_terminations.first() + + +class VLANDeviceMapping(PrimaryModel): + """ + A VLAN to device termination is a unique VLAN to device mapping. Mainly used as an additional termination point for + the L2VPN model. + + Each termination must have both a unique VLAN and device + """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='devices', + help_text=_("Device") + ) + vlan = models.ForeignKey( + to='ipam.VLAN', + on_delete=models.CASCADE, + related_name='vlans', + help_text=_("VLAN") + ) + + l2vpn_terminations = GenericRelation( + to='vpn.L2VPNTermination', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='vlandevicemapping' + ) + + clone_fields = [ + 'device', 'vlan', + ] + + class Meta: + ordering = ('device', 'vlan', 'pk') + constraints = ( + models.UniqueConstraint( + fields=('device', 'vlan'), + name='%(app_label)s_%(class)s_unique_device_vlan' + ), + ) + verbose_name = _('VLAN Device Mapping') + verbose_name_plural = _('VLAN Device Mapping') + + def __str__(self): + return f'{self.device} ({self.vlan.name if self.vlan.name else self.vlan.vid})' + + def get_absolute_url(self): + return reverse('ipam:vlandevicemapping', args=[self.pk]) + + def clean(self): + super().clean() + + @property + def l2vpn_termination(self): + return self.l2vpn_terminations.first() diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index aee91e7d8..fc2394271 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -15,6 +15,7 @@ __all__ = ( 'VLANGroupTable', 'VLANMembersTable', 'VLANTable', + 'VLANDeviceMappingTable', 'VLANVirtualMachinesTable', ) @@ -156,6 +157,30 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable): } +class VLANDeviceMappingTable(NetBoxTable): + device = tables.Column( + verbose_name=_('Device'), + linkify=True + ) + vlan = tables.Column( + verbose_name=_('VLAN'), + linkify=True + ) + + l2vpn_termination = tables.Column( + verbose_name=_('L2VPN'), + linkify=True + ) + + class Meta(NetBoxTable.Meta): + model = VLANDeviceMapping + fields = ( + 'pk', 'id', 'device', 'vlan', 'l2vpn_termination', 'description', 'comments', 'tags', 'created', + 'last_updated', + ) + default_columns = ('pk', 'id', 'device', 'vlan', 'description') + + class VLANMembersTable(NetBoxTable): """ Base table for Interface and VMInterface assignments diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index cb633e162..5230ba291 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -1018,6 +1018,57 @@ class VLANTest(APIViewTestCases.APIViewTestCase): self.assertTrue(content['detail'].startswith('Unable to delete object.')) +class VLANDeviceMappingTest(APIViewTestCases.APIViewTestCase): + model = VLANDeviceMapping + brief_fields = ['display', 'id', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') + role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + devices = ( + Device(name='Device 1', site=site, device_type=devicetype, role=role), + Device(name='Device 2', site=site, device_type=devicetype, role=role), + Device(name='Device 3', site=site, device_type=devicetype, role=role), + ) + Device.objects.bulk_create(devices) + + vlans = ( + VLAN(name='VLAN 1', vid=1), + VLAN(name='VLAN 2', vid=2), + VLAN(name='VLAN 3', vid=2), + ) + VLAN.objects.bulk_create(vlans) + + vlandevicemappings = ( + VLANDeviceMapping(device=devices[0], vlan=vlans[0]), + VLANDeviceMapping(device=devices[1], vlan=vlans[0]), + VLANDeviceMapping(device=devices[2], vlan=vlans[0]), + ) + VLANDeviceMapping.objects.bulk_create(vlandevicemappings) + + cls.create_data = [ + { + 'device': devices[0].pk, + 'vlan': vlans[2].pk, + }, + { + 'device': devices[1].pk, + 'vlan': vlans[2].pk, + }, + { + 'device': devices[2].pk, + 'vlan': vlans[2].pk, + }, + ] + + class ServiceTemplateTest(APIViewTestCases.APIViewTestCase): model = ServiceTemplate brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url'] diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 07f3e637f..ac02498a9 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -10,6 +10,7 @@ from ipam.models import * from tenancy.models import Tenant, TenantGroup from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from vpn.models import L2VPN, L2VPNTermination class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests): @@ -1496,6 +1497,96 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global +class VLANDeviceMappingTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = VLANDeviceMapping.objects.all() + filterset = VLANDeviceMappingFilterSet + + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') + role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + devices = ( + Device(name='Device 1', site=site, device_type=devicetype, role=role), + Device(name='Device 2', site=site, device_type=devicetype, role=role), + Device(name='Device 3', site=site, device_type=devicetype, role=role), + ) + Device.objects.bulk_create(devices) + + vlans = ( + VLAN(name='VLAN 1', vid=1), + VLAN(name='VLAN 2', vid=2), + VLAN(name='VLAN 3', vid=2), + ) + VLAN.objects.bulk_create(vlans) + + vlandevicemappings = ( + VLANDeviceMapping(device=devices[0], vlan=vlans[0]), + VLANDeviceMapping(device=devices[1], vlan=vlans[0]), + VLANDeviceMapping(device=devices[2], vlan=vlans[0]), + VLANDeviceMapping(device=devices[0], vlan=vlans[1]), + VLANDeviceMapping(device=devices[1], vlan=vlans[1]), + VLANDeviceMapping(device=devices[2], vlan=vlans[1]), + VLANDeviceMapping(device=devices[0], vlan=vlans[2]), + VLANDeviceMapping(device=devices[1], vlan=vlans[2]), + VLANDeviceMapping(device=devices[2], vlan=vlans[2]), + ) + VLANDeviceMapping.objects.bulk_create(vlandevicemappings) + + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan'), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vxlan'), + ) + L2VPN.objects.bulk_create(l2vpns) + + L2VPNTermination.objects.bulk_create(( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlandevicemappings[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlandevicemappings[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlandevicemappings[2]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlandevicemappings[3]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlandevicemappings[4]), + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlandevicemappings[5]), + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlandevicemappings[6]), + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlandevicemappings[7]), + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlandevicemappings[8]),) + ) + + def test_device(self): + devices = Device.objects.all() + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'device': [devices[0].name, devices[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + + def test_vlan(self): + vlans = VLAN.objects.all() + params = {'vlan_id': [vlans[0].pk, vlans[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'vlan_vid': [vlans[0].vid, vlans[1].vid]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'vlan': [vlans[0].name, vlans[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + + def test_l2vpn(self): + l2vpns = L2VPN.objects.all() + params = {'l2vpn_id': [l2vpns[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'l2vpn': [l2vpns[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_device_vlan(self): + vlan = VLAN.objects.first() + devices = Device.objects.all() + params = {'vlan_id': [vlan.pk], 'device_id': [devices[1].pk, devices[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'vlan_vid': [vlan.vid], 'device_id': [devices[1].pk, devices[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'vlan': [vlan.name], 'device': [devices[1].name, devices[2].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ServiceTemplate.objects.all() filterset = ServiceTemplateFilterSet diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index bc42341ba..09559df73 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -858,6 +858,75 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class VLANDeviceMappingTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = VLANDeviceMapping + + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') + role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + devices = ( + Device(name='Device 1', site=site, device_type=devicetype, role=role), + Device(name='Device 2', site=site, device_type=devicetype, role=role), + Device(name='Device 3', site=site, device_type=devicetype, role=role), + ) + Device.objects.bulk_create(devices) + + vlans = ( + VLAN(name='VLAN 1', vid=1), + VLAN(name='VLAN 2', vid=2), + VLAN(name='VLAN 3', vid=2), + ) + VLAN.objects.bulk_create(vlans) + + vlandevicemappings = ( + VLANDeviceMapping(device=devices[0], vlan=vlans[0]), + VLANDeviceMapping(device=devices[1], vlan=vlans[0]), + VLANDeviceMapping(device=devices[2], vlan=vlans[0]), + VLANDeviceMapping(device=devices[0], vlan=vlans[1]), + VLANDeviceMapping(device=devices[1], vlan=vlans[1]), + VLANDeviceMapping(device=devices[2], vlan=vlans[1]), + VLANDeviceMapping(device=devices[0], vlan=vlans[2]), + VLANDeviceMapping(device=devices[1], vlan=vlans[2]), + VLANDeviceMapping(device=devices[2], vlan=vlans[2]), + ) + VLANDeviceMapping.objects.bulk_create(vlandevicemappings) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'device': devices[0].pk, + 'vlan': vlans[2].pk, + 'description': 'A new VLAN', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "device,vlan", + f"{devices[1].name},{vlans[2].name}", + f"{devices[2].name},{vlans[2].name}", + ) + + cls.csv_update_data = ( + "id,name,description", + f"{vlans[0].pk},VLAN107,New description 7", + f"{vlans[1].pk},VLAN108,New description 8", + f"{vlans[2].pk},VLAN109,New description 9", + ) + + cls.bulk_edit_data = { + 'site': sites[1].pk, + 'group': vlangroups[1].pk, + 'tenant': None, + 'status': VLANStatusChoices.STATUS_RESERVED, + 'role': roles[1].pk, + 'description': 'New description', + } + + class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ServiceTemplate diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 61deeff4b..c8419a343 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -116,6 +116,13 @@ urlpatterns = [ path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), path('vlans//', include(get_model_urls('ipam', 'vlan'))), + # VLANs + path('vlans-device-mappings/', views.VLANDeviceMappingListView.as_view(), name='vlandevicemapping_list'), + path('vlans-device-mappings/add/', views.VLANDeviceMappingEditView.as_view(), name='vlandevicemapping_add'), + path('vlans-device-mappings/import/', views.VLANDeviceMappingBulkImportView.as_view(), name='vlandevicemapping_import'), + path('vlans-device-mappings/delete/', views.VLANDeviceMappingBulkDeleteView.as_view(), name='vlandevicemapping_bulk_delete'), + path('vlans-device-mappings//', include(get_model_urls('ipam', 'vlandevicemapping'))), + # Service templates path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'), path('service-templates/add/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_add'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 5c1ac6620..4ff2e0298 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1146,6 +1146,44 @@ class VLANBulkDeleteView(generic.BulkDeleteView): table = tables.VLANTable +# +# VLAN Device Terminations +# + +class VLANDeviceMappingListView(generic.ObjectListView): + queryset = VLANDeviceMapping.objects.all() + filterset = filtersets.VLANDeviceMappingFilterSet + filterset_form = forms.VLANDeviceMappingFilterForm + table = tables.VLANDeviceMappingTable + + +@register_model_view(VLANDeviceMapping) +class VLANDeviceMappingView(generic.ObjectView): + queryset = VLANDeviceMapping.objects.all() + + +@register_model_view(VLANDeviceMapping, 'edit') +class VLANDeviceMappingEditView(generic.ObjectEditView): + queryset = VLANDeviceMapping.objects.all() + form = forms.VLANDeviceMappingForm + + +@register_model_view(VLANDeviceMapping, 'delete') +class VLANDeviceMappingDeleteView(generic.ObjectDeleteView): + queryset = VLANDeviceMapping.objects.all() + + +class VLANDeviceMappingBulkImportView(generic.BulkImportView): + queryset = VLANDeviceMapping.objects.all() + model_form = forms.VLANDeviceMappingImportForm + + +class VLANDeviceMappingBulkDeleteView(generic.BulkDeleteView): + queryset = VLANDeviceMapping.objects.all() + filterset = filtersets.VLANDeviceMappingFilterSet + table = tables.VLANDeviceMappingTable + + # # Service templates # diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 49aee3540..e5f2ec4fa 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -182,6 +182,7 @@ IPAM_MENU = Menu( items=( get_model_item('ipam', 'vlan', _('VLANs')), get_model_item('ipam', 'vlangroup', _('VLAN Groups')), + get_model_item('ipam', 'vlandevicemapping', _('VLAN Device Mappings')), ), ), MenuGroup( diff --git a/netbox/templates/ipam/vlandevicemapping.html b/netbox/templates/ipam/vlandevicemapping.html new file mode 100644 index 000000000..65ecfad78 --- /dev/null +++ b/netbox/templates/ipam/vlandevicemapping.html @@ -0,0 +1,38 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "VLAN Mapping" %}
+
+ + + + + + + + + + + + + +
{% trans "Device" %}{{ object.device|linkify }}
{% trans "VLAN" %}{{ object.vlan|linkify }}
{% trans "L2VPN" %}{{ object.l2vpn_terminationn|linkify|placeholder }}
+
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/panels/related_objects.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/vpn/l2vpntermination.html b/netbox/templates/vpn/l2vpntermination.html index dd10b9af3..0e7539481 100644 --- a/netbox/templates/vpn/l2vpntermination.html +++ b/netbox/templates/vpn/l2vpntermination.html @@ -15,10 +15,6 @@ {% trans "L2VPN" %} {{ object.l2vpn|linkify }} - - {% trans "Device" %} - {{ object.device|linkify }} - {% trans "Assigned Object" %} {{ object.assigned_object|linkify }} diff --git a/netbox/templates/vpn/l2vpntermination_edit.html b/netbox/templates/vpn/l2vpntermination_edit.html index be9a67368..01ea19204 100644 --- a/netbox/templates/vpn/l2vpntermination_edit.html +++ b/netbox/templates/vpn/l2vpntermination_edit.html @@ -17,6 +17,11 @@ {% trans "VLAN" %} +