From 03f1584d3aadf5bd37c31e439a75a1b2bd064db3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 27 Jun 2022 23:24:50 -0500 Subject: [PATCH 01/17] L2VPN Clean Tree --- netbox/dcim/models/device_components.py | 5 + netbox/ipam/api/nested_serializers.py | 27 ++++ netbox/ipam/api/serializers.py | 58 +++++++++ netbox/ipam/api/urls.py | 4 + netbox/ipam/api/views.py | 13 ++ netbox/ipam/choices.py | 50 ++++++++ netbox/ipam/constants.py | 6 + netbox/ipam/filtersets.py | 65 ++++++++++ netbox/ipam/forms/bulk_edit.py | 18 +++ netbox/ipam/forms/bulk_import.py | 74 +++++++++++ netbox/ipam/forms/filtersets.py | 29 +++++ netbox/ipam/forms/models.py | 96 +++++++++++++++ netbox/ipam/models/__init__.py | 3 + netbox/ipam/models/l2vpn.py | 116 ++++++++++++++++++ netbox/ipam/models/vlans.py | 9 +- netbox/ipam/tables/__init__.py | 1 + netbox/ipam/tables/l2vpn.py | 38 ++++++ netbox/ipam/tests/test_api.py | 93 ++++++++++++++ netbox/ipam/tests/test_filtersets.py | 101 +++++++++++++++ netbox/ipam/tests/test_models.py | 10 ++ netbox/ipam/tests/test_views.py | 10 ++ netbox/ipam/urls.py | 21 ++++ netbox/ipam/views.py | 96 +++++++++++++++ netbox/netbox/navigation_menu.py | 7 ++ netbox/templates/ipam/l2vpn.html | 111 +++++++++++++++++ netbox/templates/ipam/l2vpntermination.html | 31 +++++ .../templates/ipam/l2vpntermination_edit.html | 39 ++++++ 27 files changed, 1130 insertions(+), 1 deletion(-) create mode 100644 netbox/ipam/models/l2vpn.py create mode 100644 netbox/ipam/tables/l2vpn.py create mode 100644 netbox/templates/ipam/l2vpn.html create mode 100644 netbox/templates/ipam/l2vpntermination.html create mode 100644 netbox/templates/ipam/l2vpntermination_edit.html diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index f49db08ab..4d19a2d8d 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -649,6 +649,11 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo object_id_field='interface_id', related_query_name='+' ) + l2vpn = GenericRelation( + to='ipam.L2VPNTermination', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + ) clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type'] diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 5f9e09049..8316cb992 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from ipam import models +from ipam.models.l2vpn import L2VPNTermination, L2VPN from netbox.api import WritableNestedSerializer __all__ = [ @@ -190,3 +191,29 @@ class NestedServiceSerializer(WritableNestedSerializer): class Meta: model = models.Service fields = ['id', 'url', 'display', 'name', 'protocol', 'ports'] + +# +# Virtual Circuits +# + + +class NestedL2VPNSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn-detail') + + class Meta: + model = L2VPN + fields = [ + 'id', 'url', 'display', 'name', 'type' + ] + + +class NestedL2VPNTerminationSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn_termination-detail') + l2vpn = NestedL2VPNSerializer() + + class Meta: + model = L2VPNTermination + fields = [ + 'id', 'url', 'display', 'l2vpn', 'assigned_object' + ] + diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index ea5c37f91..a51043e27 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -19,6 +19,9 @@ from .nested_serializers import * # # ASNs # +from .nested_serializers import NestedL2VPNSerializer +from ..models.l2vpn import L2VPNTermination, L2VPN + class ASNSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') @@ -433,3 +436,58 @@ class ServiceSerializer(NetBoxModelSerializer): 'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] + +# +# Virtual Circuits +# + + +class L2VPNSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn-detail') + type = ChoiceField(choices=L2VPNTypeChoices, required=False) + import_targets = SerializedPKRelatedField( + queryset=RouteTarget.objects.all(), + serializer=NestedRouteTargetSerializer, + required=False, + many=True + ) + export_targets = SerializedPKRelatedField( + queryset=RouteTarget.objects.all(), + serializer=NestedRouteTargetSerializer, + required=False, + many=True + ) + tenant = NestedTenantSerializer(required=False, allow_null=True) + + class Meta: + model = L2VPN + fields = [ + 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets', + 'description', 'tenant', + # Extra Fields + 'tags', 'custom_fields', 'created', 'last_updated' + ] + + +class L2VPNTerminationSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail') + l2vpn = NestedL2VPNSerializer() + assigned_object_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + assigned_object = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = L2VPNTermination + fields = [ + 'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id', + 'assigned_object', + # Extra Fields + 'tags', 'custom_fields', 'created', 'last_updated' + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_assigned_object(self, instance): + serializer = get_serializer_for_model(instance.assigned_object, prefix='Nested') + context = {'request': self.context['request']} + return serializer(instance.assigned_object, context=context).data diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 99e039eff..b588b6974 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -45,6 +45,10 @@ router.register('vlans', views.VLANViewSet) router.register('service-templates', views.ServiceTemplateViewSet) router.register('services', views.ServiceViewSet) +# L2VPN +router.register('l2vpn', views.L2VPNViewSet) +router.register('l2vpn-termination', views.L2VPNTerminationViewSet) + app_name = 'ipam-api' urlpatterns = [ diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index dcddec580..36a6f02b6 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -18,6 +18,7 @@ from netbox.config import get_config from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import count_related from . import serializers +from ..models.l2vpn import L2VPN, L2VPNTermination class IPAMRootView(APIRootView): @@ -157,6 +158,18 @@ class ServiceViewSet(NetBoxModelViewSet): filterset_class = filtersets.ServiceFilterSet +class L2VPNViewSet(NetBoxModelViewSet): + queryset = L2VPN.objects + serializer_class = serializers.L2VPNSerializer + filterset_class = filtersets.L2VPNFilterSet + + +class L2VPNTerminationViewSet(NetBoxModelViewSet): + queryset = L2VPNTermination.objects + serializer_class = serializers.L2VPNTerminationSerializer + filterset_class = filtersets.L2VPNTerminationFilterSet + + # # Views # diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index a364d3c6a..a867b05bc 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -170,3 +170,53 @@ class ServiceProtocolChoices(ChoiceSet): (PROTOCOL_UDP, 'UDP'), (PROTOCOL_SCTP, 'SCTP'), ) + + +class L2VPNTypeChoices(ChoiceSet): + TYPE_VPLS = 'vpls' + TYPE_VPWS = 'vpws' + TYPE_EPL = 'epl' + TYPE_EVPL = 'evpl' + TYPE_EPLAN = 'ep-lan' + TYPE_EVPLAN = 'evp-lan' + TYPE_EPTREE = 'ep-tree' + TYPE_EVPTREE = 'evp-tree' + TYPE_VXLAN = 'vxlan' + TYPE_VXLAN_EVPN = 'vxlan-evpn' + TYPE_MPLS_EVPN = 'mpls-evpn' + TYPE_PBB_EVPN = 'pbb-evpn' + + CHOICES = ( + ('VPLS', ( + (TYPE_VPWS, 'VPWS'), + (TYPE_VPLS, 'VPLS'), + )), + ('E-Line', ( + (TYPE_EPL, 'EPL'), + (TYPE_EVPL, 'EVPL'), + )), + ('E-LAN', ( + (TYPE_EPLAN, 'Ethernet Private LAN'), + (TYPE_EVPLAN, 'Ethernet Virtual Private LAN'), + )), + ('E-Tree', ( + (TYPE_EPTREE, 'Ethernet Private Tree'), + (TYPE_EVPTREE, 'Ethernet Virtual Private Tree'), + )), + ('VXLAN', ( + (TYPE_VXLAN, 'VXLAN'), + (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), + )), + ('L2VPN E-VPN', ( + (TYPE_MPLS_EVPN, 'MPLS EVPN'), + (TYPE_PBB_EVPN, 'PBB EVPN'), + )) + + ) + + P2P = ( + TYPE_VPWS, + TYPE_EPL, + TYPE_EPLAN, + TYPE_EPTREE + ) diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index ab88dfc1a..cb121515d 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -90,3 +90,9 @@ VLANGROUP_SCOPE_TYPES = ( # 16-bit port number SERVICE_PORT_MIN = 1 SERVICE_PORT_MAX = 65535 + +L2VPN_ASSIGNMENT_MODELS = Q( + Q(app_label='dcim', model='interface') | + Q(app_label='ipam', model='vlan') | + Q(app_label='virtualization', model='vminterface') +) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index d9cf6eefc..03189a7cb 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -23,6 +23,8 @@ __all__ = ( 'FHRPGroupFilterSet', 'IPAddressFilterSet', 'IPRangeFilterSet', + 'L2VPNFilterSet', + 'L2VPNTerminationFilterSet', 'PrefixFilterSet', 'RIRFilterSet', 'RoleFilterSet', @@ -922,3 +924,66 @@ class ServiceFilterSet(NetBoxModelFilterSet): return queryset qs_filter = Q(name__icontains=value) | Q(description__icontains=value) return queryset.filter(qs_filter) + + +# +# L2VPN +# + + +class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): + import_target_id = django_filters.ModelMultipleChoiceFilter( + field_name='import_targets', + queryset=RouteTarget.objects.all(), + label='Import target', + ) + import_target = django_filters.ModelMultipleChoiceFilter( + field_name='import_targets__name', + queryset=RouteTarget.objects.all(), + to_field_name='name', + label='Import target (name)', + ) + export_target_id = django_filters.ModelMultipleChoiceFilter( + field_name='export_targets', + queryset=RouteTarget.objects.all(), + label='Export target', + ) + export_target = django_filters.ModelMultipleChoiceFilter( + field_name='export_targets__name', + queryset=RouteTarget.objects.all(), + to_field_name='name', + label='Export target (name)', + ) + + class Meta: + model = L2VPN + fields = ['identifier', 'name', 'type', 'description'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(identifier=value) | Q(name__icontains=value) | Q(description__icontains=value) + return queryset.filter(qs_filter) + + +class L2VPNTerminationFilterSet(NetBoxModelFilterSet): + l2vpn_id = django_filters.ModelMultipleChoiceFilter( + queryset=L2VPN.objects.all(), + label='L2VPN (ID)', + ) + l2vpn = django_filters.ModelMultipleChoiceFilter( + field_name='l2vpn__name', + queryset=L2VPN.objects.all(), + to_field_name='name', + label='L2VPN (name)', + ) + + class Meta: + model = L2VPNTermination + fields = ['l2vpn'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(l2vpn__name__icontains=value) + return queryset.filter(qs_filter) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 66b4ba0fc..bbfa5bf9f 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -18,6 +18,7 @@ __all__ = ( 'FHRPGroupBulkEditForm', 'IPAddressBulkEditForm', 'IPRangeBulkEditForm', + 'L2VPNBulkEditForm', 'PrefixBulkEditForm', 'RIRBulkEditForm', 'RoleBulkEditForm', @@ -440,3 +441,20 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): class ServiceBulkEditForm(ServiceTemplateBulkEditForm): model = Service + + +class L2VPNBulkEditForm(NetBoxModelBulkEditForm): + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + + model = L2VPN + fieldsets = ( + (None, ('tenant', 'description')), + ) + nullable_fields = ('tenant', 'description',) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 17da242a0..5b94f6c8e 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from dcim.models import Device, Interface, Site from ipam.choices import * @@ -16,6 +17,8 @@ __all__ = ( 'FHRPGroupCSVForm', 'IPAddressCSVForm', 'IPRangeCSVForm', + 'L2VPNCSVForm', + 'L2VPNTerminationCSVForm', 'PrefixCSVForm', 'RIRCSVForm', 'RoleCSVForm', @@ -425,3 +428,74 @@ class ServiceCSVForm(NetBoxModelCSVForm): class Meta: model = Service fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description') + + +class L2VPNCSVForm(NetBoxModelCSVForm): + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + ) + type = CSVChoiceField( + choices=L2VPNTypeChoices, + help_text='IP protocol' + ) + + class Meta: + model = L2VPN + fields = ('identifier', 'name', 'slug', 'type', 'description') + + +class L2VPNTerminationCSVForm(NetBoxModelCSVForm): + l2vpn = CSVModelChoiceField( + queryset=L2VPN.objects.all(), + required=True, + to_field_name='name', + label='L2VPN', + ) + + device = CSVModelChoiceField( + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text='Required if assigned to a interface' + ) + + interface = CSVModelChoiceField( + queryset=Interface.objects.all(), + required=False, + to_field_name='name', + help_text='Required if not assigned to a vlan' + ) + + vlan = CSVModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + to_field_name='name', + help_text='Required if not assigned to a interface' + ) + + class Meta: + model = L2VPNTermination + fields = ('l2vpn', 'device', 'interface', 'vlan') + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + if data: + # Limit interface queryset by assigned device + if data.get('device'): + self.fields['interface'].queryset = Interface.objects.filter( + **{f"device__{self.fields['device'].to_field_name}": data['device']} + ) + + def clean(self): + super().clean() + + if not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')): + raise ValidationError('You must have either a interface or a VLAN') + + if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'): + raise ValidationError('Cannot assign both a interface and vlan') + + # Set Assigned Object + self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan') diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index bbd6bb97b..1cb936ca3 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -19,6 +19,8 @@ __all__ = ( 'FHRPGroupFilterForm', 'IPAddressFilterForm', 'IPRangeFilterForm', + 'L2VPNFilterForm', + 'L2VPNTerminationFilterForm', 'PrefixFilterForm', 'RIRFilterForm', 'RoleFilterForm', @@ -463,3 +465,30 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): class ServiceFilterForm(ServiceTemplateFilterForm): model = Service + + +class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): + model = L2VPN + fieldsets = ( + (None, ('type', )), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) + type = forms.ChoiceField( + choices=add_blank_choice(L2VPNTypeChoices), + required=False, + widget=StaticSelect() + ) + + +class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): + model = L2VPNTermination + fieldsets = ( + (None, ('l2vpn', )), + ) + l2vpn = DynamicModelChoiceField( + queryset=L2VPN.objects.all(), + required=True, + query_params={}, + label='L2VPN', + fetch_trigger='open' + ) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index e86abc672..7ef47ed2f 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup from extras.models import Tag @@ -8,8 +9,10 @@ from ipam.constants import * from ipam.formfields import IPNetworkFormField from ipam.models import * from ipam.models import ASN +from ipam.models.l2vpn import L2VPN, L2VPNTermination from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm +from tenancy.models import Tenant from utilities.exceptions import PermissionsViolation from utilities.forms import ( add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, @@ -26,6 +29,8 @@ __all__ = ( 'IPAddressBulkAddForm', 'IPAddressForm', 'IPRangeForm', + 'L2VPNForm', + 'L2VPNTerminationForm', 'PrefixForm', 'RIRForm', 'RoleForm', @@ -861,3 +866,94 @@ class ServiceCreateForm(ServiceForm): self.cleaned_data['description'] = service_template.description elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')): raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.") + + +# +# L2VPN +# + + +class L2VPNForm(TenancyForm, NetBoxModelForm): + slug = SlugField() + import_targets = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False + ) + export_targets = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False + ) + + fieldsets = ( + ('L2VPN', ('name', 'slug', 'type', 'identifier', 'description', 'tags')), + ('Route Targets', ('import_targets', 'export_targets')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + + class Meta: + model = L2VPN + fields = ( + 'name', 'slug', 'type', 'identifier', 'description', 'import_targets', 'export_targets', 'tenant', 'tags' + ) + + +class L2VPNTerminationForm(NetBoxModelForm): + l2vpn = DynamicModelChoiceField( + queryset=L2VPN.objects.all(), + required=True, + query_params={}, + label='L2VPN', + fetch_trigger='open' + ) + + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={} + ) + + vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + query_params={ + 'available_on_device': '$device' + } + ) + + interface = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + } + ) + + class Meta: + model = L2VPNTermination + fields = ('l2vpn', ) + + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + + if instance: + if type(instance.assigned_object) is Interface: + initial['device'] = instance.assigned_object.parent + initial['interface'] = instance.assigned_object + elif type(instance.assigned_object) is VLAN: + initial['vlan'] = instance.assigned_object + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + def clean(self): + super().clean() + + if not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')): + raise ValidationError('You must have either a interface or a VLAN') + + if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'): + raise ValidationError('Cannot assign both a interface and vlan') + + obj = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan') + self.instance.assigned_object = obj diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index ce09c482a..d13ee9076 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -2,6 +2,7 @@ from .fhrp import * from .vrfs import * from .ip import * +from .l2vpn import * from .services import * from .vlans import * @@ -12,6 +13,8 @@ __all__ = ( 'IPRange', 'FHRPGroup', 'FHRPGroupAssignment', + 'L2VPN', + 'L2VPNTermination', 'Prefix', 'RIR', 'Role', diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py new file mode 100644 index 000000000..b086fa109 --- /dev/null +++ b/netbox/ipam/models/l2vpn.py @@ -0,0 +1,116 @@ +from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse + +from ipam.choices import L2VPNTypeChoices +from ipam.constants import L2VPN_ASSIGNMENT_MODELS +from netbox.models import NetBoxModel + + +class L2VPN(NetBoxModel): + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField() + type = models.CharField(max_length=50, choices=L2VPNTypeChoices) + identifier = models.BigIntegerField( + null=True, + blank=True, + unique=True + ) + import_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='importing_l2vpns', + blank=True, + ) + export_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='exporting_l2vpns', + blank=True + ) + description = models.TextField(null=True, blank=True) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.SET_NULL, + related_name='l2vpns', + blank=True, + null=True + ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + + class Meta: + ordering = ('identifier', 'name') + verbose_name = 'L2VPN' + + def __str__(self): + if self.identifier: + return f'{self.name} ({self.identifier})' + return f'{self.name}' + + def get_absolute_url(self): + return reverse('ipam:l2vpn', args=[self.pk]) + + +class L2VPNTermination(NetBoxModel): + l2vpn = models.ForeignKey( + to='ipam.L2VPN', + on_delete=models.CASCADE, + related_name='terminations', + blank=False, + null=False + ) + + assigned_object_type = models.ForeignKey( + to=ContentType, + limit_choices_to=L2VPN_ASSIGNMENT_MODELS, + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True + ) + assigned_object_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + assigned_object = GenericForeignKey( + ct_field='assigned_object_type', + fk_field='assigned_object_id' + ) + + class Meta: + ordering = ('l2vpn',) + verbose_name = 'L2VPN Termination' + + constraints = ( + models.UniqueConstraint( + fields=('assigned_object_type', 'assigned_object_id'), + name='ipam_l2vpntermination_assigned_object' + ), + ) + + def __str__(self): + if self.pk is not None: + return f'{self.assigned_object} <> {self.l2vpn}' + return '' + + def get_absolute_url(self): + return reverse('ipam:l2vpntermination', args=[self.pk]) + + def clean(self): + # Only check is assigned_object is set + if self.assigned_object: + obj_id = self.assigned_object.pk + obj_type = ContentType.objects.get_for_model(self.assigned_object) + if L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type).\ + exclude(pk=self.pk).count() > 0: + raise ValidationError(f'L2VPN Termination already assigned ({self.assigned_object})') + + # Only check if L2VPN is set and is of type P2P + if self.l2vpn and self.l2vpn.type in L2VPNTypeChoices.P2P: + if L2VPNTermination.objects.filter(l2vpn=self.l2vpn).exclude(pk=self.pk).count() >= 2: + raise ValidationError(f'P2P Type L2VPNs can only have 2 terminations; first delete a termination') diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 7643a2617..3a7969405 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -1,4 +1,4 @@ -from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -8,6 +8,7 @@ from django.urls import reverse from dcim.models import Interface from ipam.choices import * from ipam.constants import * +from ipam.models import L2VPNTermination from ipam.querysets import VLANQuerySet from netbox.models import OrganizationalModel, NetBoxModel from virtualization.models import VMInterface @@ -173,6 +174,12 @@ class VLAN(NetBoxModel): blank=True ) + l2vpn = GenericRelation( + to='ipam.L2VPNTermination', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + ) + objects = VLANQuerySet.as_manager() clone_fields = [ diff --git a/netbox/ipam/tables/__init__.py b/netbox/ipam/tables/__init__.py index 6f429e27d..3bde78af0 100644 --- a/netbox/ipam/tables/__init__.py +++ b/netbox/ipam/tables/__init__.py @@ -1,5 +1,6 @@ from .fhrp import * from .ip import * +from .l2vpn import * from .services import * from .vlans import * from .vrfs import * diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/ipam/tables/l2vpn.py new file mode 100644 index 000000000..551f692bb --- /dev/null +++ b/netbox/ipam/tables/l2vpn.py @@ -0,0 +1,38 @@ +import django_tables2 as tables + +from ipam.models import * +from ipam.models.l2vpn import L2VPN, L2VPNTermination +from netbox.tables import NetBoxTable, columns + +__all__ = ( + 'L2VPNTable', + 'L2VPNTerminationTable', +) + + +class L2VPNTable(NetBoxTable): + pk = columns.ToggleColumn() + name = tables.Column( + linkify=True + ) + + class Meta(NetBoxTable.Meta): + model = L2VPN + fields = ('pk', 'name', 'description', 'slug', 'type', 'tenant', 'actions') + default_columns = ('pk', 'name', 'description', 'actions') + + +class L2VPNTerminationTable(NetBoxTable): + pk = columns.ToggleColumn() + assigned_object_type = columns.ContentTypeColumn( + verbose_name='Object Type' + ) + assigned_object = tables.Column( + linkify=True, + orderable=False + ) + + class Meta(NetBoxTable.Meta): + model = L2VPNTermination + fields = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions') + default_columns = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index d99de6d20..0e93bd43e 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -914,3 +914,96 @@ class ServiceTest(APIViewTestCases.APIViewTestCase): 'ports': [6], }, ] + + +class L2VPNTest(APIViewTestCases.APIViewTestCase): + model = L2VPN + brief_fields = ['display', 'id', 'identifier', 'name', 'slug', 'type', 'url'] + create_data = [ + { + 'name': 'L2VPN 4', + 'slug': 'l2vpn-4', + 'type': 'vxlan', + 'identifier': 33343344 + }, + { + 'name': 'L2VPN 5', + 'slug': 'l2vpn-5', + 'type': 'vxlan', + 'identifier': 33343345 + }, + { + 'name': 'L2VPN 6', + 'slug': 'l2vpn-6', + 'type': 'vpws', + 'identifier': 33343346 + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + l2vpns = ( + L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + +class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): + model = L2VPNTermination + brief_fields = ['display', 'id', 'l2vpn', 'assigned_object', 'assigned_object_id', 'assigned_object_type', 'url'] + + @classmethod + def setUpTestData(cls): + + vlans = ( + VLAN(name='VLAN 1', vid=650001), + VLAN(name='VLAN 2', vid=650002), + VLAN(name='VLAN 3', vid=650003), + VLAN(name='VLAN 4', vid=650004), + VLAN(name='VLAN 5', vid=650005), + VLAN(name='VLAN 6', vid=650006), + VLAN(name='VLAN 7', vid=650007) + ) + + VLAN.objects.bulk_create(vlans) + + l2vpns = ( + L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + l2vpnterminations = ( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + ) + + cls.create_data = [ + { + 'l2vpn': l2vpns[0], + 'assigned_object_type': 'ipam.vlan', + 'assigned_object_id': vlans[3], + }, + { + 'l2vpn': l2vpns[0], + 'assigned_object_type': 'ipam.vlan', + 'assigned_object_id': vlans[4], + }, + { + 'l2vpn': l2vpns[0], + 'assigned_object_type': 'ipam.vlan', + 'assigned_object_id': vlans[5], + }, + ] + + cls.bulk_update_data = { + 'l2vpn': l2vpns[2] + } diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index d98fe889e..c5cffc7dc 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1463,3 +1463,104 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'virtual_machine': [vms[0].name, vms[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class L2VPNTest(TestCase, ChangeLoggedFilterSetTests): + # TODO: L2VPN Tests + queryset = L2VPN.objects.all() + filterset = L2VPNFilterSet + + @classmethod + def setUpTestData(cls): + + l2vpns = ( + L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + def test_created(self): + from datetime import date, date + pk_list = self.queryset.values_list('pk', flat=True)[:2] + print(pk_list) + self.queryset.filter(pk__in=pk_list).update(created=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) + params = {'created': '2021-01-01T00:00:00'} + fs = self.filterset({}, self.queryset).qs.all() + for res in fs: + print(f'{res.name}:{res.created}') + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class L2VPNTerminationTest(TestCase, ChangeLoggedFilterSetTests): + # TODO: L2VPN Termination Tests + queryset = L2VPNTermination.objects.all() + filterset = L2VPNTerminationFilterSet + + @classmethod + def setUpTestData(cls): + + site = Site.objects.create(name='Site 1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1') + device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + device_role = DeviceRole.objects.create(name='Switch') + device = Device.objects.create( + name='Device 1', + site=site, + device_type=device_type, + device_role=device_role, + status='active' + ) + interfaces = Interface.objects.bulk_create( + Interface(name='GigabitEthernet1/0/1', device=device, type='1000baset'), + Interface(name='GigabitEthernet1/0/2', device=device, type='1000baset'), + Interface(name='GigabitEthernet1/0/3', device=device, type='1000baset'), + Interface(name='GigabitEthernet1/0/4', device=device, type='1000baset'), + Interface(name='GigabitEthernet1/0/5', device=device, type='1000baset'), + ) + + vlans = ( + VLAN(name='VLAN 1', vid=650001), + VLAN(name='VLAN 2', vid=650002), + VLAN(name='VLAN 3', vid=650003), + VLAN(name='VLAN 4', vid=650004), + VLAN(name='VLAN 5', vid=650005), + VLAN(name='VLAN 6', vid=650006), + VLAN(name='VLAN 7', vid=650007) + ) + + VLAN.objects.bulk_create(vlans) + + l2vpns = ( + L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + l2vpnterminations = ( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + ) + + def test_l2vpns(self): + l2vpns = L2VPN.objects.all()[:2] + params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'l2vpn': ['L2VPN 1', 'L2VPN 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_interfaces(self): + interfaces = Interface.objects.all()[:2] + params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'interface': ['Interface 1', 'Interface 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_vlans(self): + vlans = VLAN.objects.all()[:2] + params = {'vlan_id': [vlans[0].pk, vlans[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'vlan': ['VLAN 1', 'VLAN 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 09bc95799..ce4643516 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -538,3 +538,13 @@ class TestVLANGroup(TestCase): VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup) self.assertEqual(vlangroup.get_next_available_vid(), 105) + + +class TestL2VPN(TestCase): + # TODO: L2VPN Tests + pass + + +class TestL2VPNTermination(TestCase): + # TODO: L2VPN Termination Tests + pass diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 672cfbe08..8d1b9bd1b 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -746,3 +746,13 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): self.assertEqual(instance.protocol, service_template.protocol) self.assertEqual(instance.ports, service_template.ports) self.assertEqual(instance.description, service_template.description) + + +class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): + # TODO: L2VPN Tests + pass + + +class L2VPNTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): + # TODO: L2VPN Termination Tests + pass diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 3c7ed2d1f..65a6b55ad 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -186,4 +186,25 @@ urlpatterns = [ path('services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), path('services//journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}), + # L2VPN + path('l2vpn/', views.L2VPNListView.as_view(), name='l2vpn_list'), + path('l2vpn/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'), + path('l2vpn/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'), + path('l2vpn/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'), + path('l2vpn/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'), + path('l2vpn//', views.L2VPNView.as_view(), name='l2vpn'), + path('l2vpn//edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'), + path('l2vpn//delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'), + path('l2vpn//changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}), + path('l2vpn//journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}), + + path('l2vpn-termination/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), + path('l2vpn-termination/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), + path('l2vpn-termination/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), + path('l2vpn-termination/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), + path('l2vpn-termination//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), + path('l2vpn-termination//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), + path('l2vpn-termination//delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'), + path('l2vpn-termination//changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}), + path('l2vpn-termination//journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}), ] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 6682fc920..77539434c 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -17,6 +17,7 @@ from . import filtersets, forms, tables from .constants import * from .models import * from .models import ASN +from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans @@ -1140,6 +1141,101 @@ class ServiceBulkEditView(generic.BulkEditView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filtersets.ServiceFilterSet table = tables.ServiceTable + + +# L2VPN + + +class L2VPNListView(generic.ObjectListView): + queryset = L2VPN.objects.all() + table = L2VPNTable + filterset = filtersets.L2VPNFilterSet + filterset_form = forms.L2VPNFilterForm + + +class L2VPNView(generic.ObjectView): + queryset = L2VPN.objects.all() + + def get_extra_context(self, request, instance): + terminations = L2VPNTermination.objects.restrict(request.user, 'view').filter(l2vpn=instance) + terminations_table = tables.L2VPNTerminationTable(terminations, user=request.user, exclude=('l2vpn', )) + terminations_table.configure(request) + + import_targets_table = tables.RouteTargetTable( + instance.import_targets.prefetch_related('tenant'), + orderable=False + ) + export_targets_table = tables.RouteTargetTable( + instance.export_targets.prefetch_related('tenant'), + orderable=False + ) + + return { + 'terminations_table': terminations_table, + 'import_targets_table': import_targets_table, + 'export_targets_table': export_targets_table, + } + + +class L2VPNEditView(generic.ObjectEditView): + queryset = L2VPN.objects.all() + form = forms.L2VPNForm + + +class L2VPNDeleteView(generic.ObjectDeleteView): + queryset = L2VPN.objects.all() + + +class L2VPNBulkImportView(generic.BulkImportView): + queryset = L2VPN.objects.all() + model_form = forms.L2VPNCSVForm + table = tables.L2VPNTable + + +class L2VPNBulkEditView(generic.BulkEditView): + queryset = L2VPN.objects.all() + filterset = filtersets.L2VPNFilterSet + table = tables.L2VPNTable + form = forms.L2VPNBulkEditForm + + +class L2VPNBulkDeleteView(generic.BulkDeleteView): + queryset = L2VPN.objects.all() + filterset = filtersets.L2VPNFilterSet + table = tables.L2VPNTable + + +class L2VPNTerminationListView(generic.ObjectListView): + queryset = L2VPNTermination.objects.all() + table = L2VPNTerminationTable + filterset = filtersets.L2VPNTerminationFilterSet + filterset_form = forms.L2VPNTerminationFilterForm + + +class L2VPNTerminationView(generic.ObjectView): + queryset = L2VPNTermination.objects.all() + + +class L2VPNTerminationEditView(generic.ObjectEditView): + queryset = L2VPNTermination.objects.all() + form = forms.L2VPNTerminationForm + template_name = 'ipam/l2vpntermination_edit.html' + + +class L2VPNTerminationDeleteView(generic.ObjectDeleteView): + queryset = L2VPNTermination.objects.all() + + +class L2VPNTerminationBulkImportView(generic.BulkImportView): + queryset = L2VPNTermination.objects.all() + model_form = forms.L2VPNTerminationCSVForm + table = tables.L2VPNTerminationTable + + +class L2VPNTerminationBulkDeleteView(generic.BulkDeleteView): + queryset = L2VPNTermination.objects.all() + filterset = filtersets.L2VPNTerminationFilterSet + table = tables.L2VPNTerminationTable form = forms.ServiceBulkEditForm diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 9a55c263e..f2245f68b 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -260,6 +260,13 @@ IPAM_MENU = Menu( get_model_item('ipam', 'vlangroup', 'VLAN Groups'), ), ), + MenuGroup( + label='L2VPNs', + items=( + get_model_item('ipam', 'l2vpn', 'L2VPN'), + get_model_item('ipam', 'l2vpntermination', 'Terminations'), + ), + ), MenuGroup( label='Other', items=( diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/ipam/l2vpn.html new file mode 100644 index 000000000..59cc6234b --- /dev/null +++ b/netbox/templates/ipam/l2vpn.html @@ -0,0 +1,111 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block content %} +
+
+
+
+ L2VPN Attributes +
+
+ Name + + + + + + + + + + + + + + + + + + + + + + +
{{ object.name|placeholder }}
Slug{{ object.slug|placeholder }}
Identifier{{ object.identifier|placeholder }}
Type{{ object.get_type_display }}
Description{{ object.description|placeholder }}
Tenant{{ object.tenant|placeholder }}
+
+
+ {% include 'inc/panels/contacts.html' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:circuit_list' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% include 'inc/panel_table.html' with table=import_targets_table heading="Import Route Targets" %} +
+
+ {% include 'inc/panel_table.html' with table=export_targets_table heading="Export Route Targets" %} +
+
+
+
+
+
L2VPN Terminations
+
+ {% with terminations=object.terminations.all %} + {% if terminations.exists %} + + + + + + + {% for termination in terminations %} + + + + + + {% endfor %} +
Termination TypeTermination
{{ termination.assigned_object|meta:"verbose_name" }}{{ termination.assigned_object|linkify }} + {% if perms.ipam.change_l2vpntermination %} + + + + {% endif %} + {% if perms.ipam.delete_l2vpntermination %} + + + + {% endif %} +
+ {% else %} +
None
+ {% endif %} + {% endwith %} +
+ {% if perms.ipam.add_l2vpntermination %} + + {% endif %} +
+
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/l2vpntermination.html b/netbox/templates/ipam/l2vpntermination.html new file mode 100644 index 000000000..22e0cc324 --- /dev/null +++ b/netbox/templates/ipam/l2vpntermination.html @@ -0,0 +1,31 @@ +{% extends 'generic/object.html' %} +{% load helpers %} + +{% block content %} +
+
+
+
+ L2VPN Attributes +
+
+ + + + + + + + + +
L2vPN{{ object.l2vpn.name|placeholder }}
Assigned Object{{ object.assigned_object.name|placeholder }}
+
+
+
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:l2vpntermination_list' %} +
+
+ +{% endblock %} diff --git a/netbox/templates/ipam/l2vpntermination_edit.html b/netbox/templates/ipam/l2vpntermination_edit.html new file mode 100644 index 000000000..3fb0460b5 --- /dev/null +++ b/netbox/templates/ipam/l2vpntermination_edit.html @@ -0,0 +1,39 @@ +{% extends 'generic/object_edit.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block form %} +
+
+
L2VPN Termination
+
+ {% render_field form.l2vpn %} +
+
+ +
+
+
+
+ {% render_field form.device %} +
+ {% render_field form.vlan %} +
+
+ {% render_field form.interface %} +
+
+
+
+{% endblock %} From 3be9f6c4f3a74ab74f35f6cdc4de77593583c409 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 29 Jun 2022 16:01:20 -0500 Subject: [PATCH 02/17] #8157 - Final work on L2VPN model --- netbox/dcim/api/serializers.py | 4 +- netbox/dcim/models/device_components.py | 7 +- netbox/ipam/api/nested_serializers.py | 8 +- netbox/ipam/api/serializers.py | 5 +- netbox/ipam/api/views.py | 2 +- netbox/ipam/filtersets.py | 51 ++++++++- netbox/ipam/forms/bulk_edit.py | 5 + netbox/ipam/graphql/schema.py | 6 ++ netbox/ipam/graphql/types.py | 16 +++ netbox/ipam/models/vlans.py | 7 +- netbox/ipam/tests/test_api.py | 38 +++---- netbox/ipam/tests/test_filtersets.py | 62 +++++------ netbox/ipam/tests/test_models.py | 81 ++++++++++++-- netbox/ipam/tests/test_views.py | 138 ++++++++++++++++++++++-- netbox/ipam/urls.py | 1 + netbox/ipam/views.py | 21 ++-- netbox/templates/dcim/interface.html | 4 + netbox/templates/ipam/vlan.html | 4 + 18 files changed, 376 insertions(+), 84 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 8ac2aa738..32709000b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -10,6 +10,7 @@ from dcim.constants import * from dcim.models import * from ipam.api.nested_serializers import ( NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer, + NestedL2VPNTerminationSerializer, ) from ipam.models import ASN, VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField @@ -823,6 +824,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn many=True ) vrf = NestedVRFSerializer(required=False, allow_null=True) + l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True) cable = NestedCableSerializer(read_only=True) wireless_link = NestedWirelessLinkSerializer(read_only=True) wireless_lans = SerializedPKRelatedField( @@ -841,7 +843,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', - 'vrf', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'vrf', 'l2vpn_termination', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4d19a2d8d..70c21c165 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -649,10 +649,11 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo object_id_field='interface_id', related_query_name='+' ) - l2vpn = GenericRelation( + l2vpn_terminations = GenericRelation( to='ipam.L2VPNTermination', content_type_field='assigned_object_type', object_id_field='assigned_object_id', + related_query_name='interface', ) clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type'] @@ -828,6 +829,10 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo def link(self): return self.cable or self.wireless_link + @property + def l2vpn_termination(self): + return self.l2vpn_terminations.first() + # # Pass-through ports diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 8316cb992..39305a017 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -11,6 +11,8 @@ __all__ = [ 'NestedFHRPGroupAssignmentSerializer', 'NestedIPAddressSerializer', 'NestedIPRangeSerializer', + 'NestedL2VPNSerializer', + 'NestedL2VPNTerminationSerializer', 'NestedPrefixSerializer', 'NestedRIRSerializer', 'NestedRoleSerializer', @@ -203,17 +205,17 @@ class NestedL2VPNSerializer(WritableNestedSerializer): class Meta: model = L2VPN fields = [ - 'id', 'url', 'display', 'name', 'type' + 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type' ] class NestedL2VPNTerminationSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn_termination-detail') + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail') l2vpn = NestedL2VPNSerializer() class Meta: model = L2VPNTermination fields = [ - 'id', 'url', 'display', 'l2vpn', 'assigned_object' + 'id', 'url', 'display', 'l2vpn' ] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index a51043e27..36102f853 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -207,13 +207,14 @@ class VLANSerializer(NetBoxModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=VLANStatusChoices, required=False) role = NestedRoleSerializer(required=False, allow_null=True) + l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True) prefix_count = serializers.IntegerField(read_only=True) class Meta: model = VLAN fields = [ - 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', 'prefix_count', + 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', + 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 36a6f02b6..f5a61c031 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -165,7 +165,7 @@ class L2VPNViewSet(NetBoxModelViewSet): class L2VPNTerminationViewSet(NetBoxModelViewSet): - queryset = L2VPNTermination.objects + queryset = L2VPNTermination.objects.prefetch_related('assigned_object') serializer_class = serializers.L2VPNTerminationSerializer filterset_class = filtersets.L2VPNTerminationFilterSet diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 03189a7cb..f682009ee 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -957,7 +957,7 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = L2VPN - fields = ['identifier', 'name', 'type', 'description'] + fields = ['id', 'identifier', 'name', 'type', 'description'] def search(self, queryset, name, value): if not value.strip(): @@ -977,13 +977,60 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet): to_field_name='name', label='L2VPN (name)', ) + device = MultiValueCharFilter( + method='filter_device', + field_name='name', + label='Device (name)', + ) + device_id = MultiValueNumberFilter( + method='filter_device', + field_name='pk', + label='Device (ID)', + ) + interface = django_filters.ModelMultipleChoiceFilter( + field_name='interface__name', + queryset=Interface.objects.all(), + to_field_name='name', + label='Interface (name)', + ) + interface_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface', + queryset=Interface.objects.all(), + label='Interface (ID)', + ) + vlan = django_filters.ModelMultipleChoiceFilter( + field_name='vlan__name', + queryset=VLAN.objects.all(), + to_field_name='name', + label='VLAN (name)', + ) + vlan_vid = django_filters.NumberFilter( + field_name='vlan__vid', + label='VLAN number (1-4094)', + ) + vlan_id = django_filters.ModelMultipleChoiceFilter( + field_name='vlan', + queryset=VLAN.objects.all(), + label='VLAN (ID)', + ) class Meta: model = L2VPNTermination - fields = ['l2vpn'] + fields = ['id', ] def search(self, queryset, name, value): if not value.strip(): return queryset qs_filter = Q(l2vpn__name__icontains=value) return queryset.filter(qs_filter) + + def filter_device(self, queryset, name, value): + devices = Device.objects.filter(**{'{}__in'.format(name): value}) + if not devices.exists(): + return queryset.none() + interface_ids = [] + for device in devices: + interface_ids.extend(device.vc_interfaces().values_list('id', flat=True)) + return queryset.filter( + interface__in=interface_ids + ) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index bbfa5bf9f..50fc51522 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -19,6 +19,7 @@ __all__ = ( 'IPAddressBulkEditForm', 'IPRangeBulkEditForm', 'L2VPNBulkEditForm', + 'L2VPNTerminationBulkEditForm', 'PrefixBulkEditForm', 'RIRBulkEditForm', 'RoleBulkEditForm', @@ -458,3 +459,7 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): (None, ('tenant', 'description')), ) nullable_fields = ('tenant', 'description',) + + +class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm): + model = L2VPN diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index f466c1857..5cd5e030e 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -17,6 +17,12 @@ class IPAMQuery(graphene.ObjectType): ip_range = ObjectField(IPRangeType) ip_range_list = ObjectListField(IPRangeType) + l2vpn = ObjectField(L2VPNType) + l2vpn_list = ObjectListField(L2VPNType) + + l2vpn_termination = ObjectField(L2VPNTerminationType) + l2vpn_termination_list = ObjectListField(L2VPNTerminationType) + prefix = ObjectField(PrefixType) prefix_list = ObjectListField(PrefixType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index ca206b4b8..5af2ca72a 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -11,6 +11,8 @@ __all__ = ( 'FHRPGroupAssignmentType', 'IPAddressType', 'IPRangeType', + 'L2VPNType', + 'L2VPNTerminationType', 'PrefixType', 'RIRType', 'RoleType', @@ -151,3 +153,17 @@ class VRFType(NetBoxObjectType): model = models.VRF fields = '__all__' filterset_class = filtersets.VRFFilterSet + + +class L2VPNType(NetBoxObjectType): + class Meta: + model = models.L2VPN + fields = '__all__' + filtersets_class = filtersets.L2VPNFilterSet + + +class L2VPNTerminationType(NetBoxObjectType): + class Meta: + model = models.L2VPNTermination + fields = '__all__' + filtersets_class = filtersets.L2VPNTerminationFilterSet diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 3a7969405..f0e062721 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -174,10 +174,11 @@ class VLAN(NetBoxModel): blank=True ) - l2vpn = GenericRelation( + l2vpn_terminations = GenericRelation( to='ipam.L2VPNTermination', content_type_field='assigned_object_type', object_id_field='assigned_object_id', + related_query_name='vlan' ) objects = VLANQuerySet.as_manager() @@ -234,3 +235,7 @@ class VLAN(NetBoxModel): Q(untagged_vlan_id=self.pk) | Q(tagged_vlans=self.pk) ).distinct() + + @property + def l2vpn_termination(self): + return self.l2vpn_terminations.first() diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 0e93bd43e..a5ebef2c7 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -947,28 +947,28 @@ class L2VPNTest(APIViewTestCases.APIViewTestCase): def setUpTestData(cls): l2vpns = ( - L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), - L2VPN(name='L2VPN 2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', type='vpls'), # No RD + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD ) L2VPN.objects.bulk_create(l2vpns) class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): model = L2VPNTermination - brief_fields = ['display', 'id', 'l2vpn', 'assigned_object', 'assigned_object_id', 'assigned_object_type', 'url'] + brief_fields = ['display', 'id', 'l2vpn', 'url'] @classmethod def setUpTestData(cls): vlans = ( - VLAN(name='VLAN 1', vid=650001), - VLAN(name='VLAN 2', vid=650002), - VLAN(name='VLAN 3', vid=650003), - VLAN(name='VLAN 4', vid=650004), - VLAN(name='VLAN 5', vid=650005), - VLAN(name='VLAN 6', vid=650006), - VLAN(name='VLAN 7', vid=650007) + VLAN(name='VLAN 1', vid=651), + VLAN(name='VLAN 2', vid=652), + VLAN(name='VLAN 3', vid=653), + VLAN(name='VLAN 4', vid=654), + VLAN(name='VLAN 5', vid=655), + VLAN(name='VLAN 6', vid=656), + VLAN(name='VLAN 7', vid=657) ) VLAN.objects.bulk_create(vlans) @@ -986,24 +986,26 @@ class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) ) + L2VPNTermination.objects.bulk_create(l2vpnterminations) + cls.create_data = [ { - 'l2vpn': l2vpns[0], + 'l2vpn': l2vpns[0].pk, 'assigned_object_type': 'ipam.vlan', - 'assigned_object_id': vlans[3], + 'assigned_object_id': vlans[3].pk, }, { - 'l2vpn': l2vpns[0], + 'l2vpn': l2vpns[0].pk, 'assigned_object_type': 'ipam.vlan', - 'assigned_object_id': vlans[4], + 'assigned_object_id': vlans[4].pk, }, { - 'l2vpn': l2vpns[0], + 'l2vpn': l2vpns[0].pk, 'assigned_object_type': 'ipam.vlan', - 'assigned_object_id': vlans[5], + 'assigned_object_id': vlans[5].pk, }, ] cls.bulk_update_data = { - 'l2vpn': l2vpns[2] + 'l2vpn': l2vpns[2].pk } diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index c5cffc7dc..2b5fb0759 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1465,8 +1465,7 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class L2VPNTest(TestCase, ChangeLoggedFilterSetTests): - # TODO: L2VPN Tests +class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = L2VPN.objects.all() filterset = L2VPNFilterSet @@ -1480,20 +1479,8 @@ class L2VPNTest(TestCase, ChangeLoggedFilterSetTests): ) L2VPN.objects.bulk_create(l2vpns) - def test_created(self): - from datetime import date, date - pk_list = self.queryset.values_list('pk', flat=True)[:2] - print(pk_list) - self.queryset.filter(pk__in=pk_list).update(created=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) - params = {'created': '2021-01-01T00:00:00'} - fs = self.filterset({}, self.queryset).qs.all() - for res in fs: - print(f'{res.name}:{res.created}') - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - -class L2VPNTerminationTest(TestCase, ChangeLoggedFilterSetTests): - # TODO: L2VPN Termination Tests +class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = L2VPNTermination.objects.all() filterset = L2VPNTerminationFilterSet @@ -1511,22 +1498,24 @@ class L2VPNTerminationTest(TestCase, ChangeLoggedFilterSetTests): device_role=device_role, status='active' ) - interfaces = Interface.objects.bulk_create( - Interface(name='GigabitEthernet1/0/1', device=device, type='1000baset'), - Interface(name='GigabitEthernet1/0/2', device=device, type='1000baset'), - Interface(name='GigabitEthernet1/0/3', device=device, type='1000baset'), - Interface(name='GigabitEthernet1/0/4', device=device, type='1000baset'), - Interface(name='GigabitEthernet1/0/5', device=device, type='1000baset'), + + interfaces = ( + Interface(name='Interface 1', device=device, type='1000baset'), + Interface(name='Interface 2', device=device, type='1000baset'), + Interface(name='Interface 3', device=device, type='1000baset'), + Interface(name='Interface 4', device=device, type='1000baset'), + Interface(name='Interface 5', device=device, type='1000baset'), + Interface(name='Interface 6', device=device, type='1000baset') ) + Interface.objects.bulk_create(interfaces) + vlans = ( - VLAN(name='VLAN 1', vid=650001), - VLAN(name='VLAN 2', vid=650002), - VLAN(name='VLAN 3', vid=650003), - VLAN(name='VLAN 4', vid=650004), - VLAN(name='VLAN 5', vid=650005), - VLAN(name='VLAN 6', vid=650006), - VLAN(name='VLAN 7', vid=650007) + VLAN(name='VLAN 1', vid=651), + VLAN(name='VLAN 2', vid=652), + VLAN(name='VLAN 3', vid=653), + VLAN(name='VLAN 4', vid=654), + VLAN(name='VLAN 5', vid=655) ) VLAN.objects.bulk_create(vlans) @@ -1534,26 +1523,33 @@ class L2VPNTerminationTest(TestCase, ChangeLoggedFilterSetTests): l2vpns = ( L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), L2VPN(name='L2VPN 2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', type='vpls'), # No RD + L2VPN(name='L2VPN 3', type='vpls'), # No RD, ) L2VPN.objects.bulk_create(l2vpns) l2vpnterminations = ( L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vlans[2]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=interfaces[0]), + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=interfaces[1]), + L2VPNTermination(l2vpn=l2vpns[2], assigned_object=interfaces[2]), ) + L2VPNTermination.objects.bulk_create(l2vpnterminations) + def test_l2vpns(self): l2vpns = L2VPN.objects.all()[:2] params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'l2vpn': ['L2VPN 1', 'L2VPN 2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_interfaces(self): interfaces = Interface.objects.all()[:2] params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} + qs = self.filterset(params, self.queryset).qs + results = qs.all() self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'interface': ['Interface 1', 'Interface 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index ce4643516..1b5fbadc3 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -2,8 +2,9 @@ from netaddr import IPNetwork, IPSet from django.core.exceptions import ValidationError from django.test import TestCase, override_settings +from dcim.models import Interface, Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices -from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF, L2VPN, L2VPNTermination class TestAggregate(TestCase): @@ -540,11 +541,75 @@ class TestVLANGroup(TestCase): self.assertEqual(vlangroup.get_next_available_vid(), 105) -class TestL2VPN(TestCase): - # TODO: L2VPN Tests - pass - - class TestL2VPNTermination(TestCase): - # TODO: L2VPN Termination Tests - pass + + @classmethod + def setUpTestData(cls): + + site = Site.objects.create(name='Site 1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1') + device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + device_role = DeviceRole.objects.create(name='Switch') + device = Device.objects.create( + name='Device 1', + site=site, + device_type=device_type, + device_role=device_role, + status='active' + ) + + interfaces = ( + Interface(name='Interface 1', device=device, type='1000baset'), + Interface(name='Interface 2', device=device, type='1000baset'), + Interface(name='Interface 3', device=device, type='1000baset'), + Interface(name='Interface 4', device=device, type='1000baset'), + Interface(name='Interface 5', device=device, type='1000baset'), + ) + + Interface.objects.bulk_create(interfaces) + + vlans = ( + VLAN(name='VLAN 1', vid=651), + VLAN(name='VLAN 2', vid=652), + VLAN(name='VLAN 3', vid=653), + VLAN(name='VLAN 4', vid=654), + VLAN(name='VLAN 5', vid=655), + VLAN(name='VLAN 6', vid=656), + VLAN(name='VLAN 7', vid=657) + ) + + VLAN.objects.bulk_create(vlans) + + l2vpns = ( + L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + l2vpnterminations = ( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + ) + + L2VPNTermination.objects.bulk_create(l2vpnterminations) + + def test_duplicate_interface_terminations(self): + device = Device.objects.first() + interface = Interface.objects.filter(device=device).first() + l2vpn = L2VPN.objects.first() + + L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=interface) + duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=interface) + + self.assertRaises(ValidationError, duplicate.clean) + + def test_duplicate_vlan_terminations(self): + vlan = Interface.objects.first() + l2vpn = L2VPN.objects.first() + + L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan) + duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan) + self.assertRaises(ValidationError, duplicate.clean) + diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 8d1b9bd1b..dd3733d4d 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -1,14 +1,18 @@ import datetime +from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse from netaddr import IPNetwork -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface +from extras.choices import ObjectChangeActionChoices +from extras.models import ObjectChange from ipam.choices import * from ipam.models import * from tenancy.models import Tenant -from utilities.testing import ViewTestCases, create_tags +from users.models import ObjectPermission +from utilities.testing import ViewTestCases, create_tags, post_data class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): @@ -749,10 +753,130 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): - # TODO: L2VPN Tests - pass + model = L2VPN + csv_data = ( + 'name,slug,type,identifier', + 'L2VPN 5,l2vpn-5,vxlan,456', + 'L2VPN 6,l2vpn-6,vxlan,444', + ) + bulk_edit_data = { + 'description': 'New Description', + } + + @classmethod + def setUpTestData(cls): + rts = ( + RouteTarget(name='64534:123'), + RouteTarget(name='64534:321') + ) + RouteTarget.objects.bulk_create(rts) + + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier='650001'), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vxlan', identifier='650002'), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vxlan', identifier='650003') + ) + + L2VPN.objects.bulk_create(l2vpns) + + cls.form_data = { + 'name': 'L2VPN 8', + 'slug': 'l2vpn-8', + 'type': 'vxlan', + 'identifier': 123, + 'description': 'Description', + 'import_targets': [rts[0].pk], + 'export_targets': [rts[1].pk] + } + + print(cls.form_data) -class L2VPNTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): - # TODO: L2VPN Termination Tests - pass +class L2VPNTerminationTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): + + model = L2VPNTermination + + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name='Site 1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1') + device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + device_role = DeviceRole.objects.create(name='Switch') + device = Device.objects.create( + name='Device 1', + site=site, + device_type=device_type, + device_role=device_role, + status='active' + ) + + interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset') + l2vpn = L2VPN.objects.create(name='L2VPN 1', type='vxlan', identifier=650001) + l2vpn_vlans = L2VPN.objects.create(name='L2VPN 2', type='vxlan', identifier=650002) + + vlans = ( + VLAN(name='Vlan 1', vid=1001), + VLAN(name='Vlan 2', vid=1002), + VLAN(name='Vlan 3', vid=1003), + VLAN(name='Vlan 4', vid=1004), + VLAN(name='Vlan 5', vid=1005), + VLAN(name='Vlan 6', vid=1006) + ) + VLAN.objects.bulk_create(vlans) + + terminations = ( + L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[2]) + ) + L2VPNTermination.objects.bulk_create(terminations) + + cls.form_data = { + 'l2vpn': l2vpn.pk, + 'device': device.pk, + 'interface': interface.pk, + } + + cls.csv_data = ( + "l2vpn,vlan", + "L2VPN 2,Vlan 4", + "L2VPN 2,Vlan 5", + "L2VPN 2,Vlan 6", + ) + + cls.bulk_edit_data = {} + + # + # Custom assertions + # + + def assertInstanceEqual(self, instance, data, exclude=None, api=False): + """ + Override parent + """ + if exclude is None: + exclude = [] + + fields = [k for k in data.keys() if k not in exclude] + model_dict = self.model_to_dict(instance, fields=fields, api=api) + + # Omit any dictionary keys which are not instance attributes or have been excluded + relevant_data = { + k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude + } + + # Handle relations on the model + for k, v in model_dict.items(): + if isinstance(v, object) and hasattr(v, 'first'): + model_dict[k] = v.first().pk + + self.assertDictEqual(model_dict, relevant_data) diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 65a6b55ad..e00b0365f 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -201,6 +201,7 @@ urlpatterns = [ path('l2vpn-termination/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), path('l2vpn-termination/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), path('l2vpn-termination/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), + path('l2vpn-termination/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'), path('l2vpn-termination/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), path('l2vpn-termination//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), path('l2vpn-termination//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 77539434c..35103be48 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1141,6 +1141,13 @@ class ServiceBulkEditView(generic.BulkEditView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filtersets.ServiceFilterSet table = tables.ServiceTable + form = forms.ServiceBulkEditForm + + +class ServiceBulkDeleteView(generic.BulkDeleteView): + queryset = Service.objects.prefetch_related('device', 'virtual_machine') + filterset = filtersets.ServiceFilterSet + table = tables.ServiceTable # L2VPN @@ -1232,14 +1239,14 @@ class L2VPNTerminationBulkImportView(generic.BulkImportView): table = tables.L2VPNTerminationTable +class L2VPNTerminationBulkEditView(generic.BulkEditView): + queryset = L2VPNTermination.objects.all() + filterset = filtersets.L2VPNTerminationFilterSet + table = tables.L2VPNTerminationTable + form = forms.L2VPNTerminationBulkEditForm + + class L2VPNTerminationBulkDeleteView(generic.BulkDeleteView): queryset = L2VPNTermination.objects.all() filterset = filtersets.L2VPNTerminationFilterSet table = tables.L2VPNTerminationTable - form = forms.ServiceBulkEditForm - - -class ServiceBulkDeleteView(generic.BulkDeleteView): - queryset = Service.objects.prefetch_related('device', 'virtual_machine') - filterset = filtersets.ServiceFilterSet - table = tables.ServiceTable diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index e98750518..247592e14 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -104,6 +104,10 @@ LAG {{ object.lag|linkify|placeholder }} + + L2VPN + {{ object.l2vpn_termination.l2vpn|linkify|placeholder }} + diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index fd0ba36a3..53bb75b8f 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -64,6 +64,10 @@ Description {{ object.description|placeholder }} + + L2VPN + {{ object.l2vpn_termination.l2vpn|linkify|placeholder }} + From 6e983d154264b6af6586db01ef93586a7276261f Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 29 Jun 2022 16:14:30 -0500 Subject: [PATCH 03/17] Fix up some PEP errors --- netbox/ipam/api/nested_serializers.py | 1 - netbox/ipam/choices.py | 31 +++++++++++++-------------- netbox/ipam/tests/test_models.py | 1 - 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 39305a017..07a7c9598 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -218,4 +218,3 @@ class NestedL2VPNTerminationSerializer(WritableNestedSerializer): fields = [ 'id', 'url', 'display', 'l2vpn' ] - diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index a867b05bc..72cd4ff73 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -192,26 +192,25 @@ class L2VPNTypeChoices(ChoiceSet): (TYPE_VPLS, 'VPLS'), )), ('E-Line', ( - (TYPE_EPL, 'EPL'), - (TYPE_EVPL, 'EVPL'), - )), + (TYPE_EPL, 'EPL'), + (TYPE_EVPL, 'EVPL'), + )), ('E-LAN', ( - (TYPE_EPLAN, 'Ethernet Private LAN'), - (TYPE_EVPLAN, 'Ethernet Virtual Private LAN'), - )), + (TYPE_EPLAN, 'Ethernet Private LAN'), + (TYPE_EVPLAN, 'Ethernet Virtual Private LAN'), + )), ('E-Tree', ( - (TYPE_EPTREE, 'Ethernet Private Tree'), - (TYPE_EVPTREE, 'Ethernet Virtual Private Tree'), - )), + (TYPE_EPTREE, 'Ethernet Private Tree'), + (TYPE_EVPTREE, 'Ethernet Virtual Private Tree'), + )), ('VXLAN', ( - (TYPE_VXLAN, 'VXLAN'), - (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), - )), + (TYPE_VXLAN, 'VXLAN'), + (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), + )), ('L2VPN E-VPN', ( - (TYPE_MPLS_EVPN, 'MPLS EVPN'), - (TYPE_PBB_EVPN, 'PBB EVPN'), - )) - + (TYPE_MPLS_EVPN, 'MPLS EVPN'), + (TYPE_PBB_EVPN, 'PBB EVPN'), + )) ) P2P = ( diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 1b5fbadc3..3bd7e8ccb 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -612,4 +612,3 @@ class TestL2VPNTermination(TestCase): L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan) duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan) self.assertRaises(ValidationError, duplicate.clean) - From dd6bfed565fc25f841f58bc3f70e339da37f69ba Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 29 Jun 2022 18:37:31 -0500 Subject: [PATCH 04/17] Add migration file --- .../0059_l2vpn_l2vpntermination_and_more.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 netbox/ipam/migrations/0059_l2vpn_l2vpntermination_and_more.py diff --git a/netbox/ipam/migrations/0059_l2vpn_l2vpntermination_and_more.py b/netbox/ipam/migrations/0059_l2vpn_l2vpntermination_and_more.py new file mode 100644 index 000000000..a8e5ace25 --- /dev/null +++ b/netbox/ipam/migrations/0059_l2vpn_l2vpntermination_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 4.0.5 on 2022-06-28 04:57 + +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('tenancy', '0007_contact_link'), + ('extras', '0076_configcontext_locations'), + ('ipam', '0058_ipaddress_nat_inside_nonunique'), + ] + + operations = [ + migrations.CreateModel( + name='L2VPN', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField()), + ('type', models.CharField(max_length=50)), + ('identifier', models.BigIntegerField(blank=True, null=True, unique=True)), + ('description', models.TextField(blank=True, null=True)), + ('export_targets', models.ManyToManyField(blank=True, related_name='exporting_l2vpns', to='ipam.routetarget')), + ('import_targets', models.ManyToManyField(blank=True, related_name='importing_l2vpns', to='ipam.routetarget')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='l2vpns', to='tenancy.tenant')), + ], + options={ + 'verbose_name': 'L2VPN', + 'ordering': ('identifier', 'name'), + }, + ), + migrations.CreateModel( + name='L2VPNTermination', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('assigned_object_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('assigned_object_type', models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='ipam.l2vpn')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'L2VPN Termination', + 'ordering': ('l2vpn',), + }, + ), + migrations.AddConstraint( + model_name='l2vpntermination', + constraint=models.UniqueConstraint(fields=('assigned_object_type', 'assigned_object_id'), name='ipam_l2vpntermination_assigned_object'), + ), + ] From 5b397a98272c15cc2dc582abb10d629e8f753429 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 30 Jun 2022 08:29:08 -0500 Subject: [PATCH 05/17] Add docs --- docs/core-functionality/ipam.md | 5 +++++ docs/development/models.md | 2 ++ docs/models/ipam/l2vpn.md | 19 +++++++++++++++++++ docs/models/ipam/l2vpntermination.md | 12 ++++++++++++ 4 files changed, 38 insertions(+) create mode 100644 docs/models/ipam/l2vpn.md create mode 100644 docs/models/ipam/l2vpntermination.md diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index 01bb3c76d..c86819380 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -26,3 +26,8 @@ --- {!models/ipam/asn.md!} + +--- + +{!models/ipam/l2vpn.md!} +{!models/ipam/l2vpntermination.md!} diff --git a/docs/development/models.md b/docs/development/models.md index ae1bab7e7..b6b2e4da2 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -45,6 +45,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/ * [ipam.FHRPGroup](../models/ipam/fhrpgroup.md) * [ipam.IPAddress](../models/ipam/ipaddress.md) * [ipam.IPRange](../models/ipam/iprange.md) +* [ipam.L2VPN](../models/ipam/l2vpn.md) +* [ipam.L2VPNTermination](../models/ipam/l2vpntermination.md) * [ipam.Prefix](../models/ipam/prefix.md) * [ipam.RouteTarget](../models/ipam/routetarget.md) * [ipam.Service](../models/ipam/service.md) diff --git a/docs/models/ipam/l2vpn.md b/docs/models/ipam/l2vpn.md new file mode 100644 index 000000000..9c50a6407 --- /dev/null +++ b/docs/models/ipam/l2vpn.md @@ -0,0 +1,19 @@ +# L2VPN + +A L2VPN object is NetBox is a representation of a layer 2 bridge technology such as VXLAN, VPLS or EPL. Each L2VPN can be identified by name as well as an optional unique identifier (VNI would be an example). + +Each L2VPN instance must have one of the following type associated with it: + +* VPLS +* VPWS +* EPL +* EVPL +* EP-LAN +* EVP-LAN +* EP-TREE +* EVP-TREE +* VXLAN +* VXLAN EVPN +* MPLS-EVPN +* PBB-EVPN + diff --git a/docs/models/ipam/l2vpntermination.md b/docs/models/ipam/l2vpntermination.md new file mode 100644 index 000000000..9135f72a3 --- /dev/null +++ b/docs/models/ipam/l2vpntermination.md @@ -0,0 +1,12 @@ +# L2VPN Termination + +A L2VPN Termination is the termination point of a L2VPN. Certain types of L2VPN's may only have 2 termination points (point-to-point) while others may have many terminations (multipoint). + +Each termination consists of a L2VPN it is a member of as well as the connected endpoint which can be an interface or a VLAN. + +The following types of L2VPN's are considered point-to-point: + +* VPWS +* EPL +* EP-LAN +* EP-TREE \ No newline at end of file From b1729f212799a468184ddf65faedb14a4a9555c3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:02:29 -0500 Subject: [PATCH 06/17] Change Virtual Circuits to L2VPN Co-authored-by: Jeremy Stretch --- netbox/ipam/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 36102f853..d331a0f7d 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -439,7 +439,7 @@ class ServiceSerializer(NetBoxModelSerializer): ] # -# Virtual Circuits +# L2VPN # From aa856e75e8897d8a4a32b571ae7f44521b0f8695 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:02:44 -0500 Subject: [PATCH 07/17] Change Virtual Circuits to L2VPN Co-authored-by: Jeremy Stretch --- netbox/ipam/api/nested_serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 07a7c9598..e74d60fb2 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -195,7 +195,7 @@ class NestedServiceSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name', 'protocol', 'ports'] # -# Virtual Circuits +# L2VPN # From 8e39e7f8306f96080d6d13ce829b4376402bc094 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:03:07 -0500 Subject: [PATCH 08/17] Change API urls to plural form Co-authored-by: Jeremy Stretch --- netbox/ipam/api/urls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index b588b6974..20e31f4d4 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -46,8 +46,8 @@ router.register('service-templates', views.ServiceTemplateViewSet) router.register('services', views.ServiceViewSet) # L2VPN -router.register('l2vpn', views.L2VPNViewSet) -router.register('l2vpn-termination', views.L2VPNTerminationViewSet) +router.register('l2vpns', views.L2VPNViewSet) +router.register('l2vpn-terminations', views.L2VPNTerminationViewSet) app_name = 'ipam-api' From dbb1773e158d3243bcbcf9a950ab266ac1fa7a1b Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:04:33 -0500 Subject: [PATCH 09/17] Remove extraneous imports Co-authored-by: Jeremy Stretch --- netbox/ipam/forms/models.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 7ef47ed2f..5f4b37729 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -8,8 +8,6 @@ from ipam.choices import * from ipam.constants import * from ipam.formfields import IPNetworkFormField from ipam.models import * -from ipam.models import ASN -from ipam.models.l2vpn import L2VPN, L2VPNTermination from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from tenancy.models import Tenant From 0004b834fb21230c0a494d262138495fd9bc03d3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:17:50 -0500 Subject: [PATCH 10/17] Commit fixes Jeremy suggested Co-authored-by: Jeremy Stretch --- netbox/ipam/api/serializers.py | 1 - netbox/ipam/api/views.py | 4 ++-- netbox/ipam/forms/models.py | 3 +++ netbox/ipam/models/l2vpn.py | 12 ++++++++---- netbox/netbox/navigation_menu.py | 2 +- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index d331a0f7d..c6e0027f1 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -483,7 +483,6 @@ class L2VPNTerminationSerializer(NetBoxModelSerializer): fields = [ 'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id', 'assigned_object', - # Extra Fields 'tags', 'custom_fields', 'created', 'last_updated' ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index f5a61c031..0407c6d39 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -18,7 +18,7 @@ from netbox.config import get_config from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import count_related from . import serializers -from ..models.l2vpn import L2VPN, L2VPNTermination +from ipam.models import L2VPN, L2VPNTermination class IPAMRootView(APIRootView): @@ -159,7 +159,7 @@ class ServiceViewSet(NetBoxModelViewSet): class L2VPNViewSet(NetBoxModelViewSet): - queryset = L2VPN.objects + queryset = L2VPN.objects.prefetch_related('import_targets', 'export_targets', 'tenant', 'tags') serializer_class = serializers.L2VPNSerializer filterset_class = filtersets.L2VPNFilterSet diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 5f4b37729..bd1dce6fd 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -893,6 +893,9 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): fields = ( 'name', 'slug', 'type', 'identifier', 'description', 'import_targets', 'export_targets', 'tenant', 'tags' ) + widgets = { + 'type': StaticSelect(), + } class L2VPNTerminationForm(NetBoxModelForm): diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index b086fa109..46cad72f8 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -34,7 +34,7 @@ class L2VPN(NetBoxModel): description = models.TextField(null=True, blank=True) tenant = models.ForeignKey( to='tenancy.Tenant', - on_delete=models.SET_NULL, + on_delete=models.PROTECT, related_name='l2vpns', blank=True, null=True @@ -85,7 +85,6 @@ class L2VPNTermination(NetBoxModel): class Meta: ordering = ('l2vpn',) verbose_name = 'L2VPN Termination' - constraints = ( models.UniqueConstraint( fields=('assigned_object_type', 'assigned_object_id'), @@ -112,5 +111,10 @@ class L2VPNTermination(NetBoxModel): # Only check if L2VPN is set and is of type P2P if self.l2vpn and self.l2vpn.type in L2VPNTypeChoices.P2P: - if L2VPNTermination.objects.filter(l2vpn=self.l2vpn).exclude(pk=self.pk).count() >= 2: - raise ValidationError(f'P2P Type L2VPNs can only have 2 terminations; first delete a termination') + terminations_count = L2VPNTermination.objects.filter(l2vpn=self.l2vpn).exclude(pk=self.pk).count() + if terminations_count >= 2: + l2vpn_type = self.l2vpn.get_type_display() + raise ValidationError( + f'{l2vpn_type} L2VPNs cannot have more than two terminations; found {terminations_count} already ' + f'defined.' + ) diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index f2245f68b..513cf4d9e 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -263,7 +263,7 @@ IPAM_MENU = Menu( MenuGroup( label='L2VPNs', items=( - get_model_item('ipam', 'l2vpn', 'L2VPN'), + get_model_item('ipam', 'l2vpn', 'L2VPNs'), get_model_item('ipam', 'l2vpntermination', 'Terminations'), ), ), From 30350e3b40dbf699fd9d9ae1c8998e41dae2d6fb Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:57:15 -0500 Subject: [PATCH 11/17] More fixes as a result of code review --- netbox/ipam/api/serializers.py | 7 +--- netbox/ipam/choices.py | 16 ++++---- netbox/ipam/forms/models.py | 32 +++++++++++++-- netbox/ipam/models/l2vpn.py | 18 +++------ netbox/ipam/tables/l2vpn.py | 23 ++++++++++- netbox/ipam/urls.py | 40 +++++++++---------- .../templates/ipam/l2vpntermination_edit.html | 16 ++++++-- 7 files changed, 98 insertions(+), 54 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index c6e0027f1..9cde08374 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -464,9 +464,7 @@ class L2VPNSerializer(NetBoxModelSerializer): model = L2VPN fields = [ 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets', - 'description', 'tenant', - # Extra Fields - 'tags', 'custom_fields', 'created', 'last_updated' + 'description', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' ] @@ -482,8 +480,7 @@ class L2VPNTerminationSerializer(NetBoxModelSerializer): model = L2VPNTermination fields = [ 'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id', - 'assigned_object', - 'tags', 'custom_fields', 'created', 'last_updated' + 'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated' ] @swagger_serializer_method(serializer_or_field=serializers.DictField) diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 72cd4ff73..298baa643 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -191,6 +191,14 @@ class L2VPNTypeChoices(ChoiceSet): (TYPE_VPWS, 'VPWS'), (TYPE_VPLS, 'VPLS'), )), + ('VXLAN', ( + (TYPE_VXLAN, 'VXLAN'), + (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), + )), + ('L2VPN E-VPN', ( + (TYPE_MPLS_EVPN, 'MPLS EVPN'), + (TYPE_PBB_EVPN, 'PBB EVPN'), + )), ('E-Line', ( (TYPE_EPL, 'EPL'), (TYPE_EVPL, 'EVPL'), @@ -203,14 +211,6 @@ class L2VPNTypeChoices(ChoiceSet): (TYPE_EPTREE, 'Ethernet Private Tree'), (TYPE_EVPTREE, 'Ethernet Virtual Private Tree'), )), - ('VXLAN', ( - (TYPE_VXLAN, 'VXLAN'), - (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), - )), - ('L2VPN E-VPN', ( - (TYPE_MPLS_EVPN, 'MPLS EVPN'), - (TYPE_PBB_EVPN, 'PBB EVPN'), - )) ) P2P = ( diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index bd1dce6fd..d2797c1cf 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -929,6 +929,20 @@ class L2VPNTerminationForm(NetBoxModelForm): } ) + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + query_params={} + ) + + vminterface = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + query_params={ + 'virtual_machine_id': '$virtual_machine' + } + ) + class Meta: model = L2VPNTermination fields = ('l2vpn', ) @@ -943,6 +957,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 VMInterface: + initial['vminterface'] = instance.assigned_object kwargs['initial'] = initial super().__init__(*args, **kwargs) @@ -950,11 +966,21 @@ class L2VPNTerminationForm(NetBoxModelForm): def clean(self): super().clean() - if not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')): + interface = self.cleaned_data.get('interface') + vlan = self.cleaned_data.get('vlan') + vminterface = self.cleaned_data.get('vminterface') + + if not (interface or vlan or vminterface): raise ValidationError('You must have either a interface or a VLAN') - if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'): + if interface and vlan and vminterface: + raise ValidationError('Cannot assign a interface, vlan and vminterface') + elif interface and vlan: raise ValidationError('Cannot assign both a interface and vlan') + elif interface and vminterface: + raise ValidationError('Cannot assign both a interface and vminterface') + elif vlan and vminterface: + raise ValidationError('Cannot assign both a vlan and vminterface') - obj = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan') + obj = interface or vlan or vminterface self.instance.assigned_object = obj diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index 46cad72f8..dd8c51984 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -60,23 +60,15 @@ class L2VPNTermination(NetBoxModel): l2vpn = models.ForeignKey( to='ipam.L2VPN', on_delete=models.CASCADE, - related_name='terminations', - blank=False, - null=False + related_name='terminations' ) - assigned_object_type = models.ForeignKey( to=ContentType, limit_choices_to=L2VPN_ASSIGNMENT_MODELS, on_delete=models.PROTECT, - related_name='+', - blank=True, - null=True - ) - assigned_object_id = models.PositiveBigIntegerField( - blank=True, - null=True + related_name='+' ) + assigned_object_id = models.PositiveBigIntegerField() assigned_object = GenericForeignKey( ct_field='assigned_object_type', fk_field='assigned_object_id' @@ -95,13 +87,13 @@ class L2VPNTermination(NetBoxModel): def __str__(self): if self.pk is not None: return f'{self.assigned_object} <> {self.l2vpn}' - return '' + return super().__str__() def get_absolute_url(self): return reverse('ipam:l2vpntermination', args=[self.pk]) def clean(self): - # Only check is assigned_object is set + # Only check is assigned_object is set. Required otherwise we have an Integrity Error thrown. if self.assigned_object: obj_id = self.assigned_object.pk obj_type = ContentType.objects.get_for_model(self.assigned_object) diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/ipam/tables/l2vpn.py index 551f692bb..a0e2f5d67 100644 --- a/netbox/ipam/tables/l2vpn.py +++ b/netbox/ipam/tables/l2vpn.py @@ -9,25 +9,44 @@ __all__ = ( 'L2VPNTerminationTable', ) +L2VPN_TARGETS = """ +{% for rt in value.all %} + {{ rt }}{% if not forloop.last %}
{% endif %} +{% endfor %} +""" + class L2VPNTable(NetBoxTable): pk = columns.ToggleColumn() name = tables.Column( linkify=True ) + import_targets = columns.TemplateColumn( + template_code=L2VPN_TARGETS, + orderable=False + ) + export_targets = columns.TemplateColumn( + template_code=L2VPN_TARGETS, + orderable=False + ) class Meta(NetBoxTable.Meta): model = L2VPN - fields = ('pk', 'name', 'description', 'slug', 'type', 'tenant', 'actions') - default_columns = ('pk', 'name', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'actions') + default_columns = ('pk', 'name', 'type', 'description', 'actions') class L2VPNTerminationTable(NetBoxTable): pk = columns.ToggleColumn() + l2vpn = tables.Column( + verbose_name='L2VPN', + linkify=True + ) assigned_object_type = columns.ContentTypeColumn( verbose_name='Object Type' ) assigned_object = tables.Column( + verbose_name='Assigned Object', linkify=True, orderable=False ) diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index e00b0365f..d27209fd2 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -187,25 +187,25 @@ urlpatterns = [ path('services//journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}), # L2VPN - path('l2vpn/', views.L2VPNListView.as_view(), name='l2vpn_list'), - path('l2vpn/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'), - path('l2vpn/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'), - path('l2vpn/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'), - path('l2vpn/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'), - path('l2vpn//', views.L2VPNView.as_view(), name='l2vpn'), - path('l2vpn//edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'), - path('l2vpn//delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'), - path('l2vpn//changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}), - path('l2vpn//journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}), + path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'), + path('l2vpns/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'), + path('l2vpns/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'), + path('l2vpns/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'), + path('l2vpns/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'), + path('l2vpns//', views.L2VPNView.as_view(), name='l2vpn'), + path('l2vpns//edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'), + path('l2vpns//delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'), + path('l2vpns//changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}), + path('l2vpns//journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}), - path('l2vpn-termination/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), - path('l2vpn-termination/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), - path('l2vpn-termination/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), - path('l2vpn-termination/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'), - path('l2vpn-termination/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), - path('l2vpn-termination//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), - path('l2vpn-termination//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), - path('l2vpn-termination//delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'), - path('l2vpn-termination//changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}), - path('l2vpn-termination//journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}), + path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), + path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), + path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), + path('l2vpn-terminations/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'), + path('l2vpn-terminations/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), + path('l2vpn-terminations//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), + path('l2vpn-terminations//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), + path('l2vpn-terminations//delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'), + path('l2vpn-terminations//changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}), + path('l2vpn-terminations//journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}), ] diff --git a/netbox/templates/ipam/l2vpntermination_edit.html b/netbox/templates/ipam/l2vpntermination_edit.html index 3fb0460b5..4ba079eb5 100644 --- a/netbox/templates/ipam/l2vpntermination_edit.html +++ b/netbox/templates/ipam/l2vpntermination_edit.html @@ -12,7 +12,7 @@
- {% render_field form.device %} -
+
+ {% render_field form.device %} {% render_field form.vlan %}
+ {% render_field form.device %} {% render_field form.interface %}
+
+ {% render_field form.virtual_machine %} + {% render_field form.vminterface %} +
From 5bcc3a3fb9636d0b24c42a46383dc964f01287e3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 09:00:33 -0500 Subject: [PATCH 12/17] Update docs --- docs/models/ipam/l2vpn.md | 2 ++ docs/models/ipam/l2vpntermination.md | 5 ++++- netbox/ipam/forms/models.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/models/ipam/l2vpn.md b/docs/models/ipam/l2vpn.md index 9c50a6407..9f9b4703c 100644 --- a/docs/models/ipam/l2vpn.md +++ b/docs/models/ipam/l2vpn.md @@ -17,3 +17,5 @@ Each L2VPN instance must have one of the following type associated with it: * MPLS-EVPN * PBB-EVPN +!!!note + Choosing VPWS, EPL, EP-LAN, EP-TREE will result in only being able to add 2 terminations to a given L2VPN. diff --git a/docs/models/ipam/l2vpntermination.md b/docs/models/ipam/l2vpntermination.md index 9135f72a3..cc1843639 100644 --- a/docs/models/ipam/l2vpntermination.md +++ b/docs/models/ipam/l2vpntermination.md @@ -9,4 +9,7 @@ The following types of L2VPN's are considered point-to-point: * VPWS * EPL * EP-LAN -* EP-TREE \ No newline at end of file +* EP-TREE + +!!!note + Choosing any of the above types of L2VPN's will result in only being able to add 2 terminations to a given L2VPN. diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index d2797c1cf..43e33dd4d 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -976,7 +976,7 @@ class L2VPNTerminationForm(NetBoxModelForm): if interface and vlan and vminterface: raise ValidationError('Cannot assign a interface, vlan and vminterface') elif interface and vlan: - raise ValidationError('Cannot assign both a interface and vlan') + raise Validatio`nError('Cannot assign both a interface and vlan') elif interface and vminterface: raise ValidationError('Cannot assign both a interface and vminterface') elif vlan and vminterface: From f1c8926252e923589161ede1cc7b4cd6432806c1 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 09:01:08 -0500 Subject: [PATCH 13/17] Fix error --- netbox/ipam/forms/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 43e33dd4d..d2797c1cf 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -976,7 +976,7 @@ class L2VPNTerminationForm(NetBoxModelForm): if interface and vlan and vminterface: raise ValidationError('Cannot assign a interface, vlan and vminterface') elif interface and vlan: - raise Validatio`nError('Cannot assign both a interface and vlan') + raise ValidationError('Cannot assign both a interface and vlan') elif interface and vminterface: raise ValidationError('Cannot assign both a interface and vminterface') elif vlan and vminterface: From 878c465c56b5a656c4f99ebc9499d11274a7692e Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 09:10:10 -0500 Subject: [PATCH 14/17] Update Termination table rendering on L2VPN View --- netbox/templates/ipam/l2vpn.html | 34 ++------------------------------ 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/ipam/l2vpn.html index 59cc6234b..130940b02 100644 --- a/netbox/templates/ipam/l2vpn.html +++ b/netbox/templates/ipam/l2vpn.html @@ -59,39 +59,9 @@
-
L2VPN Terminations
+
Terminations
- {% with terminations=object.terminations.all %} - {% if terminations.exists %} - - - - - - - {% for termination in terminations %} - - - - - - {% endfor %} -
Termination TypeTermination
{{ termination.assigned_object|meta:"verbose_name" }}{{ termination.assigned_object|linkify }} - {% if perms.ipam.change_l2vpntermination %} - - - - {% endif %} - {% if perms.ipam.delete_l2vpntermination %} - - - - {% endif %} -
- {% else %} -
None
- {% endif %} - {% endwith %} + {% render_table terminations_table 'inc/table.html' %}
{% if perms.ipam.add_l2vpntermination %}