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

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):
"""
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('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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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):
model = ServiceTemplate

View File

@ -116,6 +116,13 @@ urlpatterns = [
path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
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
path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'),
path('service-templates/add/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_add'),

View File

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

View File

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

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>
<td>{{ object.l2vpn|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Assigned Object" %}</th>
<td>{{ object.assigned_object|linkify }}</td>

View File

@ -17,6 +17,11 @@
{% trans "VLAN" %}
</button>
</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">
<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" %}
@ -33,9 +38,11 @@
<div class="row mb-3">
<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">
{% render_field form.device %}
{% render_field form.vlan %}
</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">
{% render_field form.interface %}
</div>

View File

@ -3,5 +3,6 @@ from django.db.models import Q
L2VPN_ASSIGNMENT_MODELS = Q(
Q(app_label='dcim', model='interface') |
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 dcim.models import Device, Interface
from ipam.models import IPAddress, VLAN
from ipam.models import IPAddress, VLAN, VLANDeviceMapping
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField
@ -264,7 +264,7 @@ class L2VPNTerminationImportForm(NetBoxModelImportForm):
queryset=Device.objects.all(),
required=False,
to_field_name='name',
help_text=_('Parent device (for interface)')
help_text=_('Parent device (for interface or Device-VLAN)')
)
virtual_machine = CSVModelChoiceField(
label=_('Virtual machine'),
@ -310,13 +310,24 @@ class L2VPNTerminationImportForm(NetBoxModelImportForm):
def clean(self):
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.'))
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.'))
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.'))
# 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')

View File

@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
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 tenancy.forms import TenancyForm
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
@ -406,6 +406,12 @@ class L2VPNTerminationForm(NetBoxModelForm):
selector=True,
label=_('VLAN')
)
vlandevicemapping = DynamicModelChoiceField(
queryset=VLANDeviceMapping.objects.all(),
required=False,
selector=True,
label=_('VLAN Device Mapping')
)
interface = DynamicModelChoiceField(
label=_('Interface'),
queryset=Interface.objects.all(),
@ -432,6 +438,8 @@ class L2VPNTerminationForm(NetBoxModelForm):
initial['interface'] = instance.assigned_object
elif type(instance.assigned_object) is VLAN:
initial['vlan'] = instance.assigned_object
elif type(instance.assigned_object) is VLANDeviceMapping:
initial['vlandevicemapping'] = instance.assigned_object
elif type(instance.assigned_object) is VMInterface:
initial['vminterface'] = instance.assigned_object
kwargs['initial'] = initial
@ -444,10 +452,11 @@ class L2VPNTerminationForm(NetBoxModelForm):
interface = self.cleaned_data.get('interface')
vminterface = self.cleaned_data.get('vminterface')
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.'))
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).'))
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
elif obj_type.model == 'interface':
return self.assigned_object.device
elif obj_type.model == 'vminterface':
return self.assigned_object.virtual_machine
elif obj_type.model == 'vlandevicemapping':
return self.assigned_object.vlan
return None
@property

View File

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