Add new VLANDeviceMapping model and appropriate views, filters, forms, api and graphql objects.

This commit is contained in:
Daniel Sheppard 2023-11-30 16:02:51 -06:00
parent 21ca8d7d6d
commit a3543e4c7b
26 changed files with 601 additions and 26 deletions

View File

@ -21,6 +21,7 @@ __all__ = [
'NestedServiceTemplateSerializer', 'NestedServiceTemplateSerializer',
'NestedVLANGroupSerializer', 'NestedVLANGroupSerializer',
'NestedVLANSerializer', 'NestedVLANSerializer',
'NestedVLANDeviceMappingSerializer',
'NestedVRFSerializer', 'NestedVRFSerializer',
] ]
@ -159,6 +160,14 @@ class NestedVLANSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'vid', 'name'] 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 # Prefixes
# #

View File

@ -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): class AvailableVLANSerializer(serializers.Serializer):
""" """
Representation of a VLAN which does not exist in the database. Representation of a VLAN which does not exist in the database.

View File

@ -21,6 +21,7 @@ router.register('fhrp-groups', views.FHRPGroupViewSet)
router.register('fhrp-group-assignments', views.FHRPGroupAssignmentViewSet) router.register('fhrp-group-assignments', views.FHRPGroupAssignmentViewSet)
router.register('vlan-groups', views.VLANGroupViewSet) router.register('vlan-groups', views.VLANGroupViewSet)
router.register('vlans', views.VLANViewSet) router.register('vlans', views.VLANViewSet)
router.register('vlandevicemappings', views.VLANDeviceMappingViewSet)
router.register('service-templates', views.ServiceTemplateViewSet) router.register('service-templates', views.ServiceTemplateViewSet)
router.register('services', views.ServiceViewSet) router.register('services', views.ServiceViewSet)

View File

@ -163,6 +163,14 @@ class VLANViewSet(NetBoxModelViewSet):
filterset_class = filtersets.VLANFilterSet 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): class ServiceTemplateViewSet(NetBoxModelViewSet):
queryset = ServiceTemplate.objects.prefetch_related('tags') queryset = ServiceTemplate.objects.prefetch_related('tags')
serializer_class = serializers.ServiceTemplateSerializer serializer_class = serializers.ServiceTemplateSerializer

View File

@ -15,7 +15,7 @@ from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
) )
from virtualization.models import VirtualMachine, VMInterface from virtualization.models import VirtualMachine, VMInterface
from vpn.models import L2VPN from vpn.models import L2VPN, L2VPNTermination
from .choices import * from .choices import *
from .models import * from .models import *
@ -35,6 +35,7 @@ __all__ = (
'ServiceFilterSet', 'ServiceFilterSet',
'ServiceTemplateFilterSet', 'ServiceTemplateFilterSet',
'VLANFilterSet', 'VLANFilterSet',
'VLANDeviceMappingFilterSet',
'VLANGroupFilterSet', 'VLANGroupFilterSet',
'VRFFilterSet', 'VRFFilterSet',
) )
@ -992,6 +993,71 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
return queryset.get_for_virtualmachine(value) 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): class ServiceTemplateFilterSet(NetBoxModelFilterSet):
port = NumericArrayFilter( port = NumericArrayFilter(
field_name='ports', field_name='ports',

View File

@ -27,6 +27,7 @@ __all__ = (
'ServiceImportForm', 'ServiceImportForm',
'ServiceTemplateImportForm', 'ServiceTemplateImportForm',
'VLANImportForm', 'VLANImportForm',
'VLANDeviceMappingImportForm',
'VLANGroupImportForm', 'VLANGroupImportForm',
'VRFImportForm', 'VRFImportForm',
) )
@ -472,6 +473,25 @@ class VLANImportForm(NetBoxModelImportForm):
fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments', 'tags') 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): class ServiceTemplateImportForm(NetBoxModelImportForm):
protocol = CSVChoiceField( protocol = CSVChoiceField(
label=_('Protocol'), label=_('Protocol'),

View File

@ -26,6 +26,7 @@ __all__ = (
'ServiceFilterForm', 'ServiceFilterForm',
'ServiceTemplateFilterForm', 'ServiceTemplateFilterForm',
'VLANFilterForm', 'VLANFilterForm',
'VLANDeviceMappingFilterForm',
'VLANGroupFilterForm', 'VLANGroupFilterForm',
'VRFFilterForm', 'VRFFilterForm',
) )
@ -499,6 +500,32 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
tag = TagFilterField(model) 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): class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
model = ServiceTemplate model = ServiceTemplate
fieldsets = ( fieldsets = (

View File

@ -37,6 +37,7 @@ __all__ = (
'ServiceCreateForm', 'ServiceCreateForm',
'ServiceTemplateForm', 'ServiceTemplateForm',
'VLANForm', 'VLANForm',
'VLANDeviceMappingForm',
'VLANGroupForm', 'VLANGroupForm',
'VRFForm', '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): class ServiceTemplateForm(NetBoxModelForm):
ports = NumericArrayField( ports = NumericArrayField(
label=_('Ports'), label=_('Ports'),

View File

@ -97,6 +97,12 @@ class IPAMQuery(graphene.ObjectType):
def resolve_vlan_group_list(root, info, **kwargs): def resolve_vlan_group_list(root, info, **kwargs):
return gql_query_optimizer(models.VLANGroup.objects.all(), info) 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 = ObjectField(VRFType)
vrf_list = ObjectListField(VRFType) vrf_list = ObjectListField(VRFType)

View File

@ -20,6 +20,7 @@ __all__ = (
'ServiceTemplateType', 'ServiceTemplateType',
'VLANType', 'VLANType',
'VLANGroupType', 'VLANGroupType',
'VLANDeviceMappingType',
'VRFType', 'VRFType',
) )
@ -179,6 +180,14 @@ class VLANGroupType(OrganizationalObjectType):
filterset_class = filtersets.VLANGroupFilterSet filterset_class = filtersets.VLANGroupFilterSet
class VLANDeviceMappingType(NetBoxObjectType):
class Meta:
model = models.VLANDeviceMapping
fields = '__all__'
filterset_class = filtersets.VLANDeviceMappingFilterSet
class VRFType(NetBoxObjectType): class VRFType(NetBoxObjectType):
class Meta: class Meta:

View File

@ -15,6 +15,7 @@ from virtualization.models import VMInterface
__all__ = ( __all__ = (
'VLAN', 'VLAN',
'VLANGroup', 'VLANGroup',
'VLANDeviceMapping',
) )
@ -256,3 +257,59 @@ class VLAN(PrimaryModel):
@property @property
def l2vpn_termination(self): def l2vpn_termination(self):
return self.l2vpn_terminations.first() 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()

View File

@ -15,6 +15,7 @@ __all__ = (
'VLANGroupTable', 'VLANGroupTable',
'VLANMembersTable', 'VLANMembersTable',
'VLANTable', 'VLANTable',
'VLANDeviceMappingTable',
'VLANVirtualMachinesTable', '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): class VLANMembersTable(NetBoxTable):
""" """
Base table for Interface and VMInterface assignments Base table for Interface and VMInterface assignments

View File

@ -1018,6 +1018,57 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
self.assertTrue(content['detail'].startswith('Unable to delete object.')) 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): class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
model = ServiceTemplate model = ServiceTemplate
brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url'] brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url']

View File

@ -10,6 +10,7 @@ from ipam.models import *
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
from vpn.models import L2VPN, L2VPNTermination
class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests): 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 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): class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ServiceTemplate.objects.all() queryset = ServiceTemplate.objects.all()
filterset = ServiceTemplateFilterSet filterset = ServiceTemplateFilterSet

View File

@ -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): class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ServiceTemplate model = ServiceTemplate

View File

@ -116,6 +116,13 @@ urlpatterns = [
path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
path('vlans/<int:pk>/', include(get_model_urls('ipam', 'vlan'))), path('vlans/<int:pk>/', 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/<int:pk>/', include(get_model_urls('ipam', 'vlandevicemapping'))),
# Service templates # Service templates
path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'), path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'),
path('service-templates/add/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_add'), path('service-templates/add/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_add'),

View File

@ -1146,6 +1146,44 @@ class VLANBulkDeleteView(generic.BulkDeleteView):
table = tables.VLANTable 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 # Service templates
# #

View File

@ -182,6 +182,7 @@ IPAM_MENU = Menu(
items=( items=(
get_model_item('ipam', 'vlan', _('VLANs')), get_model_item('ipam', 'vlan', _('VLANs')),
get_model_item('ipam', 'vlangroup', _('VLAN Groups')), get_model_item('ipam', 'vlangroup', _('VLAN Groups')),
get_model_item('ipam', 'vlandevicemapping', _('VLAN Device Mappings')),
), ),
), ),
MenuGroup( MenuGroup(

View File

@ -0,0 +1,38 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "VLAN Mapping" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "VLAN" %}</th>
<td>{{ object.vlan|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "L2VPN" %}</th>
<td>{{ object.l2vpn_terminationn|linkify|placeholder }}</td>
</tr>
</table>
</div>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
{% endblock %}

View File

@ -15,10 +15,6 @@
<th scope="row">{% trans "L2VPN" %}</th> <th scope="row">{% trans "L2VPN" %}</th>
<td>{{ object.l2vpn|linkify }}</td> <td>{{ object.l2vpn|linkify }}</td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.device|linkify }}</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "Assigned Object" %}</th> <th scope="row">{% trans "Assigned Object" %}</th>
<td>{{ object.assigned_object|linkify }}</td> <td>{{ object.assigned_object|linkify }}</td>

View File

@ -17,6 +17,11 @@
{% trans "VLAN" %} {% trans "VLAN" %}
</button> </button>
</li> </li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="vlandevicemapping_tab" data-bs-toggle="tab" aria-controls="vlandevicemapping" data-bs-target="#vlandevicemapping" class="nav-link {% if form.initial.vlandevicemapping %}active{% endif %}">
{% trans "VLAN Device Mapping" %}
</button>
</li>
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<button role="tab" type="button" id="interface_tab" data-bs-toggle="tab" aria-controls="interface" data-bs-target="#interface" class="nav-link {% if form.initial.interface %}active{% endif %}"> <button role="tab" type="button" id="interface_tab" data-bs-toggle="tab" aria-controls="interface" data-bs-target="#interface" class="nav-link {% if form.initial.interface %}active{% endif %}">
{% trans "Device" %} {% trans "Device" %}
@ -33,9 +38,11 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="tab-content p-0 border-0"> <div class="tab-content p-0 border-0">
<div class="tab-pane {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab"> <div class="tab-pane {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab">
{% render_field form.device %}
{% render_field form.vlan %} {% render_field form.vlan %}
</div> </div>
<div class="tab-pane {% if form.initial.vlandevicemapping %}active{% endif %}" id="vlandevicemapping" role="tabpanel" aria-labeled-by="vlandevicemapping_tab">
{% render_field form.vlandevicemapping %}
</div>
<div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab"> <div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">
{% render_field form.interface %} {% render_field form.interface %}
</div> </div>

View File

@ -3,5 +3,6 @@ from django.db.models import Q
L2VPN_ASSIGNMENT_MODELS = Q( L2VPN_ASSIGNMENT_MODELS = Q(
Q(app_label='dcim', model='interface') | Q(app_label='dcim', model='interface') |
Q(app_label='ipam', model='vlan') | Q(app_label='ipam', model='vlan') |
Q(app_label='virtualization', model='vminterface') Q(app_label='virtualization', model='vminterface') |
Q(app_label='ipam', model='vlandevicemapping')
) )

View File

@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.models import Device, Interface from dcim.models import Device, Interface
from ipam.models import IPAddress, VLAN from ipam.models import IPAddress, VLAN, VLANDeviceMapping
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField
@ -264,7 +264,7 @@ class L2VPNTerminationImportForm(NetBoxModelImportForm):
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Parent device (for interface)') help_text=_('Parent device (for interface or Device-VLAN)')
) )
virtual_machine = CSVModelChoiceField( virtual_machine = CSVModelChoiceField(
label=_('Virtual machine'), label=_('Virtual machine'),
@ -310,13 +310,24 @@ class L2VPNTerminationImportForm(NetBoxModelImportForm):
def clean(self): def clean(self):
super().clean() super().clean()
if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'): device = self.cleaned_data.get('device')
vm = self.cleaned_data.get('virtual_machine')
interface = self.cleaned_data.get('interface')
vlan = self.cleaned_data.get('vlan')
if device and vm:
raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.')) raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.'))
if not self.instance and not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')): if not self.instance and not (interface or vlan):
raise ValidationError(_('Each termination must specify either an interface or a VLAN.')) raise ValidationError(_('Each termination must specify either an interface or a VLAN.'))
if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'): if interface and vlan:
raise ValidationError(_('Cannot assign both an interface and a VLAN.')) raise ValidationError(_('Cannot assign both an interface and a VLAN.'))
# if this is an update we might not have interface or vlan in the form data # if this is an update we might not have interface or vlan in the form data
if self.cleaned_data.get('interface') or self.cleaned_data.get('vlan'): if vlan and device:
try:
vlandevicemapping = VLANDeviceMapping.objects.get(device=device, vlan=vlan)
except VLANDeviceMapping.DoesNotExist:
raise ValidationError(_('Cannot find Device VLAN Mapping'))
self.instance.assigned_object = vlandevicemapping
elif interface or vlan:
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan') self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')

View File

@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.models import Device, Interface from dcim.models import Device, Interface
from ipam.models import IPAddress, RouteTarget, VLAN from ipam.models import IPAddress, RouteTarget, VLAN, VLANDeviceMapping
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
@ -406,6 +406,12 @@ class L2VPNTerminationForm(NetBoxModelForm):
selector=True, selector=True,
label=_('VLAN') label=_('VLAN')
) )
vlandevicemapping = DynamicModelChoiceField(
queryset=VLANDeviceMapping.objects.all(),
required=False,
selector=True,
label=_('VLAN Device Mapping')
)
interface = DynamicModelChoiceField( interface = DynamicModelChoiceField(
label=_('Interface'), label=_('Interface'),
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
@ -432,6 +438,8 @@ class L2VPNTerminationForm(NetBoxModelForm):
initial['interface'] = instance.assigned_object initial['interface'] = instance.assigned_object
elif type(instance.assigned_object) is VLAN: elif type(instance.assigned_object) is VLAN:
initial['vlan'] = instance.assigned_object initial['vlan'] = instance.assigned_object
elif type(instance.assigned_object) is VLANDeviceMapping:
initial['vlandevicemapping'] = instance.assigned_object
elif type(instance.assigned_object) is VMInterface: elif type(instance.assigned_object) is VMInterface:
initial['vminterface'] = instance.assigned_object initial['vminterface'] = instance.assigned_object
kwargs['initial'] = initial kwargs['initial'] = initial
@ -444,10 +452,11 @@ class L2VPNTerminationForm(NetBoxModelForm):
interface = self.cleaned_data.get('interface') interface = self.cleaned_data.get('interface')
vminterface = self.cleaned_data.get('vminterface') vminterface = self.cleaned_data.get('vminterface')
vlan = self.cleaned_data.get('vlan') vlan = self.cleaned_data.get('vlan')
vlandevicemapping = self.cleaned_data.get('vlandevicemapping')
if not (interface or vminterface or vlan): if not (interface or vminterface or vlan or vlandevicemapping):
raise ValidationError(_('A termination must specify an interface or VLAN.')) raise ValidationError(_('A termination must specify an interface or VLAN.'))
if len([x for x in (interface, vminterface, vlan) if x]) > 1: if len([x for x in (interface, vminterface, vlan, vlandevicemapping) if x]) > 1:
raise ValidationError(_('A termination can only have one terminating object (an interface or VLAN).')) raise ValidationError(_('A termination can only have one terminating object (an interface or VLAN).'))
self.instance.assigned_object = interface or vminterface or vlan self.instance.assigned_object = interface or vminterface or vlan or vlandevicemapping

View File

@ -152,8 +152,8 @@ class L2VPNTermination(NetBoxModel):
return self.assigned_object.virtual_machine return self.assigned_object.virtual_machine
elif obj_type.model == 'interface': elif obj_type.model == 'interface':
return self.assigned_object.device return self.assigned_object.device
elif obj_type.model == 'vminterface': elif obj_type.model == 'vlandevicemapping':
return self.assigned_object.virtual_machine return self.assigned_object.vlan
return None return None
@property @property

View File

@ -55,11 +55,6 @@ class L2VPNTerminationTable(NetBoxTable):
verbose_name=_('L2VPN'), verbose_name=_('L2VPN'),
linkify=True linkify=True
) )
device = tables.Column(
linkify=True,
orderable=False,
verbose_name=_('Device')
)
assigned_object_type = columns.ContentTypeColumn( assigned_object_type = columns.ContentTypeColumn(
verbose_name=_('Object Type') verbose_name=_('Object Type')
) )
@ -77,8 +72,8 @@ class L2VPNTerminationTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = L2VPNTermination model = L2VPNTermination
fields = ( fields = (
'pk', 'l2vpn', 'device', 'assigned_object_type', 'assigned_object', 'assigned_object_site', 'actions', 'pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'assigned_object_site', 'actions',
) )
default_columns = ( default_columns = (
'pk', 'l2vpn', 'device', 'assigned_object_type', 'assigned_object', 'actions', 'pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions',
) )