Explorar el Código

L2VPN Clean Tree

Daniel Sheppard hace 3 años
padre
commit
03f1584d3a

+ 5 - 0
netbox/dcim/models/device_components.py

@@ -649,6 +649,11 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
         object_id_field='interface_id',
         object_id_field='interface_id',
         related_query_name='+'
         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']
     clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type']
 
 

+ 27 - 0
netbox/ipam/api/nested_serializers.py

@@ -1,6 +1,7 @@
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from ipam import models
 from ipam import models
+from ipam.models.l2vpn import L2VPNTermination, L2VPN
 from netbox.api import WritableNestedSerializer
 from netbox.api import WritableNestedSerializer
 
 
 __all__ = [
 __all__ = [
@@ -190,3 +191,29 @@ class NestedServiceSerializer(WritableNestedSerializer):
     class Meta:
     class Meta:
         model = models.Service
         model = models.Service
         fields = ['id', 'url', 'display', 'name', 'protocol', 'ports']
         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'
+        ]
+

+ 58 - 0
netbox/ipam/api/serializers.py

@@ -19,6 +19,9 @@ from .nested_serializers import *
 #
 #
 # ASNs
 # ASNs
 #
 #
+from .nested_serializers import NestedL2VPNSerializer
+from ..models.l2vpn import L2VPNTermination, L2VPN
+
 
 
 class ASNSerializer(NetBoxModelSerializer):
 class ASNSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
     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',
             'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses',
             'description', 'tags', 'custom_fields', 'created', 'last_updated',
             '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

+ 4 - 0
netbox/ipam/api/urls.py

@@ -45,6 +45,10 @@ router.register('vlans', views.VLANViewSet)
 router.register('service-templates', views.ServiceTemplateViewSet)
 router.register('service-templates', views.ServiceTemplateViewSet)
 router.register('services', views.ServiceViewSet)
 router.register('services', views.ServiceViewSet)
 
 
+# L2VPN
+router.register('l2vpn', views.L2VPNViewSet)
+router.register('l2vpn-termination', views.L2VPNTerminationViewSet)
+
 app_name = 'ipam-api'
 app_name = 'ipam-api'
 
 
 urlpatterns = [
 urlpatterns = [

+ 13 - 0
netbox/ipam/api/views.py

@@ -18,6 +18,7 @@ from netbox.config import get_config
 from utilities.constants import ADVISORY_LOCK_KEYS
 from utilities.constants import ADVISORY_LOCK_KEYS
 from utilities.utils import count_related
 from utilities.utils import count_related
 from . import serializers
 from . import serializers
+from ..models.l2vpn import L2VPN, L2VPNTermination
 
 
 
 
 class IPAMRootView(APIRootView):
 class IPAMRootView(APIRootView):
@@ -157,6 +158,18 @@ class ServiceViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.ServiceFilterSet
     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
 # Views
 #
 #

+ 50 - 0
netbox/ipam/choices.py

@@ -170,3 +170,53 @@ class ServiceProtocolChoices(ChoiceSet):
         (PROTOCOL_UDP, 'UDP'),
         (PROTOCOL_UDP, 'UDP'),
         (PROTOCOL_SCTP, 'SCTP'),
         (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
+    )

+ 6 - 0
netbox/ipam/constants.py

@@ -90,3 +90,9 @@ VLANGROUP_SCOPE_TYPES = (
 # 16-bit port number
 # 16-bit port number
 SERVICE_PORT_MIN = 1
 SERVICE_PORT_MIN = 1
 SERVICE_PORT_MAX = 65535
 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')
+)

+ 65 - 0
netbox/ipam/filtersets.py

@@ -23,6 +23,8 @@ __all__ = (
     'FHRPGroupFilterSet',
     'FHRPGroupFilterSet',
     'IPAddressFilterSet',
     'IPAddressFilterSet',
     'IPRangeFilterSet',
     'IPRangeFilterSet',
+    'L2VPNFilterSet',
+    'L2VPNTerminationFilterSet',
     'PrefixFilterSet',
     'PrefixFilterSet',
     'RIRFilterSet',
     'RIRFilterSet',
     'RoleFilterSet',
     'RoleFilterSet',
@@ -922,3 +924,66 @@ class ServiceFilterSet(NetBoxModelFilterSet):
             return queryset
             return queryset
         qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
         qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
         return queryset.filter(qs_filter)
         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)

+ 18 - 0
netbox/ipam/forms/bulk_edit.py

@@ -18,6 +18,7 @@ __all__ = (
     'FHRPGroupBulkEditForm',
     'FHRPGroupBulkEditForm',
     'IPAddressBulkEditForm',
     'IPAddressBulkEditForm',
     'IPRangeBulkEditForm',
     'IPRangeBulkEditForm',
+    'L2VPNBulkEditForm',
     'PrefixBulkEditForm',
     'PrefixBulkEditForm',
     'RIRBulkEditForm',
     'RIRBulkEditForm',
     'RoleBulkEditForm',
     'RoleBulkEditForm',
@@ -440,3 +441,20 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
 
 
 class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
 class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
     model = Service
     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',)

+ 74 - 0
netbox/ipam/forms/bulk_import.py

@@ -1,5 +1,6 @@
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 
 
 from dcim.models import Device, Interface, Site
 from dcim.models import Device, Interface, Site
 from ipam.choices import *
 from ipam.choices import *
@@ -16,6 +17,8 @@ __all__ = (
     'FHRPGroupCSVForm',
     'FHRPGroupCSVForm',
     'IPAddressCSVForm',
     'IPAddressCSVForm',
     'IPRangeCSVForm',
     'IPRangeCSVForm',
+    'L2VPNCSVForm',
+    'L2VPNTerminationCSVForm',
     'PrefixCSVForm',
     'PrefixCSVForm',
     'RIRCSVForm',
     'RIRCSVForm',
     'RoleCSVForm',
     'RoleCSVForm',
@@ -425,3 +428,74 @@ class ServiceCSVForm(NetBoxModelCSVForm):
     class Meta:
     class Meta:
         model = Service
         model = Service
         fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description')
         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')

+ 29 - 0
netbox/ipam/forms/filtersets.py

@@ -19,6 +19,8 @@ __all__ = (
     'FHRPGroupFilterForm',
     'FHRPGroupFilterForm',
     'IPAddressFilterForm',
     'IPAddressFilterForm',
     'IPRangeFilterForm',
     'IPRangeFilterForm',
+    'L2VPNFilterForm',
+    'L2VPNTerminationFilterForm',
     'PrefixFilterForm',
     'PrefixFilterForm',
     'RIRFilterForm',
     'RIRFilterForm',
     'RoleFilterForm',
     'RoleFilterForm',
@@ -463,3 +465,30 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
 
 
 class ServiceFilterForm(ServiceTemplateFilterForm):
 class ServiceFilterForm(ServiceTemplateFilterForm):
     model = Service
     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'
+    )

+ 96 - 0
netbox/ipam/forms/models.py

@@ -1,5 +1,6 @@
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 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 dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
 from extras.models import Tag
 from extras.models import Tag
@@ -8,8 +9,10 @@ from ipam.constants import *
 from ipam.formfields import IPNetworkFormField
 from ipam.formfields import IPNetworkFormField
 from ipam.models import *
 from ipam.models import *
 from ipam.models import ASN
 from ipam.models import ASN
+from ipam.models.l2vpn import L2VPN, L2VPNTermination
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
+from tenancy.models import Tenant
 from utilities.exceptions import PermissionsViolation
 from utilities.exceptions import PermissionsViolation
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField,
     add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField,
@@ -26,6 +29,8 @@ __all__ = (
     'IPAddressBulkAddForm',
     'IPAddressBulkAddForm',
     'IPAddressForm',
     'IPAddressForm',
     'IPRangeForm',
     'IPRangeForm',
+    'L2VPNForm',
+    'L2VPNTerminationForm',
     'PrefixForm',
     'PrefixForm',
     'RIRForm',
     'RIRForm',
     'RoleForm',
     'RoleForm',
@@ -861,3 +866,94 @@ class ServiceCreateForm(ServiceForm):
                 self.cleaned_data['description'] = service_template.description
                 self.cleaned_data['description'] = service_template.description
         elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
         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.")
             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

+ 3 - 0
netbox/ipam/models/__init__.py

@@ -2,6 +2,7 @@
 from .fhrp import *
 from .fhrp import *
 from .vrfs import *
 from .vrfs import *
 from .ip import *
 from .ip import *
+from .l2vpn import *
 from .services import *
 from .services import *
 from .vlans import *
 from .vlans import *
 
 
@@ -12,6 +13,8 @@ __all__ = (
     'IPRange',
     'IPRange',
     'FHRPGroup',
     'FHRPGroup',
     'FHRPGroupAssignment',
     'FHRPGroupAssignment',
+    'L2VPN',
+    'L2VPNTermination',
     'Prefix',
     'Prefix',
     'RIR',
     'RIR',
     'Role',
     'Role',

+ 116 - 0
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')

+ 8 - 1
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.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
@@ -8,6 +8,7 @@ from django.urls import reverse
 from dcim.models import Interface
 from dcim.models import Interface
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
+from ipam.models import L2VPNTermination
 from ipam.querysets import VLANQuerySet
 from ipam.querysets import VLANQuerySet
 from netbox.models import OrganizationalModel, NetBoxModel
 from netbox.models import OrganizationalModel, NetBoxModel
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
@@ -173,6 +174,12 @@ class VLAN(NetBoxModel):
         blank=True
         blank=True
     )
     )
 
 
+    l2vpn = GenericRelation(
+        to='ipam.L2VPNTermination',
+        content_type_field='assigned_object_type',
+        object_id_field='assigned_object_id',
+    )
+
     objects = VLANQuerySet.as_manager()
     objects = VLANQuerySet.as_manager()
 
 
     clone_fields = [
     clone_fields = [

+ 1 - 0
netbox/ipam/tables/__init__.py

@@ -1,5 +1,6 @@
 from .fhrp import *
 from .fhrp import *
 from .ip import *
 from .ip import *
+from .l2vpn import *
 from .services import *
 from .services import *
 from .vlans import *
 from .vlans import *
 from .vrfs import *
 from .vrfs import *

+ 38 - 0
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')

+ 93 - 0
netbox/ipam/tests/test_api.py

@@ -914,3 +914,96 @@ class ServiceTest(APIViewTestCases.APIViewTestCase):
                 'ports': [6],
                 '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]
+        }

+ 101 - 0
netbox/ipam/tests/test_filtersets.py

@@ -1463,3 +1463,104 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'virtual_machine': [vms[0].name, vms[1].name]}
         params = {'virtual_machine': [vms[0].name, vms[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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)

+ 10 - 0
netbox/ipam/tests/test_models.py

@@ -538,3 +538,13 @@ class TestVLANGroup(TestCase):
 
 
         VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
         VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
         self.assertEqual(vlangroup.get_next_available_vid(), 105)
         self.assertEqual(vlangroup.get_next_available_vid(), 105)
+
+
+class TestL2VPN(TestCase):
+    # TODO: L2VPN Tests
+    pass
+
+
+class TestL2VPNTermination(TestCase):
+    # TODO: L2VPN Termination Tests
+    pass

+ 10 - 0
netbox/ipam/tests/test_views.py

@@ -746,3 +746,13 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertEqual(instance.protocol, service_template.protocol)
         self.assertEqual(instance.protocol, service_template.protocol)
         self.assertEqual(instance.ports, service_template.ports)
         self.assertEqual(instance.ports, service_template.ports)
         self.assertEqual(instance.description, service_template.description)
         self.assertEqual(instance.description, service_template.description)
+
+
+class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    # TODO: L2VPN Tests
+    pass
+
+
+class L2VPNTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    # TODO: L2VPN Termination Tests
+    pass

+ 21 - 0
netbox/ipam/urls.py

@@ -186,4 +186,25 @@ urlpatterns = [
     path('services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
     path('services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
     path('services/<int:pk>/journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}),
     path('services/<int:pk>/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/<int:pk>/', views.L2VPNView.as_view(), name='l2vpn'),
+    path('l2vpn/<int:pk>/edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'),
+    path('l2vpn/<int:pk>/delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'),
+    path('l2vpn/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}),
+    path('l2vpn/<int:pk>/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/<int:pk>/', views.L2VPNTerminationView.as_view(), name='l2vpntermination'),
+    path('l2vpn-termination/<int:pk>/edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'),
+    path('l2vpn-termination/<int:pk>/delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'),
+    path('l2vpn-termination/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}),
+    path('l2vpn-termination/<int:pk>/journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}),
 ]
 ]

+ 96 - 0
netbox/ipam/views.py

@@ -17,6 +17,7 @@ from . import filtersets, forms, tables
 from .constants import *
 from .constants import *
 from .models import *
 from .models import *
 from .models import ASN
 from .models import ASN
+from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable
 from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
 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')
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     filterset = filtersets.ServiceFilterSet
     filterset = filtersets.ServiceFilterSet
     table = tables.ServiceTable
     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
     form = forms.ServiceBulkEditForm
 
 
 
 

+ 7 - 0
netbox/netbox/navigation_menu.py

@@ -260,6 +260,13 @@ IPAM_MENU = Menu(
                 get_model_item('ipam', 'vlangroup', 'VLAN Groups'),
                 get_model_item('ipam', 'vlangroup', 'VLAN Groups'),
             ),
             ),
         ),
         ),
+        MenuGroup(
+            label='L2VPNs',
+            items=(
+                get_model_item('ipam', 'l2vpn', 'L2VPN'),
+                get_model_item('ipam', 'l2vpntermination', 'Terminations'),
+            ),
+        ),
         MenuGroup(
         MenuGroup(
             label='Other',
             label='Other',
             items=(
             items=(

+ 111 - 0
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 %}
+<div class="row mb-3">
+	<div class="col col-md-6">
+        <div class="card">
+            <h5 class="card-header">
+                L2VPN Attributes
+            </h5>
+            <div class="card-body">
+                <table class="table table-hover attr-table
+                    <tr>
+                        <th scope="row">Name</th>
+                        <td>{{ object.name|placeholder }}</td>
+                    </tr>
+                    <tr>
+                        <th scope="row">Slug</th>
+                        <td>{{ object.slug|placeholder }}</td>
+                    </tr>
+                    <tr>
+                        <th scope="row">Identifier</th>
+                        <td>{{ object.identifier|placeholder }}</td>
+                    </tr>
+                    <tr>
+                        <th scope="row">Type</th>
+                        <td>{{ object.get_type_display }}</td>
+                    </tr>
+                    <tr>
+                        <th scope="row">Description</th>
+                        <td>{{ object.description|placeholder }}</td>
+                    </tr>
+                    <tr>
+                        <th scope="row">Tenant</th>
+                        <td>{{ object.tenant|placeholder }}</td>
+                    </tr>
+                </table>
+            </div>
+        </div>
+        {% include 'inc/panels/contacts.html' %}
+        {% plugin_left_page object %}
+	</div>
+	<div class="col col-md-6">
+        {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:circuit_list' %}
+        {% include 'inc/panels/custom_fields.html' %}
+        {% plugin_right_page object %}
+    </div>
+</div>
+<div class="row mb-3">
+	<div class="col col-md-6">
+    {% include 'inc/panel_table.html' with table=import_targets_table heading="Import Route Targets" %}
+  </div>
+	<div class="col col-md-6">
+    {% include 'inc/panel_table.html' with table=export_targets_table heading="Export Route Targets" %}
+  </div>
+</div>
+<div class="row mb-3">
+	<div class="col col-md-12">
+        <div class="card">
+          <h5 class="card-header">L2VPN Terminations</h5>
+          <div class="card-body">
+            {% with terminations=object.terminations.all %}
+              {% if terminations.exists %}
+                <table class="table table-hover">
+                  <tr>
+                    <th>Termination Type</th>
+                    <th>Termination</th>
+                    <th></th>
+                  </tr>
+                  {% for termination in terminations %}
+                    <tr>
+                      <td>{{ termination.assigned_object|meta:"verbose_name" }}</td>
+                      <td>{{ termination.assigned_object|linkify }}</td>
+                      <td class="text-end noprint">
+                        {% if perms.ipam.change_l2vpntermination %}
+                          <a href="{% url 'ipam:l2vpntermination_edit' pk=termination.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-warning btn-sm lh-1" title="Edit">
+                            <i class="mdi mdi-pencil" aria-hidden="true"></i>
+                          </a>
+                        {% endif %}
+                        {% if perms.ipam.delete_l2vpntermination %}
+                          <a href="{% url 'ipam:l2vpntermination_delete' pk=termination.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-danger btn-sm lh-1" title="Delete">
+                            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
+                          </a>
+                        {% endif %}
+                      </td>
+                    </tr>
+                  {% endfor %}
+                </table>
+              {% else %}
+                <div class="text-muted">None</div>
+              {% endif %}
+            {% endwith %}
+          </div>
+          {% if perms.ipam.add_l2vpntermination %}
+            <div class="card-footer text-end noprint">
+              <a href="{% url 'ipam:l2vpntermination_add' %}?l2vpn={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a Termination
+              </a>
+            </div>
+          {% endif %}
+        </div>
+    </div>
+</div>
+<div class="row mb-3">
+	<div class="col col-md-12">
+        {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}

+ 31 - 0
netbox/templates/ipam/l2vpntermination.html

@@ -0,0 +1,31 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+
+{% block content %}
+<div class="row">
+	<div class="col col-md-6">
+        <div class="card">
+            <h5 class="card-header">
+                L2VPN Attributes
+            </h5>
+            <div class="card-body">
+                <table class="table table-hover">
+                    <tr>
+                        <th scope="row">L2vPN</th>
+                        <td>{{ object.l2vpn.name|placeholder }}</td>
+                    </tr>
+                    <tr>
+                        <th scope="row">Assigned Object</th>
+                        <td>{{ object.assigned_object.name|placeholder }}</td>
+                    </tr>
+                </table>
+            </div>
+        </div>
+	</div>
+	<div class="col col-md-6">
+        {% include 'inc/panels/custom_fields.html' %}
+        {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:l2vpntermination_list' %}
+    </div>
+</div>
+
+{% endblock %}

+ 39 - 0
netbox/templates/ipam/l2vpntermination_edit.html

@@ -0,0 +1,39 @@
+{% extends 'generic/object_edit.html' %}
+{% load helpers %}
+{% load form_helpers %}
+
+{% block form %}
+  <div class="field-group my-5">
+    <div class="row mb-2">
+      <h5 class="offset-sm-3">L2VPN Termination</h5>
+    </div>
+    {% render_field form.l2vpn %}
+    <div class="row mb-3">
+      <div class="offset-sm-3">
+        <ul class="nav nav-pills" role="tablist">
+          <li role="presentation" class="nav-item">
+            <button role="tab" type="button" id="vlan_tab" data-bs-toggle="tab" aria-controls="vlan" data-bs-target="#vlan" class="nav-link {% if not form.initial.interface %}active{% endif %}">
+              VLAN
+            </button>
+          </li>
+          <li role="presentation" class="nav-item">
+            <button role="tab" type="button" id="interface_tab" data-bs-toggle="tab" aria-controls="interface" data-bs-target="#interface" class="nav-link {% if form.initial.interface %}active{% endif %}">
+              Interface
+            </button>
+          </li>
+        </ul>
+      </div>
+    </div>
+    <div class="row mb-3">
+      <div class="tab-content p-0 border-0">
+        {% render_field form.device %}
+        <div class="tab-pane {% if not form.initial.interface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab">
+          {% render_field form.vlan %}
+        </div>
+        <div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">
+          {% render_field form.interface %}
+        </div>
+      </div>
+    </div>
+  </div>
+{% endblock %}