From 03f1584d3aadf5bd37c31e439a75a1b2bd064db3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 27 Jun 2022 23:24:50 -0500 Subject: [PATCH] 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 %}