Просмотр исходного кода

Closes #14311: Move L2VPN models from `ipam` to `vpn` (#14358)

* Move L2VPN and L2VPNTermination models from ipam to vpn

* Move L2VPN resources from ipam to vpn

* Extend migration to update content types

* Misc cleanup
Jeremy Stretch 2 лет назад
Родитель
Сommit
d2fea4edc4
66 измененных файлов с 1616 добавлено и 1441 удалено
  1. 3 3
      netbox/dcim/api/serializers.py
  2. 2 1
      netbox/dcim/filtersets.py
  3. 2 1
      netbox/dcim/forms/filtersets.py
  4. 1 1
      netbox/dcim/models/device_components.py
  5. 2 2
      netbox/dcim/tables/template_code.py
  6. 0 28
      netbox/ipam/api/nested_serializers.py
  7. 2 52
      netbox/ipam/api/serializers.py
  8. 0 2
      netbox/ipam/api/urls.py
  9. 0 13
      netbox/ipam/api/views.py
  10. 0 49
      netbox/ipam/choices.py
  11. 0 6
      netbox/ipam/constants.py
  12. 2 179
      netbox/ipam/filtersets.py
  13. 0 31
      netbox/ipam/forms/bulk_edit.py
  14. 0 92
      netbox/ipam/forms/bulk_import.py
  15. 2 93
      netbox/ipam/forms/filtersets.py
  16. 0 96
      netbox/ipam/forms/model_forms.py
  17. 2 15
      netbox/ipam/graphql/schema.py
  18. 0 19
      netbox/ipam/graphql/types.py
  19. 64 0
      netbox/ipam/migrations/0068_move_l2vpn.py
  20. 0 22
      netbox/ipam/models/__init__.py
  21. 1 2
      netbox/ipam/models/vlans.py
  22. 1 13
      netbox/ipam/search.py
  23. 0 1
      netbox/ipam/tables/__init__.py
  24. 0 93
      netbox/ipam/tests/test_api.py
  25. 1 161
      netbox/ipam/tests/test_filtersets.py
  26. 3 77
      netbox/ipam/tests/test_models.py
  27. 1 140
      netbox/ipam/tests/test_views.py
  28. 0 16
      netbox/ipam/urls.py
  29. 1 112
      netbox/ipam/views.py
  30. 2 2
      netbox/netbox/navigation/menu.py
  31. 2 2
      netbox/templates/ipam/routetarget.html
  32. 4 4
      netbox/templates/vpn/l2vpn.html
  33. 1 1
      netbox/templates/vpn/l2vpntermination.html
  34. 0 0
      netbox/templates/vpn/l2vpntermination_edit.html
  35. 2 3
      netbox/virtualization/api/serializers.py
  36. 2 1
      netbox/virtualization/forms/filtersets.py
  37. 1 1
      netbox/virtualization/models/virtualmachines.py
  38. 2 2
      netbox/virtualization/tables/virtualmachines.py
  39. 27 0
      netbox/vpn/api/nested_serializers.py
  40. 55 1
      netbox/vpn/api/serializers.py
  41. 2 0
      netbox/vpn/api/urls.py
  42. 14 0
      netbox/vpn/api/views.py
  43. 53 0
      netbox/vpn/choices.py
  44. 7 0
      netbox/vpn/constants.py
  45. 177 3
      netbox/vpn/filtersets.py
  46. 31 0
      netbox/vpn/forms/bulk_edit.py
  47. 93 1
      netbox/vpn/forms/bulk_import.py
  48. 98 1
      netbox/vpn/forms/filtersets.py
  49. 98 2
      netbox/vpn/forms/model_forms.py
  50. 30 0
      netbox/vpn/graphql/gfk_mixins.py
  51. 12 0
      netbox/vpn/graphql/schema.py
  52. 21 1
      netbox/vpn/graphql/types.py
  53. 73 0
      netbox/vpn/migrations/0002_move_l2vpn.py
  54. 1 0
      netbox/vpn/models/__init__.py
  55. 7 7
      netbox/vpn/models/l2vpn.py
  56. 12 0
      netbox/vpn/search.py
  57. 3 0
      netbox/vpn/tables/__init__.py
  58. 0 81
      netbox/vpn/tables/crypto.py
  59. 3 3
      netbox/vpn/tables/l2vpn.py
  60. 87 0
      netbox/vpn/tables/tunnels.py
  61. 94 0
      netbox/vpn/tests/test_api.py
  62. 165 4
      netbox/vpn/tests/test_filtersets.py
  63. 79 0
      netbox/vpn/tests/test_models.py
  64. 141 1
      netbox/vpn/tests/test_views.py
  65. 16 0
      netbox/vpn/urls.py
  66. 111 0
      netbox/vpn/views.py

+ 3 - 3
netbox/dcim/api/serializers.py

@@ -2,8 +2,8 @@ import decimal
 
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
-from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from timezone_field.rest_framework import TimeZoneSerializerField
 
@@ -12,8 +12,7 @@ from dcim.constants import *
 from dcim.models import *
 from extras.api.nested_serializers import NestedConfigTemplateSerializer
 from ipam.api.nested_serializers import (
-    NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
-    NestedVRFSerializer,
+    NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
 )
 from ipam.models import ASN, VLAN
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
@@ -27,6 +26,7 @@ from tenancy.api.nested_serializers import NestedTenantSerializer
 from users.api.nested_serializers import NestedUserSerializer
 from utilities.api import get_serializer_for_model
 from virtualization.api.nested_serializers import NestedClusterSerializer
+from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer
 from wireless.api.nested_serializers import NestedWirelessLANSerializer, NestedWirelessLinkSerializer
 from wireless.choices import *
 from wireless.models import WirelessLAN

+ 2 - 1
netbox/dcim/filtersets.py

@@ -5,7 +5,7 @@ from django.utils.translation import gettext as _
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate
 from ipam.filtersets import PrimaryIPFilterSet
-from ipam.models import ASN, L2VPN, IPAddress, VRF
+from ipam.models import ASN, IPAddress, VRF
 from netbox.filtersets import (
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
 )
@@ -17,6 +17,7 @@ from utilities.filters import (
     TreeNodeMultipleChoiceFilter,
 )
 from virtualization.models import Cluster
+from vpn.models import L2VPN
 from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
 from .choices import *
 from .constants import *

+ 2 - 1
netbox/dcim/forms/filtersets.py

@@ -7,12 +7,13 @@ from dcim.constants import *
 from dcim.models import *
 from extras.forms import LocalConfigContextFilterForm
 from extras.models import ConfigTemplate
-from ipam.models import ASN, L2VPN, VRF
+from ipam.models import ASN, VRF
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms.widgets import APISelectMultiple, NumberWithOptions
+from vpn.models import L2VPN
 from wireless.choices import *
 
 __all__ = (

+ 1 - 1
netbox/dcim/models/device_components.py

@@ -730,7 +730,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         related_query_name='interface'
     )
     l2vpn_terminations = GenericRelation(
-        to='ipam.L2VPNTermination',
+        to='vpn.L2VPNTermination',
         content_type_field='assigned_object_type',
         object_id_field='assigned_object_id',
         related_query_name='interface',

+ 2 - 2
netbox/dcim/tables/template_code.py

@@ -316,8 +316,8 @@ INTERFACE_BUTTONS = """
       {% if perms.dcim.add_interface %}
         <li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ record.device_id }}&parent={{ record.pk }}&name={{ record.name }}.&type=virtual&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Child Interface</a></li>
       {% endif %}
-      {% if perms.ipam.add_l2vpntermination %}
-        <li><a class="dropdown-item" href="{% url 'ipam:l2vpntermination_add' %}?device={{ object.pk }}&interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">L2VPN Termination</a></li>
+      {% if perms.vpn.add_l2vpntermination %}
+        <li><a class="dropdown-item" href="{% url 'vpn:l2vpntermination_add' %}?device={{ object.pk }}&interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">L2VPN Termination</a></li>
       {% endif %}
       {% if perms.ipam.add_fhrpgroupassignment %}
         <li><a class="dropdown-item" href="{% url 'ipam:fhrpgroupassignment_add' %}?interface_type={{ record|content_type_id }}&interface_id={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Assign FHRP Group</a></li>

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

@@ -2,7 +2,6 @@ from drf_spectacular.utils import extend_schema_serializer
 from rest_framework import serializers
 
 from ipam import models
-from ipam.models.l2vpn import L2VPNTermination, L2VPN
 from netbox.api.serializers import WritableNestedSerializer
 from .field_serializers import IPAddressField
 
@@ -14,8 +13,6 @@ __all__ = [
     'NestedFHRPGroupAssignmentSerializer',
     'NestedIPAddressSerializer',
     'NestedIPRangeSerializer',
-    'NestedL2VPNSerializer',
-    'NestedL2VPNTerminationSerializer',
     'NestedPrefixSerializer',
     'NestedRIRSerializer',
     'NestedRoleSerializer',
@@ -223,28 +220,3 @@ class NestedServiceSerializer(WritableNestedSerializer):
     class Meta:
         model = models.Service
         fields = ['id', 'url', 'display', 'name', 'protocol', 'ports']
-
-#
-# L2VPN
-#
-
-
-class NestedL2VPNSerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn-detail')
-
-    class Meta:
-        model = L2VPN
-        fields = [
-            'id', 'url', 'display', 'identifier', 'name', 'slug', 'type'
-        ]
-
-
-class NestedL2VPNTerminationSerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail')
-    l2vpn = NestedL2VPNSerializer()
-
-    class Meta:
-        model = L2VPNTermination
-        fields = [
-            'id', 'url', 'display', 'l2vpn'
-        ]

+ 2 - 52
netbox/ipam/api/serializers.py

@@ -12,8 +12,9 @@ from netbox.constants import NESTED_SERIALIZER_PREFIX
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import get_serializer_for_model
 from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
-from .nested_serializers import *
+from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer
 from .field_serializers import IPAddressField, IPNetworkField
+from .nested_serializers import *
 
 
 #
@@ -479,54 +480,3 @@ class ServiceSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses',
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
-
-#
-# L2VPN
-#
-
-
-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', 'comments', 'tenant', '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', 'tags', 'custom_fields', 'created', 'last_updated'
-        ]
-
-    @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_assigned_object(self, instance):
-        serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX)
-        context = {'request': self.context['request']}
-        return serializer(instance.assigned_object, context=context).data

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

@@ -23,8 +23,6 @@ router.register('vlan-groups', views.VLANGroupViewSet)
 router.register('vlans', views.VLANViewSet)
 router.register('service-templates', views.ServiceTemplateViewSet)
 router.register('services', views.ServiceViewSet)
-router.register('l2vpns', views.L2VPNViewSet)
-router.register('l2vpn-terminations', views.L2VPNTerminationViewSet)
 
 app_name = 'ipam-api'
 

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

@@ -14,7 +14,6 @@ from circuits.models import Provider
 from dcim.models import Site
 from ipam import filtersets
 from ipam.models import *
-from ipam.models import L2VPN, L2VPNTermination
 from ipam.utils import get_next_available_prefix
 from netbox.api.viewsets import NetBoxModelViewSet
 from netbox.api.viewsets.mixins import ObjectValidationMixin
@@ -178,18 +177,6 @@ class ServiceViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.ServiceFilterSet
 
 
-class L2VPNViewSet(NetBoxModelViewSet):
-    queryset = L2VPN.objects.prefetch_related('import_targets', 'export_targets', 'tenant', 'tags')
-    serializer_class = serializers.L2VPNSerializer
-    filterset_class = filtersets.L2VPNFilterSet
-
-
-class L2VPNTerminationViewSet(NetBoxModelViewSet):
-    queryset = L2VPNTermination.objects.prefetch_related('assigned_object')
-    serializer_class = serializers.L2VPNTerminationSerializer
-    filterset_class = filtersets.L2VPNTerminationFilterSet
-
-
 #
 # Views
 #

+ 0 - 49
netbox/ipam/choices.py

@@ -172,52 +172,3 @@ 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'),
-        )),
-        ('VXLAN', (
-            (TYPE_VXLAN, 'VXLAN'),
-            (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'),
-        )),
-        ('L2VPN E-VPN', (
-            (TYPE_MPLS_EVPN, 'MPLS EVPN'),
-            (TYPE_PBB_EVPN, 'PBB EVPN'),
-        )),
-        ('E-Line', (
-            (TYPE_EPL, 'EPL'),
-            (TYPE_EVPL, 'EVPL'),
-        )),
-        ('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'),
-        )),
-    )
-
-    P2P = (
-        TYPE_VPWS,
-        TYPE_EPL,
-        TYPE_EPLAN,
-        TYPE_EPTREE
-    )

+ 0 - 6
netbox/ipam/constants.py

@@ -86,9 +86,3 @@ 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')
-)

+ 2 - 179
netbox/ipam/filtersets.py

@@ -4,8 +4,8 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.db.models import Q
 from django.utils.translation import gettext as _
-from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
 from netaddr.core import AddrFormatError
 
 from dcim.models import Device, Interface, Region, Site, SiteGroup
@@ -15,6 +15,7 @@ from utilities.filters import (
     ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
 )
 from virtualization.models import VirtualMachine, VMInterface
+from vpn.models import L2VPN
 from .choices import *
 from .models import *
 
@@ -26,8 +27,6 @@ __all__ = (
     'FHRPGroupFilterSet',
     'IPAddressFilterSet',
     'IPRangeFilterSet',
-    'L2VPNFilterSet',
-    'L2VPNTerminationFilterSet',
     'PrefixFilterSet',
     'PrimaryIPFilterSet',
     'RIRFilterSet',
@@ -1059,182 +1058,6 @@ class ServiceFilterSet(NetBoxModelFilterSet):
         return queryset.filter(qs_filter)
 
 
-#
-# L2VPN
-#
-
-class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
-    type = django_filters.MultipleChoiceFilter(
-        choices=L2VPNTypeChoices,
-        null_value=None
-    )
-    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 = ['id', 'identifier', 'name', 'slug', 'type', 'description']
-
-    def search(self, queryset, name, value):
-        if not value.strip():
-            return queryset
-        qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
-        try:
-            qs_filter |= Q(identifier=int(value))
-        except ValueError:
-            pass
-        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__slug',
-        queryset=L2VPN.objects.all(),
-        to_field_name='slug',
-        label=_('L2VPN (slug)'),
-    )
-    region = MultiValueCharFilter(
-        method='filter_region',
-        field_name='slug',
-        label=_('Region (slug)'),
-    )
-    region_id = MultiValueNumberFilter(
-        method='filter_region',
-        field_name='pk',
-        label=_('Region (ID)'),
-    )
-    site = MultiValueCharFilter(
-        method='filter_site',
-        field_name='slug',
-        label=_('Site (slug)'),
-    )
-    site_id = MultiValueNumberFilter(
-        method='filter_site',
-        field_name='pk',
-        label=_('Site (ID)'),
-    )
-    device = django_filters.ModelMultipleChoiceFilter(
-        field_name='interface__device__name',
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        label=_('Device (name)'),
-    )
-    device_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='interface__device',
-        queryset=Device.objects.all(),
-        label=_('Device (ID)'),
-    )
-    virtual_machine = django_filters.ModelMultipleChoiceFilter(
-        field_name='vminterface__virtual_machine__name',
-        queryset=VirtualMachine.objects.all(),
-        to_field_name='name',
-        label=_('Virtual machine (name)'),
-    )
-    virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='vminterface__virtual_machine',
-        queryset=VirtualMachine.objects.all(),
-        label=_('Virtual machine (ID)'),
-    )
-    interface = django_filters.ModelMultipleChoiceFilter(
-        field_name='interface__name',
-        queryset=Interface.objects.all(),
-        to_field_name='name',
-        label=_('Interface (name)'),
-    )
-    interface_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='interface',
-        queryset=Interface.objects.all(),
-        label=_('Interface (ID)'),
-    )
-    vminterface = django_filters.ModelMultipleChoiceFilter(
-        field_name='vminterface__name',
-        queryset=VMInterface.objects.all(),
-        to_field_name='name',
-        label=_('VM interface (name)'),
-    )
-    vminterface_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='vminterface',
-        queryset=VMInterface.objects.all(),
-        label=_('VM Interface (ID)'),
-    )
-    vlan = django_filters.ModelMultipleChoiceFilter(
-        field_name='vlan__name',
-        queryset=VLAN.objects.all(),
-        to_field_name='name',
-        label=_('VLAN (name)'),
-    )
-    vlan_vid = django_filters.NumberFilter(
-        field_name='vlan__vid',
-        label=_('VLAN number (1-4094)'),
-    )
-    vlan_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='vlan',
-        queryset=VLAN.objects.all(),
-        label=_('VLAN (ID)'),
-    )
-    assigned_object_type = ContentTypeFilter()
-
-    class Meta:
-        model = L2VPNTermination
-        fields = ('id', 'assigned_object_type_id')
-
-    def search(self, queryset, name, value):
-        if not value.strip():
-            return queryset
-        qs_filter = Q(l2vpn__name__icontains=value)
-        return queryset.filter(qs_filter)
-
-    def filter_assigned_object(self, queryset, name, value):
-        qs = queryset.filter(
-            Q(**{'{}__in'.format(name): value})
-        )
-        return qs
-
-    def filter_site(self, queryset, name, value):
-        qs = queryset.filter(
-            Q(
-                Q(**{'vlan__site__{}__in'.format(name): value}) |
-                Q(**{'interface__device__site__{}__in'.format(name): value}) |
-                Q(**{'vminterface__virtual_machine__site__{}__in'.format(name): value})
-            )
-        )
-        return qs
-
-    def filter_region(self, queryset, name, value):
-        qs = queryset.filter(
-            Q(
-                Q(**{'vlan__site__region__{}__in'.format(name): value}) |
-                Q(**{'interface__device__site__region__{}__in'.format(name): value}) |
-                Q(**{'vminterface__virtual_machine__site__region__{}__in'.format(name): value})
-            )
-        )
-        return qs
-
-
 class PrimaryIPFilterSet(django_filters.FilterSet):
     """
     An inheritable FilterSet for models which support primary IP assignment.

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

@@ -23,8 +23,6 @@ __all__ = (
     'FHRPGroupBulkEditForm',
     'IPAddressBulkEditForm',
     'IPRangeBulkEditForm',
-    'L2VPNBulkEditForm',
-    'L2VPNTerminationBulkEditForm',
     'PrefixBulkEditForm',
     'RIRBulkEditForm',
     'RoleBulkEditForm',
@@ -596,32 +594,3 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
 
 class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
     model = Service
-
-
-class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
-    type = forms.ChoiceField(
-        label=_('Type'),
-        choices=add_blank_choice(L2VPNTypeChoices),
-        required=False
-    )
-    tenant = DynamicModelChoiceField(
-        label=_('Tenant'),
-        queryset=Tenant.objects.all(),
-        required=False
-    )
-    description = forms.CharField(
-        label=_('Description'),
-        max_length=200,
-        required=False
-    )
-    comments = CommentField()
-
-    model = L2VPN
-    fieldsets = (
-        (None, ('type', 'tenant', 'description')),
-    )
-    nullable_fields = ('tenant', 'description', 'comments')
-
-
-class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm):
-    model = L2VPN

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

@@ -1,6 +1,5 @@
 from django import forms
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 
 from dcim.models import Device, Interface, Site
@@ -21,8 +20,6 @@ __all__ = (
     'FHRPGroupImportForm',
     'IPAddressImportForm',
     'IPRangeImportForm',
-    'L2VPNImportForm',
-    'L2VPNTerminationImportForm',
     'PrefixImportForm',
     'RIRImportForm',
     'RoleImportForm',
@@ -529,92 +526,3 @@ class ServiceImportForm(NetBoxModelImportForm):
                 )
 
         return self.cleaned_data['ipaddresses']
-
-
-class L2VPNImportForm(NetBoxModelImportForm):
-    tenant = CSVModelChoiceField(
-        label=_('Tenant'),
-        queryset=Tenant.objects.all(),
-        required=False,
-        to_field_name='name',
-    )
-    type = CSVChoiceField(
-        label=_('Type'),
-        choices=L2VPNTypeChoices,
-        help_text=_('L2VPN type')
-    )
-
-    class Meta:
-        model = L2VPN
-        fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description',
-                  'comments', 'tags')
-
-
-class L2VPNTerminationImportForm(NetBoxModelImportForm):
-    l2vpn = CSVModelChoiceField(
-        queryset=L2VPN.objects.all(),
-        required=True,
-        to_field_name='name',
-        label=_('L2VPN'),
-    )
-    device = CSVModelChoiceField(
-        label=_('Device'),
-        queryset=Device.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text=_('Parent device (for interface)')
-    )
-    virtual_machine = CSVModelChoiceField(
-        label=_('Virtual machine'),
-        queryset=VirtualMachine.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text=_('Parent virtual machine (for interface)')
-    )
-    interface = CSVModelChoiceField(
-        label=_('Interface'),
-        queryset=Interface.objects.none(),  # Can also refer to VMInterface
-        required=False,
-        to_field_name='name',
-        help_text=_('Assigned interface (device or VM)')
-    )
-    vlan = CSVModelChoiceField(
-        label=_('VLAN'),
-        queryset=VLAN.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text=_('Assigned VLAN')
-    )
-
-    class Meta:
-        model = L2VPNTermination
-        fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan', 'tags')
-
-    def __init__(self, data=None, *args, **kwargs):
-        super().__init__(data, *args, **kwargs)
-
-        if data:
-
-            # Limit interface queryset by device or VM
-            if data.get('device'):
-                self.fields['interface'].queryset = Interface.objects.filter(
-                    **{f"device__{self.fields['device'].to_field_name}": data['device']}
-                )
-            elif data.get('virtual_machine'):
-                self.fields['interface'].queryset = VMInterface.objects.filter(
-                    **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
-                )
-
-    def clean(self):
-        super().clean()
-
-        if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'):
-            raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.'))
-        if not self.instance and not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')):
-            raise ValidationError(_('Each termination must specify either an interface or a VLAN.'))
-        if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'):
-            raise ValidationError(_('Cannot assign both an interface and a VLAN.'))
-
-        # if this is an update we might not have interface or vlan in the form data
-        if self.cleaned_data.get('interface') or self.cleaned_data.get('vlan'):
-            self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')

+ 2 - 93
netbox/ipam/forms/filtersets.py

@@ -1,5 +1,4 @@
 from django import forms
-from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 
 from dcim.models import Location, Rack, Region, Site, SiteGroup, Device
@@ -9,10 +8,9 @@ from ipam.models import *
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
-from utilities.forms.fields import (
-    ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
-)
+from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
 from virtualization.models import VirtualMachine
+from vpn.models import L2VPN
 
 __all__ = (
     'AggregateFilterForm',
@@ -21,8 +19,6 @@ __all__ = (
     'FHRPGroupFilterForm',
     'IPAddressFilterForm',
     'IPRangeFilterForm',
-    'L2VPNFilterForm',
-    'L2VPNTerminationFilterForm',
     'PrefixFilterForm',
     'RIRFilterForm',
     'RoleFilterForm',
@@ -539,90 +535,3 @@ class ServiceFilterForm(ServiceTemplateFilterForm):
         label=_('Virtual Machine'),
     )
     tag = TagFilterField(model)
-
-
-class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
-    model = L2VPN
-    fieldsets = (
-        (None, ('q', 'filter_id', 'tag')),
-        (_('Attributes'), ('type', 'import_target_id', 'export_target_id')),
-        (_('Tenant'), ('tenant_group_id', 'tenant_id')),
-    )
-    type = forms.ChoiceField(
-        label=_('Type'),
-        choices=add_blank_choice(L2VPNTypeChoices),
-        required=False
-    )
-    import_target_id = DynamicModelMultipleChoiceField(
-        queryset=RouteTarget.objects.all(),
-        required=False,
-        label=_('Import targets')
-    )
-    export_target_id = DynamicModelMultipleChoiceField(
-        queryset=RouteTarget.objects.all(),
-        required=False,
-        label=_('Export targets')
-    )
-    tag = TagFilterField(model)
-
-
-class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
-    model = L2VPNTermination
-    fieldsets = (
-        (None, ('filter_id', 'l2vpn_id',)),
-        (_('Assigned Object'), (
-            'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id',
-        )),
-    )
-    l2vpn_id = DynamicModelChoiceField(
-        queryset=L2VPN.objects.all(),
-        required=False,
-        label=_('L2VPN')
-    )
-    assigned_object_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.filter(L2VPN_ASSIGNMENT_MODELS),
-        required=False,
-        label=_('Assigned Object Type'),
-        limit_choices_to=L2VPN_ASSIGNMENT_MODELS
-    )
-    region_id = DynamicModelMultipleChoiceField(
-        queryset=Region.objects.all(),
-        required=False,
-        label=_('Region')
-    )
-    site_id = DynamicModelMultipleChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'region_id': '$region_id'
-        },
-        label=_('Site')
-    )
-    device_id = DynamicModelMultipleChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$site_id'
-        },
-        label=_('Device')
-    )
-    vlan_id = DynamicModelMultipleChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$site_id'
-        },
-        label=_('VLAN')
-    )
-    virtual_machine_id = DynamicModelMultipleChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$site_id'
-        },
-        label=_('Virtual Machine')
-    )

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

@@ -29,8 +29,6 @@ __all__ = (
     'IPAddressBulkAddForm',
     'IPAddressForm',
     'IPRangeForm',
-    'L2VPNForm',
-    'L2VPNTerminationForm',
     'PrefixForm',
     'RIRForm',
     'RoleForm',
@@ -754,97 +752,3 @@ 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(
-        label=_('Import targets'),
-        queryset=RouteTarget.objects.all(),
-        required=False
-    )
-    export_targets = DynamicModelMultipleChoiceField(
-        label=_('Export targets'),
-        queryset=RouteTarget.objects.all(),
-        required=False
-    )
-    comments = CommentField()
-
-    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', 'import_targets', 'export_targets', 'tenant', 'description',
-            'comments', 'tags'
-        )
-
-
-class L2VPNTerminationForm(NetBoxModelForm):
-    l2vpn = DynamicModelChoiceField(
-        queryset=L2VPN.objects.all(),
-        required=True,
-        query_params={},
-        label=_('L2VPN'),
-        fetch_trigger='open'
-    )
-    vlan = DynamicModelChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False,
-        selector=True,
-        label=_('VLAN')
-    )
-    interface = DynamicModelChoiceField(
-        label=_('Interface'),
-        queryset=Interface.objects.all(),
-        required=False,
-        selector=True
-    )
-    vminterface = DynamicModelChoiceField(
-        queryset=VMInterface.objects.all(),
-        required=False,
-        selector=True,
-        label=_('Interface')
-    )
-
-    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['interface'] = instance.assigned_object
-            elif type(instance.assigned_object) is VLAN:
-                initial['vlan'] = instance.assigned_object
-            elif type(instance.assigned_object) is VMInterface:
-                initial['vminterface'] = instance.assigned_object
-            kwargs['initial'] = initial
-
-        super().__init__(*args, **kwargs)
-
-    def clean(self):
-        super().clean()
-
-        interface = self.cleaned_data.get('interface')
-        vminterface = self.cleaned_data.get('vminterface')
-        vlan = self.cleaned_data.get('vlan')
-
-        if not (interface or vminterface or vlan):
-            raise ValidationError(_('A termination must specify an interface or VLAN.'))
-        if len([x for x in (interface, vminterface, vlan) if x]) > 1:
-            raise ValidationError(_('A termination can only have one terminating object (an interface or VLAN).'))
-
-        self.instance.assigned_object = interface or vminterface or vlan

+ 2 - 15
netbox/ipam/graphql/schema.py

@@ -1,9 +1,8 @@
 import graphene
-from ipam import models
-from utilities.graphql_optimizer import gql_query_optimizer
 
+from ipam import models
 from netbox.graphql.fields import ObjectField, ObjectListField
-
+from utilities.graphql_optimizer import gql_query_optimizer
 from .types import *
 
 
@@ -38,18 +37,6 @@ class IPAMQuery(graphene.ObjectType):
     def resolve_ip_range_list(root, info, **kwargs):
         return gql_query_optimizer(models.IPRange.objects.all(), info)
 
-    l2vpn = ObjectField(L2VPNType)
-    l2vpn_list = ObjectListField(L2VPNType)
-
-    def resolve_l2vpn_list(root, info, **kwargs):
-        return gql_query_optimizer(models.L2VPN.objects.all(), info)
-
-    l2vpn_termination = ObjectField(L2VPNTerminationType)
-    l2vpn_termination_list = ObjectListField(L2VPNTerminationType)
-
-    def resolve_l2vpn_termination_list(root, info, **kwargs):
-        return gql_query_optimizer(models.L2VPNTermination.objects.all(), info)
-
     prefix = ObjectField(PrefixType)
     prefix_list = ObjectListField(PrefixType)
 

+ 0 - 19
netbox/ipam/graphql/types.py

@@ -1,6 +1,5 @@
 import graphene
 
-from extras.graphql.mixins import ContactsMixin
 from ipam import filtersets, models
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
@@ -13,8 +12,6 @@ __all__ = (
     'FHRPGroupAssignmentType',
     'IPAddressType',
     'IPRangeType',
-    'L2VPNType',
-    'L2VPNTerminationType',
     'PrefixType',
     'RIRType',
     'RoleType',
@@ -188,19 +185,3 @@ class VRFType(NetBoxObjectType):
         model = models.VRF
         fields = '__all__'
         filterset_class = filtersets.VRFFilterSet
-
-
-class L2VPNType(ContactsMixin, NetBoxObjectType):
-    class Meta:
-        model = models.L2VPN
-        fields = '__all__'
-        filtersets_class = filtersets.L2VPNFilterSet
-
-
-class L2VPNTerminationType(NetBoxObjectType):
-    assigned_object = graphene.Field('ipam.graphql.gfk_mixins.L2VPNAssignmentType')
-
-    class Meta:
-        model = models.L2VPNTermination
-        exclude = ('assigned_object_type', 'assigned_object_id')
-        filtersets_class = filtersets.L2VPNTerminationFilterSet

+ 64 - 0
netbox/ipam/migrations/0068_move_l2vpn.py

@@ -0,0 +1,64 @@
+from django.db import migrations
+
+
+def update_content_types(apps, schema_editor):
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+
+    # Delete the new ContentTypes effected by the new models in the vpn app
+    ContentType.objects.filter(app_label='vpn', model='l2vpn').delete()
+    ContentType.objects.filter(app_label='vpn', model='l2vpntermination').delete()
+
+    # Update the app labels of the original ContentTypes for ipam.L2VPN and ipam.L2VPNTermination to ensure
+    # that any foreign key references are preserved
+    ContentType.objects.filter(app_label='ipam', model='l2vpn').update(app_label='vpn')
+    ContentType.objects.filter(app_label='ipam', model='l2vpntermination').update(app_label='vpn')
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0067_ipaddress_index_host'),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name='l2vpntermination',
+            name='ipam_l2vpntermination_assigned_object',
+        ),
+        migrations.SeparateDatabaseAndState(
+            state_operations=[
+                migrations.RemoveField(
+                    model_name='l2vpntermination',
+                    name='assigned_object_type',
+                ),
+                migrations.RemoveField(
+                    model_name='l2vpntermination',
+                    name='l2vpn',
+                ),
+                migrations.RemoveField(
+                    model_name='l2vpntermination',
+                    name='tags',
+                ),
+                migrations.DeleteModel(
+                    name='L2VPN',
+                ),
+                migrations.DeleteModel(
+                    name='L2VPNTermination',
+                ),
+            ],
+            database_operations=[
+                migrations.AlterModelTable(
+                    name='L2VPN',
+                    table='vpn_l2vpn',
+                ),
+                migrations.AlterModelTable(
+                    name='L2VPNTermination',
+                    table='vpn_l2vpntermination',
+                ),
+            ],
+        ),
+        migrations.RunPython(
+            code=update_content_types,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

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

@@ -3,27 +3,5 @@ from .asns import *
 from .fhrp import *
 from .vrfs import *
 from .ip import *
-from .l2vpn import *
 from .services import *
 from .vlans import *
-
-__all__ = (
-    'ASN',
-    'ASNRange',
-    'Aggregate',
-    'IPAddress',
-    'IPRange',
-    'FHRPGroup',
-    'FHRPGroupAssignment',
-    'L2VPN',
-    'L2VPNTermination',
-    'Prefix',
-    'RIR',
-    'Role',
-    'RouteTarget',
-    'Service',
-    'ServiceTemplate',
-    'VLAN',
-    'VLANGroup',
-    'VRF',
-)

+ 1 - 2
netbox/ipam/models/vlans.py

@@ -183,9 +183,8 @@ class VLAN(PrimaryModel):
         null=True,
         help_text=_("The primary function of this VLAN")
     )
-
     l2vpn_terminations = GenericRelation(
-        to='ipam.L2VPNTermination',
+        to='vpn.L2VPNTermination',
         content_type_field='assigned_object_type',
         object_id_field='assigned_object_id',
         related_query_name='vlan'

+ 1 - 13
netbox/ipam/search.py

@@ -1,5 +1,5 @@
-from . import models
 from netbox.search import SearchIndex, register_search
+from . import models
 
 
 @register_search
@@ -69,18 +69,6 @@ class IPRangeIndex(SearchIndex):
     display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
 
 
-@register_search
-class L2VPNIndex(SearchIndex):
-    model = models.L2VPN
-    fields = (
-        ('name', 100),
-        ('slug', 110),
-        ('description', 500),
-        ('comments', 5000),
-    )
-    display_attrs = ('type', 'identifier', 'tenant', 'description')
-
-
 @register_search
 class PrefixIndex(SearchIndex):
     model = models.Prefix

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

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

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

@@ -1100,96 +1100,3 @@ 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', slug='l2vpn-1', type='vxlan', identifier=650001),
-            L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
-            L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'),  # No RD
-        )
-        L2VPN.objects.bulk_create(l2vpns)
-
-
-class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase):
-    model = L2VPNTermination
-    brief_fields = ['display', 'id', 'l2vpn', 'url']
-
-    @classmethod
-    def setUpTestData(cls):
-
-        vlans = (
-            VLAN(name='VLAN 1', vid=651),
-            VLAN(name='VLAN 2', vid=652),
-            VLAN(name='VLAN 3', vid=653),
-            VLAN(name='VLAN 4', vid=654),
-            VLAN(name='VLAN 5', vid=655),
-            VLAN(name='VLAN 6', vid=656),
-            VLAN(name='VLAN 7', vid=657)
-        )
-        VLAN.objects.bulk_create(vlans)
-
-        l2vpns = (
-            L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
-            L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
-            L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'),  # No RD
-        )
-        L2VPN.objects.bulk_create(l2vpns)
-
-        l2vpnterminations = (
-            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
-            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
-            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2])
-        )
-        L2VPNTermination.objects.bulk_create(l2vpnterminations)
-
-        cls.create_data = [
-            {
-                'l2vpn': l2vpns[0].pk,
-                'assigned_object_type': 'ipam.vlan',
-                'assigned_object_id': vlans[3].pk,
-            },
-            {
-                'l2vpn': l2vpns[0].pk,
-                'assigned_object_type': 'ipam.vlan',
-                'assigned_object_id': vlans[4].pk,
-            },
-            {
-                'l2vpn': l2vpns[0].pk,
-                'assigned_object_type': 'ipam.vlan',
-                'assigned_object_id': vlans[5].pk,
-            },
-        ]
-
-        cls.bulk_update_data = {
-            'l2vpn': l2vpns[2].pk
-        }

+ 1 - 161
netbox/ipam/tests/test_filtersets.py

@@ -7,9 +7,9 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Man
 from ipam.choices import *
 from ipam.filtersets import *
 from ipam.models import *
+from tenancy.models import Tenant, TenantGroup
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
-from tenancy.models import Tenant, TenantGroup
 
 
 class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -1616,163 +1616,3 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'ipaddress': [str(ips[0].address), str(ips[1].address)]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
-
-class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
-    queryset = L2VPN.objects.all()
-    filterset = L2VPNFilterSet
-
-    @classmethod
-    def setUpTestData(cls):
-
-        route_targets = (
-            RouteTarget(name='1:1'),
-            RouteTarget(name='1:2'),
-            RouteTarget(name='1:3'),
-            RouteTarget(name='2:1'),
-            RouteTarget(name='2:2'),
-            RouteTarget(name='2:3'),
-        )
-        RouteTarget.objects.bulk_create(route_targets)
-
-        l2vpns = (
-            L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=65001),
-            L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VPWS, identifier=65002),
-            L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VPLS),
-        )
-        L2VPN.objects.bulk_create(l2vpns)
-        l2vpns[0].import_targets.add(route_targets[0])
-        l2vpns[1].import_targets.add(route_targets[1])
-        l2vpns[2].import_targets.add(route_targets[2])
-        l2vpns[0].export_targets.add(route_targets[3])
-        l2vpns[1].export_targets.add(route_targets[4])
-        l2vpns[2].export_targets.add(route_targets[5])
-
-    def test_name(self):
-        params = {'name': ['L2VPN 1', 'L2VPN 2']}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
-    def test_slug(self):
-        params = {'slug': ['l2vpn-1', 'l2vpn-2']}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
-    def test_identifier(self):
-        params = {'identifier': ['65001', '65002']}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
-    def test_type(self):
-        params = {'type': [L2VPNTypeChoices.TYPE_VXLAN, L2VPNTypeChoices.TYPE_VPWS]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
-    def test_import_targets(self):
-        route_targets = RouteTarget.objects.filter(name__in=['1:1', '1:2'])
-        params = {'import_target_id': [route_targets[0].pk, route_targets[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'import_target': [route_targets[0].name, route_targets[1].name]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
-    def test_export_targets(self):
-        route_targets = RouteTarget.objects.filter(name__in=['2:1', '2:2'])
-        params = {'export_target_id': [route_targets[0].pk, route_targets[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'export_target': [route_targets[0].name, route_targets[1].name]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
-
-class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
-    queryset = L2VPNTermination.objects.all()
-    filterset = L2VPNTerminationFilterSet
-
-    @classmethod
-    def setUpTestData(cls):
-        device = create_test_device('Device 1')
-        interfaces = (
-            Interface(name='Interface 1', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
-            Interface(name='Interface 2', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
-            Interface(name='Interface 3', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
-        )
-        Interface.objects.bulk_create(interfaces)
-
-        vm = create_test_virtualmachine('Virtual Machine 1')
-        vminterfaces = (
-            VMInterface(name='Interface 1', virtual_machine=vm),
-            VMInterface(name='Interface 2', virtual_machine=vm),
-            VMInterface(name='Interface 3', virtual_machine=vm),
-        )
-        VMInterface.objects.bulk_create(vminterfaces)
-
-        vlans = (
-            VLAN(name='VLAN 1', vid=101),
-            VLAN(name='VLAN 2', vid=102),
-            VLAN(name='VLAN 3', vid=103),
-        )
-        VLAN.objects.bulk_create(vlans)
-
-        l2vpns = (
-            L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=65001),
-            L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=65002),
-            L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'),  # No RD,
-        )
-        L2VPN.objects.bulk_create(l2vpns)
-
-        l2vpnterminations = (
-            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
-            L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlans[1]),
-            L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vlans[2]),
-            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=interfaces[0]),
-            L2VPNTermination(l2vpn=l2vpns[1], assigned_object=interfaces[1]),
-            L2VPNTermination(l2vpn=l2vpns[2], assigned_object=interfaces[2]),
-            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vminterfaces[0]),
-            L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vminterfaces[1]),
-            L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vminterfaces[2]),
-        )
-        L2VPNTermination.objects.bulk_create(l2vpnterminations)
-
-    def test_l2vpn(self):
-        l2vpns = L2VPN.objects.all()[:2]
-        params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
-        params = {'l2vpn': [l2vpns[0].slug, l2vpns[1].slug]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
-
-    def test_content_type(self):
-        params = {'assigned_object_type_id': ContentType.objects.get(model='vlan').pk}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-
-    def test_interface(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)
-
-    def test_vminterface(self):
-        vminterfaces = VMInterface.objects.all()[:2]
-        params = {'vminterface_id': [vminterfaces[0].pk, vminterfaces[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
-    def test_vlan(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)
-
-    def test_site(self):
-        site = Site.objects.all().first()
-        params = {'site_id': [site.pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-        params = {'site': ['site-1']}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-
-    def test_device(self):
-        device = Device.objects.all().first()
-        params = {'device_id': [device.pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-        params = {'device': ['Device 1']}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-
-    def test_virtual_machine(self):
-        virtual_machine = VirtualMachine.objects.all().first()
-        params = {'virtual_machine_id': [virtual_machine.pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-        params = {'virtual_machine': ['Virtual Machine 1']}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

+ 3 - 77
netbox/ipam/tests/test_models.py

@@ -1,10 +1,9 @@
-from netaddr import IPNetwork, IPSet
 from django.core.exceptions import ValidationError
 from django.test import TestCase, override_settings
+from netaddr import IPNetwork, IPSet
 
-from dcim.models import Interface, Device, DeviceRole, DeviceType, Manufacturer, Site
-from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices
-from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF, L2VPN, L2VPNTermination
+from ipam.choices import *
+from ipam.models import *
 
 
 class TestAggregate(TestCase):
@@ -539,76 +538,3 @@ class TestVLANGroup(TestCase):
 
         VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
         self.assertEqual(vlangroup.get_next_available_vid(), 105)
-
-
-class TestL2VPNTermination(TestCase):
-
-    @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)
-        role = DeviceRole.objects.create(name='Switch')
-        device = Device.objects.create(
-            name='Device 1',
-            site=site,
-            device_type=device_type,
-            role=role,
-            status='active'
-        )
-
-        interfaces = (
-            Interface(name='Interface 1', device=device, type='1000baset'),
-            Interface(name='Interface 2', device=device, type='1000baset'),
-            Interface(name='Interface 3', device=device, type='1000baset'),
-            Interface(name='Interface 4', device=device, type='1000baset'),
-            Interface(name='Interface 5', device=device, type='1000baset'),
-        )
-
-        Interface.objects.bulk_create(interfaces)
-
-        vlans = (
-            VLAN(name='VLAN 1', vid=651),
-            VLAN(name='VLAN 2', vid=652),
-            VLAN(name='VLAN 3', vid=653),
-            VLAN(name='VLAN 4', vid=654),
-            VLAN(name='VLAN 5', vid=655),
-            VLAN(name='VLAN 6', vid=656),
-            VLAN(name='VLAN 7', vid=657)
-        )
-
-        VLAN.objects.bulk_create(vlans)
-
-        l2vpns = (
-            L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
-            L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
-            L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'),  # No RD
-        )
-        L2VPN.objects.bulk_create(l2vpns)
-
-        l2vpnterminations = (
-            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
-            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
-            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2])
-        )
-
-        L2VPNTermination.objects.bulk_create(l2vpnterminations)
-
-    def test_duplicate_interface_terminations(self):
-        device = Device.objects.first()
-        interface = Interface.objects.filter(device=device).first()
-        l2vpn = L2VPN.objects.first()
-
-        L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=interface)
-        duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=interface)
-
-        self.assertRaises(ValidationError, duplicate.clean)
-
-    def test_duplicate_vlan_terminations(self):
-        vlan = Interface.objects.first()
-        l2vpn = L2VPN.objects.first()
-
-        L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan)
-        duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan)
-        self.assertRaises(ValidationError, duplicate.clean)

+ 1 - 140
netbox/ipam/tests/test_views.py

@@ -9,7 +9,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Inte
 from ipam.choices import *
 from ipam.models import *
 from tenancy.models import Tenant
-from utilities.testing import ViewTestCases, create_test_device, create_tags
+from utilities.testing import ViewTestCases, create_tags
 
 
 class ASNRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -986,142 +986,3 @@ 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):
-    model = L2VPN
-
-    @classmethod
-    def setUpTestData(cls):
-        rts = (
-            RouteTarget(name='64534:123'),
-            RouteTarget(name='64534:321')
-        )
-        RouteTarget.objects.bulk_create(rts)
-
-        l2vpns = (
-            L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001'),
-            L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'),
-            L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003')
-        )
-        L2VPN.objects.bulk_create(l2vpns)
-
-        cls.csv_data = (
-            'name,slug,type,identifier',
-            'L2VPN 5,l2vpn-5,vxlan,456',
-            'L2VPN 6,l2vpn-6,vxlan,444',
-        )
-
-        cls.csv_update_data = (
-            'id,name,description',
-            f'{l2vpns[0].pk},L2VPN 7,New description 7',
-            f'{l2vpns[1].pk},L2VPN 8,New description 8',
-        )
-
-        cls.bulk_edit_data = {
-            'description': 'New Description',
-        }
-
-        cls.form_data = {
-            'name': 'L2VPN 8',
-            'slug': 'l2vpn-8',
-            'type': L2VPNTypeChoices.TYPE_VXLAN,
-            'identifier': 123,
-            'description': 'Description',
-            'import_targets': [rts[0].pk],
-            'export_targets': [rts[1].pk]
-        }
-
-
-class L2VPNTerminationTestCase(
-        ViewTestCases.GetObjectViewTestCase,
-        ViewTestCases.GetObjectChangelogViewTestCase,
-        ViewTestCases.CreateObjectViewTestCase,
-        ViewTestCases.EditObjectViewTestCase,
-        ViewTestCases.DeleteObjectViewTestCase,
-        ViewTestCases.ListObjectsViewTestCase,
-        ViewTestCases.BulkImportObjectsViewTestCase,
-        ViewTestCases.BulkDeleteObjectsViewTestCase,
-):
-
-    model = L2VPNTermination
-
-    @classmethod
-    def setUpTestData(cls):
-        device = create_test_device('Device 1')
-        interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset')
-        l2vpns = (
-            L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001),
-            L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650002),
-        )
-        L2VPN.objects.bulk_create(l2vpns)
-
-        vlans = (
-            VLAN(name='Vlan 1', vid=1001),
-            VLAN(name='Vlan 2', vid=1002),
-            VLAN(name='Vlan 3', vid=1003),
-            VLAN(name='Vlan 4', vid=1004),
-            VLAN(name='Vlan 5', vid=1005),
-            VLAN(name='Vlan 6', vid=1006)
-        )
-        VLAN.objects.bulk_create(vlans)
-
-        terminations = (
-            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
-            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
-            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2])
-        )
-        L2VPNTermination.objects.bulk_create(terminations)
-
-        cls.form_data = {
-            'l2vpn': l2vpns[0].pk,
-            'device': device.pk,
-            'interface': interface.pk,
-        }
-
-        cls.csv_data = (
-            "l2vpn,vlan",
-            "L2VPN 1,Vlan 4",
-            "L2VPN 1,Vlan 5",
-            "L2VPN 1,Vlan 6",
-        )
-
-        cls.csv_update_data = (
-            f"id,l2vpn",
-            f"{terminations[0].pk},{l2vpns[0].name}",
-            f"{terminations[1].pk},{l2vpns[0].name}",
-            f"{terminations[2].pk},{l2vpns[0].name}",
-        )
-
-        cls.bulk_edit_data = {}
-
-    # TODO: Fix L2VPNTerminationImportForm validation to support bulk updates
-    def test_bulk_update_objects_with_permission(self):
-        pass
-
-    #
-    # Custom assertions
-    #
-
-    # TODO: Remove this
-    def assertInstanceEqual(self, instance, data, exclude=None, api=False):
-        """
-        Override parent
-        """
-        if exclude is None:
-            exclude = []
-
-        fields = [k for k in data.keys() if k not in exclude]
-        model_dict = self.model_to_dict(instance, fields=fields, api=api)
-
-        # Omit any dictionary keys which are not instance attributes or have been excluded
-        relevant_data = {
-            k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
-        }
-
-        # Handle relations on the model
-        for k, v in model_dict.items():
-            if isinstance(v, object) and hasattr(v, 'first'):
-                model_dict[k] = v.first().pk
-
-        self.assertDictEqual(model_dict, relevant_data)

+ 0 - 16
netbox/ipam/urls.py

@@ -131,20 +131,4 @@ urlpatterns = [
     path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
     path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
     path('services/<int:pk>/', include(get_model_urls('ipam', 'service'))),
-
-    # L2VPN
-    path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'),
-    path('l2vpns/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'),
-    path('l2vpns/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'),
-    path('l2vpns/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'),
-    path('l2vpns/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'),
-    path('l2vpns/<int:pk>/', include(get_model_urls('ipam', 'l2vpn'))),
-
-    # L2VPN terminations
-    path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'),
-    path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'),
-    path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'),
-    path('l2vpn-terminations/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'),
-    path('l2vpn-terminations/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'),
-    path('l2vpn-terminations/<int:pk>/', include(get_model_urls('ipam', 'l2vpntermination'))),
 ]

+ 1 - 112
netbox/ipam/views.py

@@ -1,5 +1,5 @@
 from django.contrib.contenttypes.models import ContentType
-from django.db.models import F, Prefetch
+from django.db.models import Prefetch
 from django.db.models.expressions import RawSQL
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
@@ -9,7 +9,6 @@ from circuits.models import Provider
 from dcim.filtersets import InterfaceFilterSet
 from dcim.models import Interface, Site
 from netbox.views import generic
-from tenancy.views import ObjectContactsView
 from utilities.tables import get_table_ordering
 from utilities.utils import count_related
 from utilities.views import ViewTab, register_model_view
@@ -19,7 +18,6 @@ from . import filtersets, forms, tables
 from .choices import PrefixStatusChoices
 from .constants import *
 from .models import *
-from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable
 from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
 
 
@@ -1243,112 +1241,3 @@ class ServiceBulkDeleteView(generic.BulkDeleteView):
     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
-
-
-@register_model_view(L2VPN)
-class L2VPNView(generic.ObjectView):
-    queryset = L2VPN.objects.all()
-
-    def get_extra_context(self, request, instance):
-        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 {
-            'import_targets_table': import_targets_table,
-            'export_targets_table': export_targets_table,
-        }
-
-
-@register_model_view(L2VPN, 'edit')
-class L2VPNEditView(generic.ObjectEditView):
-    queryset = L2VPN.objects.all()
-    form = forms.L2VPNForm
-
-
-@register_model_view(L2VPN, 'delete')
-class L2VPNDeleteView(generic.ObjectDeleteView):
-    queryset = L2VPN.objects.all()
-
-
-class L2VPNBulkImportView(generic.BulkImportView):
-    queryset = L2VPN.objects.all()
-    model_form = forms.L2VPNImportForm
-
-
-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
-
-
-@register_model_view(L2VPN, 'contacts')
-class L2VPNContactsView(ObjectContactsView):
-    queryset = L2VPN.objects.all()
-
-
-#
-# L2VPN terminations
-#
-
-class L2VPNTerminationListView(generic.ObjectListView):
-    queryset = L2VPNTermination.objects.all()
-    table = L2VPNTerminationTable
-    filterset = filtersets.L2VPNTerminationFilterSet
-    filterset_form = forms.L2VPNTerminationFilterForm
-
-
-@register_model_view(L2VPNTermination)
-class L2VPNTerminationView(generic.ObjectView):
-    queryset = L2VPNTermination.objects.all()
-
-
-@register_model_view(L2VPNTermination, 'edit')
-class L2VPNTerminationEditView(generic.ObjectEditView):
-    queryset = L2VPNTermination.objects.all()
-    form = forms.L2VPNTerminationForm
-    template_name = 'ipam/l2vpntermination_edit.html'
-
-
-@register_model_view(L2VPNTermination, 'delete')
-class L2VPNTerminationDeleteView(generic.ObjectDeleteView):
-    queryset = L2VPNTermination.objects.all()
-
-
-class L2VPNTerminationBulkImportView(generic.BulkImportView):
-    queryset = L2VPNTermination.objects.all()
-    model_form = forms.L2VPNTerminationImportForm
-
-
-class L2VPNTerminationBulkEditView(generic.BulkEditView):
-    queryset = L2VPNTermination.objects.all()
-    filterset = filtersets.L2VPNTerminationFilterSet
-    table = tables.L2VPNTerminationTable
-    form = forms.L2VPNTerminationBulkEditForm
-
-
-class L2VPNTerminationBulkDeleteView(generic.BulkDeleteView):
-    queryset = L2VPNTermination.objects.all()
-    filterset = filtersets.L2VPNTerminationFilterSet
-    table = tables.L2VPNTerminationTable

+ 2 - 2
netbox/netbox/navigation/menu.py

@@ -209,8 +209,8 @@ VPN_MENU = Menu(
         MenuGroup(
             label=_('L2VPNs'),
             items=(
-                get_model_item('ipam', 'l2vpn', _('L2VPNs')),
-                get_model_item('ipam', 'l2vpntermination', _('Terminations')),
+                get_model_item('vpn', 'l2vpn', _('L2VPNs')),
+                get_model_item('vpn', 'l2vpntermination', _('Terminations')),
             ),
         ),
         MenuGroup(

+ 2 - 2
netbox/templates/ipam/routetarget.html

@@ -59,7 +59,7 @@
       <div class="card">
         <h5 class="card-header">{% trans "Importing L2VPNs" %}</h5>
         <div class="card-body htmx-container table-responsive"
-          hx-get="{% url 'ipam:l2vpn_list' %}?import_target_id={{ object.pk }}"
+          hx-get="{% url 'vpn:l2vpn_list' %}?import_target_id={{ object.pk }}"
           hx-trigger="load"
         ></div>
       </div>
@@ -68,7 +68,7 @@
       <div class="card">
         <h5 class="card-header">{% trans "Exporting L2VPNs" %}</h5>
         <div class="card-body htmx-container table-responsive"
-          hx-get="{% url 'ipam:l2vpn_list' %}?export_target_id={{ object.pk }}"
+          hx-get="{% url 'vpn:l2vpn_list' %}?export_target_id={{ object.pk }}"
           hx-trigger="load"
         ></div>
       </div>

+ 4 - 4
netbox/templates/ipam/l2vpn.html → netbox/templates/vpn/l2vpn.html

@@ -34,7 +34,7 @@
         </table>
       </div>
     </div>
-    {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:l2vpn_list' %}
+    {% include 'inc/panels/tags.html' with tags=object.tags.all url='vpn:l2vpn_list' %}
     {% plugin_left_page object %}
 	</div>
 	<div class="col col-md-6">
@@ -56,12 +56,12 @@
     <div class="card">
       <h5 class="card-header">{% trans "Terminations" %}</h5>
       <div class="card-body htmx-container table-responsive"
-        hx-get="{% url 'ipam:l2vpntermination_list' %}?l2vpn_id={{ object.pk }}"
+        hx-get="{% url 'vpn:l2vpntermination_list' %}?l2vpn_id={{ object.pk }}"
         hx-trigger="load"
       ></div>
-      {% if perms.ipam.add_l2vpntermination %}
+      {% if perms.vpn.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{% if not object.can_add_termination %} disabled" aria-disabled="true{% endif %}">
+          <a href="{% url 'vpn:l2vpntermination_add' %}?l2vpn={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm{% if not object.can_add_termination %} disabled" aria-disabled="true{% endif %}">
             <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Termination" %}
           </a>
         </div>

+ 1 - 1
netbox/templates/ipam/l2vpntermination.html → netbox/templates/vpn/l2vpntermination.html

@@ -25,7 +25,7 @@
 	</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' %}
+        {% include 'inc/panels/tags.html' with tags=object.tags.all url='vpn:l2vpntermination_list' %}
     </div>
 </div>
 

+ 0 - 0
netbox/templates/ipam/l2vpntermination_edit.html → netbox/templates/vpn/l2vpntermination_edit.html


+ 2 - 3
netbox/virtualization/api/serializers.py

@@ -6,15 +6,14 @@ from dcim.api.nested_serializers import (
 )
 from dcim.choices import InterfaceModeChoices
 from extras.api.nested_serializers import NestedConfigTemplateSerializer
-from ipam.api.nested_serializers import (
-    NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, NestedVRFSerializer,
-)
+from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
 from ipam.models import VLAN
 from netbox.api.fields import ChoiceField, SerializedPKRelatedField
 from netbox.api.serializers import NetBoxModelSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualDisk, VirtualMachine, VMInterface
+from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer
 from .nested_serializers import *
 
 

+ 2 - 1
netbox/virtualization/forms/filtersets.py

@@ -4,13 +4,14 @@ from django.utils.translation import gettext_lazy as _
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from extras.forms import LocalConfigContextFilterForm
 from extras.models import ConfigTemplate
-from ipam.models import L2VPN, VRF
+from ipam.models import VRF
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
 from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
 from virtualization.choices import *
 from virtualization.models import *
+from vpn.models import L2VPN
 
 __all__ = (
     'ClusterFilterForm',

+ 1 - 1
netbox/virtualization/models/virtualmachines.py

@@ -358,7 +358,7 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
         related_query_name='vminterface',
     )
     l2vpn_terminations = GenericRelation(
-        to='ipam.L2VPNTermination',
+        to='vpn.L2VPNTermination',
         content_type_field='assigned_object_type',
         object_id_field='assigned_object_id',
         related_query_name='vminterface',

+ 2 - 2
netbox/virtualization/tables/virtualmachines.py

@@ -24,8 +24,8 @@ VMINTERFACE_BUTTONS = """
       {% if perms.ipam.add_ipaddress %}
         <li><a class="dropdown-item" href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">IP Address</a></li>
       {% endif %}
-      {% if perms.ipam.add_l2vpntermination %}
-        <li><a class="dropdown-item" href="{% url 'ipam:l2vpntermination_add' %}?virtual_machine={{ object.pk }}&vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">L2VPN Termination</a></li>
+      {% if perms.vpn.add_l2vpntermination %}
+        <li><a class="dropdown-item" href="{% url 'vpn:l2vpntermination_add' %}?virtual_machine={{ object.pk }}&vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">L2VPN Termination</a></li>
       {% endif %}
       {% if perms.ipam.add_fhrpgroupassignment %}
         <li><a class="dropdown-item" href="{% url 'ipam:fhrpgroupassignment_add' %}?interface_type={{ record|content_type_id }}&interface_id={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">Assign FHRP Group</a></li>

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

@@ -9,6 +9,8 @@ __all__ = (
     'NestedIPSecPolicySerializer',
     'NestedIPSecProfileSerializer',
     'NestedIPSecProposalSerializer',
+    'NestedL2VPNSerializer',
+    'NestedL2VPNTerminationSerializer',
     'NestedTunnelSerializer',
     'NestedTunnelTerminationSerializer',
 )
@@ -82,3 +84,28 @@ class NestedIPSecProfileSerializer(WritableNestedSerializer):
     class Meta:
         model = models.IPSecProfile
         fields = ('id', 'url', 'display', 'name')
+
+
+#
+# L2VPN
+#
+
+class NestedL2VPNSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='vpn-api:l2vpn-detail')
+
+    class Meta:
+        model = models.L2VPN
+        fields = [
+            'id', 'url', 'display', 'identifier', 'name', 'slug', 'type'
+        ]
+
+
+class NestedL2VPNTerminationSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='vpn-api:l2vpntermination-detail')
+    l2vpn = NestedL2VPNSerializer()
+
+    class Meta:
+        model = models.L2VPNTermination
+        fields = [
+            'id', 'url', 'display', 'l2vpn'
+        ]

+ 55 - 1
netbox/vpn/api/serializers.py

@@ -2,7 +2,8 @@ from django.contrib.contenttypes.models import ContentType
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
-from ipam.api.nested_serializers import NestedIPAddressSerializer
+from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedRouteTargetSerializer
+from ipam.models import RouteTarget
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import NetBoxModelSerializer
 from netbox.constants import NESTED_SERIALIZER_PREFIX
@@ -18,6 +19,8 @@ __all__ = (
     'IPSecPolicySerializer',
     'IPSecProfileSerializer',
     'IPSecProposalSerializer',
+    'L2VPNSerializer',
+    'L2VPNTerminationSerializer',
     'TunnelSerializer',
     'TunnelTerminationSerializer',
 )
@@ -191,3 +194,54 @@ class IPSecProfileSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'comments', 'tags',
             'custom_fields', 'created', 'last_updated',
         )
+
+
+#
+# L2VPN
+#
+
+class L2VPNSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='vpn-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', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated'
+        ]
+
+
+class L2VPNTerminationSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='vpn-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', 'tags', 'custom_fields', 'created', 'last_updated'
+        ]
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_assigned_object(self, instance):
+        serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX)
+        context = {'request': self.context['request']}
+        return serializer(instance.assigned_object, context=context).data

+ 2 - 0
netbox/vpn/api/urls.py

@@ -10,6 +10,8 @@ router.register('ipsec-proposals', views.IPSecProposalViewSet)
 router.register('ipsec-profiles', views.IPSecProfileViewSet)
 router.register('tunnels', views.TunnelViewSet)
 router.register('tunnel-terminations', views.TunnelTerminationViewSet)
+router.register('l2vpns', views.L2VPNViewSet)
+router.register('l2vpn-terminations', views.L2VPNTerminationViewSet)
 
 app_name = 'vpn-api'
 urlpatterns = router.urls

+ 14 - 0
netbox/vpn/api/views.py

@@ -12,6 +12,8 @@ __all__ = (
     'IPSecPolicyViewSet',
     'IPSecProfileViewSet',
     'IPSecProposalViewSet',
+    'L2VPNViewSet',
+    'L2VPNTerminationViewSet',
     'TunnelTerminationViewSet',
     'TunnelViewSet',
     'VPNRootView',
@@ -72,3 +74,15 @@ class IPSecProfileViewSet(NetBoxModelViewSet):
     queryset = IPSecProfile.objects.all()
     serializer_class = serializers.IPSecProfileSerializer
     filterset_class = filtersets.IPSecProfileFilterSet
+
+
+class L2VPNViewSet(NetBoxModelViewSet):
+    queryset = L2VPN.objects.prefetch_related('import_targets', 'export_targets', 'tenant', 'tags')
+    serializer_class = serializers.L2VPNSerializer
+    filterset_class = filtersets.L2VPNFilterSet
+
+
+class L2VPNTerminationViewSet(NetBoxModelViewSet):
+    queryset = L2VPNTermination.objects.prefetch_related('assigned_object')
+    serializer_class = serializers.L2VPNTerminationSerializer
+    filterset_class = filtersets.L2VPNTerminationFilterSet

+ 53 - 0
netbox/vpn/choices.py

@@ -199,3 +199,56 @@ class DHGroupChoices(ChoiceSet):
         (GROUP_33, _('Group {n}').format(n=33)),
         (GROUP_34, _('Group {n}').format(n=34)),
     )
+
+
+#
+# L2VPN
+#
+
+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'),
+        )),
+        ('VXLAN', (
+            (TYPE_VXLAN, 'VXLAN'),
+            (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'),
+        )),
+        ('L2VPN E-VPN', (
+            (TYPE_MPLS_EVPN, 'MPLS EVPN'),
+            (TYPE_PBB_EVPN, 'PBB EVPN'),
+        )),
+        ('E-Line', (
+            (TYPE_EPL, 'EPL'),
+            (TYPE_EVPL, 'EVPL'),
+        )),
+        ('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')),
+        )),
+    )
+
+    P2P = (
+        TYPE_VPWS,
+        TYPE_EPL,
+        TYPE_EPLAN,
+        TYPE_EPTREE
+    )

+ 7 - 0
netbox/vpn/constants.py

@@ -0,0 +1,7 @@
+from django.db.models import Q
+
+L2VPN_ASSIGNMENT_MODELS = Q(
+    Q(app_label='dcim', model='interface') |
+    Q(app_label='ipam', model='vlan') |
+    Q(app_label='virtualization', model='vminterface')
+)

+ 177 - 3
netbox/vpn/filtersets.py

@@ -2,12 +2,12 @@ import django_filters
 from django.db.models import Q
 from django.utils.translation import gettext as _
 
-from dcim.models import Interface
-from ipam.models import IPAddress
+from dcim.models import Device, Interface
+from ipam.models import IPAddress, RouteTarget, VLAN
 from netbox.filtersets import NetBoxModelFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
-from virtualization.models import VMInterface
+from virtualization.models import VirtualMachine, VMInterface
 from .choices import *
 from .models import *
 
@@ -17,6 +17,8 @@ __all__ = (
     'IPSecPolicyFilterSet',
     'IPSecProfileFilterSet',
     'IPSecProposalFilterSet',
+    'L2VPNFilterSet',
+    'L2VPNTerminationFilterSet',
     'TunnelFilterSet',
     'TunnelTerminationFilterSet',
 )
@@ -239,3 +241,175 @@ class IPSecProfileFilterSet(NetBoxModelFilterSet):
             Q(description__icontains=value) |
             Q(comments__icontains=value)
         )
+
+
+class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=L2VPNTypeChoices,
+        null_value=None
+    )
+    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 = ['id', 'identifier', 'name', 'slug', 'type', 'description']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
+        try:
+            qs_filter |= Q(identifier=int(value))
+        except ValueError:
+            pass
+        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__slug',
+        queryset=L2VPN.objects.all(),
+        to_field_name='slug',
+        label=_('L2VPN (slug)'),
+    )
+    region = MultiValueCharFilter(
+        method='filter_region',
+        field_name='slug',
+        label=_('Region (slug)'),
+    )
+    region_id = MultiValueNumberFilter(
+        method='filter_region',
+        field_name='pk',
+        label=_('Region (ID)'),
+    )
+    site = MultiValueCharFilter(
+        method='filter_site',
+        field_name='slug',
+        label=_('Site (slug)'),
+    )
+    site_id = MultiValueNumberFilter(
+        method='filter_site',
+        field_name='pk',
+        label=_('Site (ID)'),
+    )
+    device = django_filters.ModelMultipleChoiceFilter(
+        field_name='interface__device__name',
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        label=_('Device (name)'),
+    )
+    device_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='interface__device',
+        queryset=Device.objects.all(),
+        label=_('Device (ID)'),
+    )
+    virtual_machine = django_filters.ModelMultipleChoiceFilter(
+        field_name='vminterface__virtual_machine__name',
+        queryset=VirtualMachine.objects.all(),
+        to_field_name='name',
+        label=_('Virtual machine (name)'),
+    )
+    virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='vminterface__virtual_machine',
+        queryset=VirtualMachine.objects.all(),
+        label=_('Virtual machine (ID)'),
+    )
+    interface = django_filters.ModelMultipleChoiceFilter(
+        field_name='interface__name',
+        queryset=Interface.objects.all(),
+        to_field_name='name',
+        label=_('Interface (name)'),
+    )
+    interface_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='interface',
+        queryset=Interface.objects.all(),
+        label=_('Interface (ID)'),
+    )
+    vminterface = django_filters.ModelMultipleChoiceFilter(
+        field_name='vminterface__name',
+        queryset=VMInterface.objects.all(),
+        to_field_name='name',
+        label=_('VM interface (name)'),
+    )
+    vminterface_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='vminterface',
+        queryset=VMInterface.objects.all(),
+        label=_('VM Interface (ID)'),
+    )
+    vlan = django_filters.ModelMultipleChoiceFilter(
+        field_name='vlan__name',
+        queryset=VLAN.objects.all(),
+        to_field_name='name',
+        label=_('VLAN (name)'),
+    )
+    vlan_vid = django_filters.NumberFilter(
+        field_name='vlan__vid',
+        label=_('VLAN number (1-4094)'),
+    )
+    vlan_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='vlan',
+        queryset=VLAN.objects.all(),
+        label=_('VLAN (ID)'),
+    )
+    assigned_object_type = ContentTypeFilter()
+
+    class Meta:
+        model = L2VPNTermination
+        fields = ('id', 'assigned_object_type_id')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = Q(l2vpn__name__icontains=value)
+        return queryset.filter(qs_filter)
+
+    def filter_assigned_object(self, queryset, name, value):
+        qs = queryset.filter(
+            Q(**{'{}__in'.format(name): value})
+        )
+        return qs
+
+    def filter_site(self, queryset, name, value):
+        qs = queryset.filter(
+            Q(
+                Q(**{'vlan__site__{}__in'.format(name): value}) |
+                Q(**{'interface__device__site__{}__in'.format(name): value}) |
+                Q(**{'vminterface__virtual_machine__site__{}__in'.format(name): value})
+            )
+        )
+        return qs
+
+    def filter_region(self, queryset, name, value):
+        qs = queryset.filter(
+            Q(
+                Q(**{'vlan__site__region__{}__in'.format(name): value}) |
+                Q(**{'interface__device__site__region__{}__in'.format(name): value}) |
+                Q(**{'vminterface__virtual_machine__site__region__{}__in'.format(name): value})
+            )
+        )
+        return qs

+ 31 - 0
netbox/vpn/forms/bulk_edit.py

@@ -14,6 +14,8 @@ __all__ = (
     'IPSecPolicyBulkEditForm',
     'IPSecProfileBulkEditForm',
     'IPSecProposalBulkEditForm',
+    'L2VPNBulkEditForm',
+    'L2VPNTerminationBulkEditForm',
     'TunnelBulkEditForm',
     'TunnelTerminationBulkEditForm',
 )
@@ -241,3 +243,32 @@ class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm):
     nullable_fields = (
         'description', 'comments',
     )
+
+
+class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
+    type = forms.ChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(L2VPNTypeChoices),
+        required=False
+    )
+    tenant = DynamicModelChoiceField(
+        label=_('Tenant'),
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+    comments = CommentField()
+
+    model = L2VPN
+    fieldsets = (
+        (None, ('type', 'tenant', 'description')),
+    )
+    nullable_fields = ('tenant', 'description', 'comments')
+
+
+class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm):
+    model = L2VPN

+ 93 - 1
netbox/vpn/forms/bulk_import.py

@@ -1,7 +1,8 @@
+from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 
 from dcim.models import Device, Interface
-from ipam.models import IPAddress
+from ipam.models import IPAddress, VLAN
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField
@@ -15,6 +16,8 @@ __all__ = (
     'IPSecPolicyImportForm',
     'IPSecProfileImportForm',
     'IPSecProposalImportForm',
+    'L2VPNImportForm',
+    'L2VPNTerminationImportForm',
     'TunnelImportForm',
     'TunnelTerminationImportForm',
 )
@@ -228,3 +231,92 @@ class IPSecProfileImportForm(NetBoxModelImportForm):
         fields = (
             'name', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags',
         )
+
+
+class L2VPNImportForm(NetBoxModelImportForm):
+    tenant = CSVModelChoiceField(
+        label=_('Tenant'),
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+    )
+    type = CSVChoiceField(
+        label=_('Type'),
+        choices=L2VPNTypeChoices,
+        help_text=_('L2VPN type')
+    )
+
+    class Meta:
+        model = L2VPN
+        fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description',
+                  'comments', 'tags')
+
+
+class L2VPNTerminationImportForm(NetBoxModelImportForm):
+    l2vpn = CSVModelChoiceField(
+        queryset=L2VPN.objects.all(),
+        required=True,
+        to_field_name='name',
+        label=_('L2VPN'),
+    )
+    device = CSVModelChoiceField(
+        label=_('Device'),
+        queryset=Device.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Parent device (for interface)')
+    )
+    virtual_machine = CSVModelChoiceField(
+        label=_('Virtual machine'),
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Parent virtual machine (for interface)')
+    )
+    interface = CSVModelChoiceField(
+        label=_('Interface'),
+        queryset=Interface.objects.none(),  # Can also refer to VMInterface
+        required=False,
+        to_field_name='name',
+        help_text=_('Assigned interface (device or VM)')
+    )
+    vlan = CSVModelChoiceField(
+        label=_('VLAN'),
+        queryset=VLAN.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Assigned VLAN')
+    )
+
+    class Meta:
+        model = L2VPNTermination
+        fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan', 'tags')
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit interface queryset by device or VM
+            if data.get('device'):
+                self.fields['interface'].queryset = Interface.objects.filter(
+                    **{f"device__{self.fields['device'].to_field_name}": data['device']}
+                )
+            elif data.get('virtual_machine'):
+                self.fields['interface'].queryset = VMInterface.objects.filter(
+                    **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
+                )
+
+    def clean(self):
+        super().clean()
+
+        if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'):
+            raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.'))
+        if not self.instance and not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')):
+            raise ValidationError(_('Each termination must specify either an interface or a VLAN.'))
+        if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'):
+            raise ValidationError(_('Cannot assign both an interface and a VLAN.'))
+
+        # if this is an update we might not have interface or vlan in the form data
+        if self.cleaned_data.get('interface') or self.cleaned_data.get('vlan'):
+            self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')

+ 98 - 1
netbox/vpn/forms/filtersets.py

@@ -1,10 +1,18 @@
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 
+from dcim.models import Device, Region, Site
+from ipam.models import RouteTarget, VLAN
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
-from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
+from utilities.forms.fields import (
+    ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
+)
+from utilities.forms.utils import add_blank_choice
+from virtualization.models import VirtualMachine
 from vpn.choices import *
+from vpn.constants import L2VPN_ASSIGNMENT_MODELS
 from vpn.models import *
 
 __all__ = (
@@ -13,6 +21,8 @@ __all__ = (
     'IPSecPolicyFilterForm',
     'IPSecProfileFilterForm',
     'IPSecProposalFilterForm',
+    'L2VPNFilterForm',
+    'L2VPNTerminationFilterForm',
     'TunnelFilterForm',
     'TunnelTerminationFilterForm',
 )
@@ -180,3 +190,90 @@ class IPSecProfileFilterForm(NetBoxModelFilterSetForm):
         label=_('IPSec policy')
     )
     tag = TagFilterField(model)
+
+
+class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+    model = L2VPN
+    fieldsets = (
+        (None, ('q', 'filter_id', 'tag')),
+        (_('Attributes'), ('type', 'import_target_id', 'export_target_id')),
+        (_('Tenant'), ('tenant_group_id', 'tenant_id')),
+    )
+    type = forms.ChoiceField(
+        label=_('Type'),
+        choices=add_blank_choice(L2VPNTypeChoices),
+        required=False
+    )
+    import_target_id = DynamicModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        required=False,
+        label=_('Import targets')
+    )
+    export_target_id = DynamicModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        required=False,
+        label=_('Export targets')
+    )
+    tag = TagFilterField(model)
+
+
+class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
+    model = L2VPNTermination
+    fieldsets = (
+        (None, ('filter_id', 'l2vpn_id',)),
+        (_('Assigned Object'), (
+            'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id',
+        )),
+    )
+    l2vpn_id = DynamicModelChoiceField(
+        queryset=L2VPN.objects.all(),
+        required=False,
+        label=_('L2VPN')
+    )
+    assigned_object_type_id = ContentTypeMultipleChoiceField(
+        queryset=ContentType.objects.filter(L2VPN_ASSIGNMENT_MODELS),
+        required=False,
+        label=_('Assigned Object Type'),
+        limit_choices_to=L2VPN_ASSIGNMENT_MODELS
+    )
+    region_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Region')
+    )
+    site_id = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'region_id': '$region_id'
+        },
+        label=_('Site')
+    )
+    device_id = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site_id'
+        },
+        label=_('Device')
+    )
+    vlan_id = DynamicModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site_id'
+        },
+        label=_('VLAN')
+    )
+    virtual_machine_id = DynamicModelMultipleChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'site_id': '$site_id'
+        },
+        label=_('Virtual Machine')
+    )

+ 98 - 2
netbox/vpn/forms/model_forms.py

@@ -1,11 +1,12 @@
 from django import forms
+from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 
 from dcim.models import Device, Interface
-from ipam.models import IPAddress
+from ipam.models import IPAddress, RouteTarget, VLAN
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
-from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
 from utilities.forms.utils import add_blank_choice
 from utilities.forms.widgets import HTMXSelect
 from virtualization.models import VirtualMachine, VMInterface
@@ -18,6 +19,8 @@ __all__ = (
     'IPSecPolicyForm',
     'IPSecProfileForm',
     'IPSecProposalForm',
+    'L2VPNForm',
+    'L2VPNTerminationForm',
     'TunnelCreateForm',
     'TunnelForm',
     'TunnelTerminationForm',
@@ -355,3 +358,96 @@ class IPSecProfileForm(NetBoxModelForm):
         fields = [
             'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags',
         ]
+
+
+#
+# L2VPN
+#
+
+class L2VPNForm(TenancyForm, NetBoxModelForm):
+    slug = SlugField()
+    import_targets = DynamicModelMultipleChoiceField(
+        label=_('Import targets'),
+        queryset=RouteTarget.objects.all(),
+        required=False
+    )
+    export_targets = DynamicModelMultipleChoiceField(
+        label=_('Export targets'),
+        queryset=RouteTarget.objects.all(),
+        required=False
+    )
+    comments = CommentField()
+
+    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', 'import_targets', 'export_targets', 'tenant', 'description',
+            'comments', 'tags'
+        )
+
+
+class L2VPNTerminationForm(NetBoxModelForm):
+    l2vpn = DynamicModelChoiceField(
+        queryset=L2VPN.objects.all(),
+        required=True,
+        query_params={},
+        label=_('L2VPN'),
+        fetch_trigger='open'
+    )
+    vlan = DynamicModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        selector=True,
+        label=_('VLAN')
+    )
+    interface = DynamicModelChoiceField(
+        label=_('Interface'),
+        queryset=Interface.objects.all(),
+        required=False,
+        selector=True
+    )
+    vminterface = DynamicModelChoiceField(
+        queryset=VMInterface.objects.all(),
+        required=False,
+        selector=True,
+        label=_('Interface')
+    )
+
+    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['interface'] = instance.assigned_object
+            elif type(instance.assigned_object) is VLAN:
+                initial['vlan'] = instance.assigned_object
+            elif type(instance.assigned_object) is VMInterface:
+                initial['vminterface'] = instance.assigned_object
+            kwargs['initial'] = initial
+
+        super().__init__(*args, **kwargs)
+
+    def clean(self):
+        super().clean()
+
+        interface = self.cleaned_data.get('interface')
+        vminterface = self.cleaned_data.get('vminterface')
+        vlan = self.cleaned_data.get('vlan')
+
+        if not (interface or vminterface or vlan):
+            raise ValidationError(_('A termination must specify an interface or VLAN.'))
+        if len([x for x in (interface, vminterface, vlan) if x]) > 1:
+            raise ValidationError(_('A termination can only have one terminating object (an interface or VLAN).'))
+
+        self.instance.assigned_object = interface or vminterface or vlan

+ 30 - 0
netbox/vpn/graphql/gfk_mixins.py

@@ -0,0 +1,30 @@
+import graphene
+
+from dcim.graphql.types import InterfaceType
+from dcim.models import Interface
+from ipam.graphql.types import VLANType
+from ipam.models import VLAN
+from virtualization.graphql.types import VMInterfaceType
+from virtualization.models import VMInterface
+
+__all__ = (
+    'L2VPNAssignmentType',
+)
+
+
+class L2VPNAssignmentType(graphene.Union):
+    class Meta:
+        types = (
+            InterfaceType,
+            VLANType,
+            VMInterfaceType,
+        )
+
+    @classmethod
+    def resolve_type(cls, instance, info):
+        if type(instance) is Interface:
+            return InterfaceType
+        if type(instance) is VLAN:
+            return VLANType
+        if type(instance) is VMInterface:
+            return VMInterfaceType

+ 12 - 0
netbox/vpn/graphql/schema.py

@@ -38,6 +38,18 @@ class VPNQuery(graphene.ObjectType):
     def resolve_ipsec_proposal_list(root, info, **kwargs):
         return gql_query_optimizer(models.IPSecProposal.objects.all(), info)
 
+    l2vpn = ObjectField(L2VPNType)
+    l2vpn_list = ObjectListField(L2VPNType)
+
+    def resolve_l2vpn_list(root, info, **kwargs):
+        return gql_query_optimizer(models.L2VPN.objects.all(), info)
+
+    l2vpn_termination = ObjectField(L2VPNTerminationType)
+    l2vpn_termination_list = ObjectListField(L2VPNTerminationType)
+
+    def resolve_l2vpn_termination_list(root, info, **kwargs):
+        return gql_query_optimizer(models.L2VPNTermination.objects.all(), info)
+
     tunnel = ObjectField(TunnelType)
     tunnel_list = ObjectListField(TunnelType)
 

+ 21 - 1
netbox/vpn/graphql/types.py

@@ -1,4 +1,6 @@
-from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
+import graphene
+
+from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
 from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
 from vpn import filtersets, models
 
@@ -8,6 +10,8 @@ __all__ = (
     'IPSecPolicyType',
     'IPSecProfileType',
     'IPSecProposalType',
+    'L2VPNType',
+    'L2VPNTerminationType',
     'TunnelTerminationType',
     'TunnelType',
 )
@@ -67,3 +71,19 @@ class IPSecProfileType(OrganizationalObjectType):
         model = models.IPSecProfile
         fields = '__all__'
         filterset_class = filtersets.IPSecProfileFilterSet
+
+
+class L2VPNType(ContactsMixin, NetBoxObjectType):
+    class Meta:
+        model = models.L2VPN
+        fields = '__all__'
+        filtersets_class = filtersets.L2VPNFilterSet
+
+
+class L2VPNTerminationType(NetBoxObjectType):
+    assigned_object = graphene.Field('vpn.graphql.gfk_mixins.L2VPNAssignmentType')
+
+    class Meta:
+        model = models.L2VPNTermination
+        exclude = ('assigned_object_type', 'assigned_object_id')
+        filtersets_class = filtersets.L2VPNTerminationFilterSet

+ 73 - 0
netbox/vpn/migrations/0002_move_l2vpn.py

@@ -0,0 +1,73 @@
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+import utilities.json
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0099_cachedvalue_ordering'),
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('tenancy', '0012_contactassignment_custom_fields'),
+        ('ipam', '0068_move_l2vpn'),
+        ('vpn', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.SeparateDatabaseAndState(
+            state_operations=[
+                migrations.CreateModel(
+                    name='L2VPN',
+                    fields=[
+                        ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                        ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                        ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                        ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+                        ('description', models.CharField(blank=True, max_length=200)),
+                        ('comments', models.TextField(blank=True)),
+                        ('name', models.CharField(max_length=100, unique=True)),
+                        ('slug', models.SlugField(max_length=100, unique=True)),
+                        ('type', models.CharField(max_length=50)),
+                        ('identifier', models.BigIntegerField(blank=True, null=True)),
+                        ('export_targets', models.ManyToManyField(blank=True, related_name='exporting_l2vpns', to='ipam.routetarget')),
+                        ('import_targets', models.ManyToManyField(blank=True, related_name='importing_l2vpns', to='ipam.routetarget')),
+                        ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+                        ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='l2vpns', to='tenancy.tenant')),
+                    ],
+                    options={
+                        'verbose_name': 'L2VPN',
+                        'verbose_name_plural': 'L2VPNs',
+                        'ordering': ('name', 'identifier'),
+                    },
+                ),
+                migrations.CreateModel(
+                    name='L2VPNTermination',
+                    fields=[
+                        ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                        ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                        ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                        ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+                        ('assigned_object_id', models.PositiveBigIntegerField()),
+                        ('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
+                        ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='vpn.l2vpn')),
+                        ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+                    ],
+                    options={
+                        'verbose_name': 'L2VPN termination',
+                        'verbose_name_plural': 'L2VPN terminations',
+                        'ordering': ('l2vpn',),
+                    },
+                ),
+            ],
+            # Tables have been renamed from ipam
+            database_operations=[],
+        ),
+        migrations.AddConstraint(
+            model_name='l2vpntermination',
+            constraint=models.UniqueConstraint(
+                fields=('assigned_object_type', 'assigned_object_id'),
+                name='vpn_l2vpntermination_assigned_object'
+            ),
+        ),
+    ]

+ 1 - 0
netbox/vpn/models/__init__.py

@@ -1,2 +1,3 @@
 from .crypto import *
+from .l2vpn import *
 from .tunnels import *

+ 7 - 7
netbox/ipam/models/l2vpn.py → netbox/vpn/models/l2vpn.py

@@ -6,10 +6,10 @@ from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
 
 from core.models import ContentType
-from ipam.choices import L2VPNTypeChoices
-from ipam.constants import L2VPN_ASSIGNMENT_MODELS
 from netbox.models import NetBoxModel, PrimaryModel
 from netbox.models.features import ContactsMixin
+from vpn.choices import L2VPNTypeChoices
+from vpn.constants import L2VPN_ASSIGNMENT_MODELS
 
 __all__ = (
     'L2VPN',
@@ -69,7 +69,7 @@ class L2VPN(ContactsMixin, PrimaryModel):
         return f'{self.name}'
 
     def get_absolute_url(self):
-        return reverse('ipam:l2vpn', args=[self.pk])
+        return reverse('vpn:l2vpn', args=[self.pk])
 
     @cached_property
     def can_add_termination(self):
@@ -81,7 +81,7 @@ class L2VPN(ContactsMixin, PrimaryModel):
 
 class L2VPNTermination(NetBoxModel):
     l2vpn = models.ForeignKey(
-        to='ipam.L2VPN',
+        to='vpn.L2VPN',
         on_delete=models.CASCADE,
         related_name='terminations'
     )
@@ -99,7 +99,7 @@ class L2VPNTermination(NetBoxModel):
 
     clone_fields = ('l2vpn',)
     prerequisite_models = (
-        'ipam.L2VPN',
+        'vpn.L2VPN',
     )
 
     class Meta:
@@ -107,7 +107,7 @@ class L2VPNTermination(NetBoxModel):
         constraints = (
             models.UniqueConstraint(
                 fields=('assigned_object_type', 'assigned_object_id'),
-                name='ipam_l2vpntermination_assigned_object'
+                name='vpn_l2vpntermination_assigned_object'
             ),
         )
         verbose_name = _('L2VPN termination')
@@ -119,7 +119,7 @@ class L2VPNTermination(NetBoxModel):
         return super().__str__()
 
     def get_absolute_url(self):
-        return reverse('ipam:l2vpntermination', args=[self.pk])
+        return reverse('vpn:l2vpntermination', args=[self.pk])
 
     def clean(self):
         # Only check is assigned_object is set.  Required otherwise we have an Integrity Error thrown.

+ 12 - 0
netbox/vpn/search.py

@@ -63,3 +63,15 @@ class IPSecProfileIndex(SearchIndex):
         ('comments', 5000),
     )
     display_attrs = ('description',)
+
+
+@register_search
+class L2VPNIndex(SearchIndex):
+    model = models.L2VPN
+    fields = (
+        ('name', 100),
+        ('slug', 110),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('type', 'identifier', 'tenant', 'description')

+ 3 - 0
netbox/vpn/tables/__init__.py

@@ -0,0 +1,3 @@
+from .crypto import *
+from .l2vpn import *
+from .tunnels import *

+ 0 - 81
netbox/vpn/tables.py → netbox/vpn/tables/crypto.py

@@ -1,8 +1,6 @@
 import django_tables2 as tables
 from django.utils.translation import gettext_lazy as _
-from django_tables2.utils import Accessor
 
-from tenancy.tables import TenancyColumnsMixin
 from netbox.tables import NetBoxTable, columns
 from vpn.models import *
 
@@ -12,88 +10,9 @@ __all__ = (
     'IPSecPolicyTable',
     'IPSecProposalTable',
     'IPSecProfileTable',
-    'TunnelTable',
-    'TunnelTerminationTable',
 )
 
 
-class TunnelTable(TenancyColumnsMixin, NetBoxTable):
-    name = tables.Column(
-        verbose_name=_('Name'),
-        linkify=True
-    )
-    status = columns.ChoiceFieldColumn(
-        verbose_name=_('Status')
-    )
-    ipsec_profile = tables.Column(
-        verbose_name=_('IPSec profile'),
-        linkify=True
-    )
-    terminations_count = columns.LinkedCountColumn(
-        accessor=Accessor('count_terminations'),
-        viewname='vpn:tunneltermination_list',
-        url_params={'tunnel_id': 'pk'},
-        verbose_name=_('Terminations')
-    )
-    comments = columns.MarkdownColumn(
-        verbose_name=_('Comments'),
-    )
-    tags = columns.TagColumn(
-        url_name='vpn:tunnel_list'
-    )
-
-    class Meta(NetBoxTable.Meta):
-        model = Tunnel
-        fields = (
-            'pk', 'id', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', 'tunnel_id',
-            'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated',
-        )
-        default_columns = ('pk', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'terminations_count')
-
-
-class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable):
-    tunnel = tables.Column(
-        verbose_name=_('Tunnel'),
-        linkify=True
-    )
-    role = columns.ChoiceFieldColumn(
-        verbose_name=_('Role')
-    )
-    termination_parent = tables.Column(
-        accessor='termination__parent_object',
-        linkify=True,
-        orderable=False,
-        verbose_name=_('Host')
-    )
-    termination = tables.Column(
-        verbose_name=_('Termination'),
-        linkify=True
-    )
-    ip_addresses = tables.ManyToManyColumn(
-        accessor=tables.A('termination__ip_addresses'),
-        orderable=False,
-        linkify_item=True,
-        verbose_name=_('IP Addresses')
-    )
-    outside_ip = tables.Column(
-        verbose_name=_('Outside IP'),
-        linkify=True
-    )
-    tags = columns.TagColumn(
-        url_name='vpn:tunneltermination_list'
-    )
-
-    class Meta(NetBoxTable.Meta):
-        model = TunnelTermination
-        fields = (
-            'pk', 'id', 'tunnel', 'role', 'termination_parent', 'termination', 'ip_addresses', 'outside_ip', 'tags',
-            'created', 'last_updated',
-        )
-        default_columns = (
-            'pk', 'id', 'tunnel', 'role', 'termination_parent', 'termination', 'ip_addresses', 'outside_ip',
-        )
-
-
 class IKEProposalTable(NetBoxTable):
     name = tables.Column(
         verbose_name=_('Name'),

+ 3 - 3
netbox/ipam/tables/l2vpn.py → netbox/vpn/tables/l2vpn.py

@@ -1,9 +1,9 @@
-from django.utils.translation import gettext_lazy as _
 import django_tables2 as tables
+from django.utils.translation import gettext_lazy as _
 
-from ipam.models import L2VPN, L2VPNTermination
 from netbox.tables import NetBoxTable, columns
 from tenancy.tables import TenancyColumnsMixin
+from vpn.models import L2VPN, L2VPNTermination
 
 __all__ = (
     'L2VPNTable',
@@ -37,7 +37,7 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name=_('Comments'),
     )
     tags = columns.TagColumn(
-        url_name='ipam:l2vpn_list'
+        url_name='vpn:l2vpn_list'
     )
 
     class Meta(NetBoxTable.Meta):

+ 87 - 0
netbox/vpn/tables/tunnels.py

@@ -0,0 +1,87 @@
+import django_tables2 as tables
+from django.utils.translation import gettext_lazy as _
+from django_tables2.utils import Accessor
+
+from netbox.tables import NetBoxTable, columns
+from tenancy.tables import TenancyColumnsMixin
+from vpn.models import *
+
+__all__ = (
+    'TunnelTable',
+    'TunnelTerminationTable',
+)
+
+
+class TunnelTable(TenancyColumnsMixin, NetBoxTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True
+    )
+    status = columns.ChoiceFieldColumn(
+        verbose_name=_('Status')
+    )
+    ipsec_profile = tables.Column(
+        verbose_name=_('IPSec profile'),
+        linkify=True
+    )
+    terminations_count = columns.LinkedCountColumn(
+        accessor=Accessor('count_terminations'),
+        viewname='vpn:tunneltermination_list',
+        url_params={'tunnel_id': 'pk'},
+        verbose_name=_('Terminations')
+    )
+    comments = columns.MarkdownColumn(
+        verbose_name=_('Comments'),
+    )
+    tags = columns.TagColumn(
+        url_name='vpn:tunnel_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = Tunnel
+        fields = (
+            'pk', 'id', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', 'tunnel_id',
+            'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated',
+        )
+        default_columns = ('pk', 'name', 'status', 'encapsulation', 'tenant', 'terminations_count')
+
+
+class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable):
+    tunnel = tables.Column(
+        verbose_name=_('Tunnel'),
+        linkify=True
+    )
+    role = columns.ChoiceFieldColumn(
+        verbose_name=_('Role')
+    )
+    interface_parent = tables.Column(
+        accessor='interface__parent_object',
+        linkify=True,
+        orderable=False,
+        verbose_name=_('Host')
+    )
+    interface = tables.Column(
+        verbose_name=_('Interface'),
+        linkify=True
+    )
+    ip_addresses = tables.ManyToManyColumn(
+        accessor=tables.A('interface__ip_addresses'),
+        orderable=False,
+        linkify_item=True,
+        verbose_name=_('IP Addresses')
+    )
+    outside_ip = tables.Column(
+        verbose_name=_('Outside IP'),
+        linkify=True
+    )
+    tags = columns.TagColumn(
+        url_name='vpn:tunneltermination_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = TunnelTermination
+        fields = (
+            'pk', 'id', 'tunnel', 'role', 'interface_parent', 'interface', 'ip_addresses', 'outside_ip', 'tags',
+            'created', 'last_updated',
+        )
+        default_columns = ('pk', 'id', 'tunnel', 'role', 'interface_parent', 'interface', 'ip_addresses', 'outside_ip')

+ 94 - 0
netbox/vpn/tests/test_api.py

@@ -2,6 +2,7 @@ from django.urls import reverse
 
 from dcim.choices import InterfaceTypeChoices
 from dcim.models import Interface
+from ipam.models import VLAN
 from utilities.testing import APITestCase, APIViewTestCases, create_test_device
 from vpn.choices import *
 from vpn.models import *
@@ -471,3 +472,96 @@ class IPSecProfileTest(APIViewTestCases.APIViewTestCase):
             'ipsec_policy': ipsec_policies[1].pk,
             'description': 'New description',
         }
+
+
+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', slug='l2vpn-1', type='vxlan', identifier=650001),
+            L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
+            L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'),  # No RD
+        )
+        L2VPN.objects.bulk_create(l2vpns)
+
+
+class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase):
+    model = L2VPNTermination
+    brief_fields = ['display', 'id', 'l2vpn', 'url']
+
+    @classmethod
+    def setUpTestData(cls):
+
+        vlans = (
+            VLAN(name='VLAN 1', vid=651),
+            VLAN(name='VLAN 2', vid=652),
+            VLAN(name='VLAN 3', vid=653),
+            VLAN(name='VLAN 4', vid=654),
+            VLAN(name='VLAN 5', vid=655),
+            VLAN(name='VLAN 6', vid=656),
+            VLAN(name='VLAN 7', vid=657)
+        )
+        VLAN.objects.bulk_create(vlans)
+
+        l2vpns = (
+            L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
+            L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
+            L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'),  # No RD
+        )
+        L2VPN.objects.bulk_create(l2vpns)
+
+        l2vpnterminations = (
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2])
+        )
+        L2VPNTermination.objects.bulk_create(l2vpnterminations)
+
+        cls.create_data = [
+            {
+                'l2vpn': l2vpns[0].pk,
+                'assigned_object_type': 'ipam.vlan',
+                'assigned_object_id': vlans[3].pk,
+            },
+            {
+                'l2vpn': l2vpns[0].pk,
+                'assigned_object_type': 'ipam.vlan',
+                'assigned_object_id': vlans[4].pk,
+            },
+            {
+                'l2vpn': l2vpns[0].pk,
+                'assigned_object_type': 'ipam.vlan',
+                'assigned_object_id': vlans[5].pk,
+            },
+        ]
+
+        cls.bulk_update_data = {
+            'l2vpn': l2vpns[2].pk
+        }

+ 165 - 4
netbox/vpn/tests/test_filtersets.py

@@ -1,13 +1,14 @@
+from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 
 from dcim.choices import InterfaceTypeChoices
-from dcim.models import Interface
-from ipam.models import IPAddress
-from virtualization.models import VMInterface
+from dcim.models import Device, Interface, Site
+from ipam.models import IPAddress, VLAN, RouteTarget
+from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
+from virtualization.models import VirtualMachine, VMInterface
 from vpn.choices import *
 from vpn.filtersets import *
 from vpn.models import *
-from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 
 
 class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -590,3 +591,163 @@ class IPSecProfileTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'ipsec_policy': [ipsec_policies[0].name, ipsec_policies[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = L2VPN.objects.all()
+    filterset = L2VPNFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        route_targets = (
+            RouteTarget(name='1:1'),
+            RouteTarget(name='1:2'),
+            RouteTarget(name='1:3'),
+            RouteTarget(name='2:1'),
+            RouteTarget(name='2:2'),
+            RouteTarget(name='2:3'),
+        )
+        RouteTarget.objects.bulk_create(route_targets)
+
+        l2vpns = (
+            L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=65001),
+            L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VPWS, identifier=65002),
+            L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VPLS),
+        )
+        L2VPN.objects.bulk_create(l2vpns)
+        l2vpns[0].import_targets.add(route_targets[0])
+        l2vpns[1].import_targets.add(route_targets[1])
+        l2vpns[2].import_targets.add(route_targets[2])
+        l2vpns[0].export_targets.add(route_targets[3])
+        l2vpns[1].export_targets.add(route_targets[4])
+        l2vpns[2].export_targets.add(route_targets[5])
+
+    def test_name(self):
+        params = {'name': ['L2VPN 1', 'L2VPN 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_slug(self):
+        params = {'slug': ['l2vpn-1', 'l2vpn-2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_identifier(self):
+        params = {'identifier': ['65001', '65002']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_type(self):
+        params = {'type': [L2VPNTypeChoices.TYPE_VXLAN, L2VPNTypeChoices.TYPE_VPWS]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_import_targets(self):
+        route_targets = RouteTarget.objects.filter(name__in=['1:1', '1:2'])
+        params = {'import_target_id': [route_targets[0].pk, route_targets[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'import_target': [route_targets[0].name, route_targets[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_export_targets(self):
+        route_targets = RouteTarget.objects.filter(name__in=['2:1', '2:2'])
+        params = {'export_target_id': [route_targets[0].pk, route_targets[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'export_target': [route_targets[0].name, route_targets[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = L2VPNTermination.objects.all()
+    filterset = L2VPNTerminationFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        device = create_test_device('Device 1')
+        interfaces = (
+            Interface(name='Interface 1', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(name='Interface 2', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(name='Interface 3', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+        )
+        Interface.objects.bulk_create(interfaces)
+
+        vm = create_test_virtualmachine('Virtual Machine 1')
+        vminterfaces = (
+            VMInterface(name='Interface 1', virtual_machine=vm),
+            VMInterface(name='Interface 2', virtual_machine=vm),
+            VMInterface(name='Interface 3', virtual_machine=vm),
+        )
+        VMInterface.objects.bulk_create(vminterfaces)
+
+        vlans = (
+            VLAN(name='VLAN 1', vid=101),
+            VLAN(name='VLAN 2', vid=102),
+            VLAN(name='VLAN 3', vid=103),
+        )
+        VLAN.objects.bulk_create(vlans)
+
+        l2vpns = (
+            L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=65001),
+            L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=65002),
+            L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'),  # No RD,
+        )
+        L2VPN.objects.bulk_create(l2vpns)
+
+        l2vpnterminations = (
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
+            L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlans[1]),
+            L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vlans[2]),
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=interfaces[0]),
+            L2VPNTermination(l2vpn=l2vpns[1], assigned_object=interfaces[1]),
+            L2VPNTermination(l2vpn=l2vpns[2], assigned_object=interfaces[2]),
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vminterfaces[0]),
+            L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vminterfaces[1]),
+            L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vminterfaces[2]),
+        )
+        L2VPNTermination.objects.bulk_create(l2vpnterminations)
+
+    def test_l2vpn(self):
+        l2vpns = L2VPN.objects.all()[:2]
+        params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+        params = {'l2vpn': [l2vpns[0].slug, l2vpns[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+
+    def test_content_type(self):
+        params = {'assigned_object_type_id': ContentType.objects.get(model='vlan').pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_interface(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)
+
+    def test_vminterface(self):
+        vminterfaces = VMInterface.objects.all()[:2]
+        params = {'vminterface_id': [vminterfaces[0].pk, vminterfaces[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_vlan(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)
+
+    def test_site(self):
+        site = Site.objects.all().first()
+        params = {'site_id': [site.pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+        params = {'site': ['site-1']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_device(self):
+        device = Device.objects.all().first()
+        params = {'device_id': [device.pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+        params = {'device': ['Device 1']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_virtual_machine(self):
+        virtual_machine = VirtualMachine.objects.all().first()
+        params = {'virtual_machine_id': [virtual_machine.pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+        params = {'virtual_machine': ['Virtual Machine 1']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

+ 79 - 0
netbox/vpn/tests/test_models.py

@@ -0,0 +1,79 @@
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+
+from dcim.models import Interface, Device, DeviceRole, DeviceType, Manufacturer, Site
+from ipam.models import VLAN
+from vpn.models import *
+
+
+class TestL2VPNTermination(TestCase):
+
+    @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)
+        role = DeviceRole.objects.create(name='Switch')
+        device = Device.objects.create(
+            name='Device 1',
+            site=site,
+            device_type=device_type,
+            role=role,
+            status='active'
+        )
+
+        interfaces = (
+            Interface(name='Interface 1', device=device, type='1000baset'),
+            Interface(name='Interface 2', device=device, type='1000baset'),
+            Interface(name='Interface 3', device=device, type='1000baset'),
+            Interface(name='Interface 4', device=device, type='1000baset'),
+            Interface(name='Interface 5', device=device, type='1000baset'),
+        )
+
+        Interface.objects.bulk_create(interfaces)
+
+        vlans = (
+            VLAN(name='VLAN 1', vid=651),
+            VLAN(name='VLAN 2', vid=652),
+            VLAN(name='VLAN 3', vid=653),
+            VLAN(name='VLAN 4', vid=654),
+            VLAN(name='VLAN 5', vid=655),
+            VLAN(name='VLAN 6', vid=656),
+            VLAN(name='VLAN 7', vid=657)
+        )
+
+        VLAN.objects.bulk_create(vlans)
+
+        l2vpns = (
+            L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
+            L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
+            L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'),  # No RD
+        )
+        L2VPN.objects.bulk_create(l2vpns)
+
+        l2vpnterminations = (
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2])
+        )
+
+        L2VPNTermination.objects.bulk_create(l2vpnterminations)
+
+    def test_duplicate_interface_terminations(self):
+        device = Device.objects.first()
+        interface = Interface.objects.filter(device=device).first()
+        l2vpn = L2VPN.objects.first()
+
+        L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=interface)
+        duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=interface)
+
+        self.assertRaises(ValidationError, duplicate.clean)
+
+    def test_duplicate_vlan_terminations(self):
+        vlan = Interface.objects.first()
+        l2vpn = L2VPN.objects.first()
+
+        L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan)
+        duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan)
+        self.assertRaises(ValidationError, duplicate.clean)

+ 141 - 1
netbox/vpn/tests/test_views.py

@@ -1,8 +1,9 @@
 from dcim.choices import InterfaceTypeChoices
 from dcim.models import Interface
+from ipam.models import RouteTarget, VLAN
+from utilities.testing import ViewTestCases, create_tags, create_test_device
 from vpn.choices import *
 from vpn.models import *
-from utilities.testing import ViewTestCases, create_tags, create_test_device
 
 
 class TunnelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -506,3 +507,142 @@ class IPSecProfileTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'ike_policy': ike_policies[1].pk,
             'ipsec_policy': ipsec_policies[1].pk,
         }
+
+
+class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = L2VPN
+
+    @classmethod
+    def setUpTestData(cls):
+        rts = (
+            RouteTarget(name='64534:123'),
+            RouteTarget(name='64534:321')
+        )
+        RouteTarget.objects.bulk_create(rts)
+
+        l2vpns = (
+            L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001'),
+            L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'),
+            L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003')
+        )
+        L2VPN.objects.bulk_create(l2vpns)
+
+        cls.csv_data = (
+            'name,slug,type,identifier',
+            'L2VPN 5,l2vpn-5,vxlan,456',
+            'L2VPN 6,l2vpn-6,vxlan,444',
+        )
+
+        cls.csv_update_data = (
+            'id,name,description',
+            f'{l2vpns[0].pk},L2VPN 7,New description 7',
+            f'{l2vpns[1].pk},L2VPN 8,New description 8',
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New Description',
+        }
+
+        cls.form_data = {
+            'name': 'L2VPN 8',
+            'slug': 'l2vpn-8',
+            'type': L2VPNTypeChoices.TYPE_VXLAN,
+            'identifier': 123,
+            'description': 'Description',
+            'import_targets': [rts[0].pk],
+            'export_targets': [rts[1].pk]
+        }
+
+
+class L2VPNTerminationTestCase(
+        ViewTestCases.GetObjectViewTestCase,
+        ViewTestCases.GetObjectChangelogViewTestCase,
+        ViewTestCases.CreateObjectViewTestCase,
+        ViewTestCases.EditObjectViewTestCase,
+        ViewTestCases.DeleteObjectViewTestCase,
+        ViewTestCases.ListObjectsViewTestCase,
+        ViewTestCases.BulkImportObjectsViewTestCase,
+        ViewTestCases.BulkDeleteObjectsViewTestCase,
+):
+
+    model = L2VPNTermination
+
+    @classmethod
+    def setUpTestData(cls):
+        device = create_test_device('Device 1')
+        interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset')
+        l2vpns = (
+            L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001),
+            L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650002),
+        )
+        L2VPN.objects.bulk_create(l2vpns)
+
+        vlans = (
+            VLAN(name='Vlan 1', vid=1001),
+            VLAN(name='Vlan 2', vid=1002),
+            VLAN(name='Vlan 3', vid=1003),
+            VLAN(name='Vlan 4', vid=1004),
+            VLAN(name='Vlan 5', vid=1005),
+            VLAN(name='Vlan 6', vid=1006)
+        )
+        VLAN.objects.bulk_create(vlans)
+
+        terminations = (
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2])
+        )
+        L2VPNTermination.objects.bulk_create(terminations)
+
+        cls.form_data = {
+            'l2vpn': l2vpns[0].pk,
+            'device': device.pk,
+            'interface': interface.pk,
+        }
+
+        cls.csv_data = (
+            "l2vpn,vlan",
+            "L2VPN 1,Vlan 4",
+            "L2VPN 1,Vlan 5",
+            "L2VPN 1,Vlan 6",
+        )
+
+        cls.csv_update_data = (
+            f"id,l2vpn",
+            f"{terminations[0].pk},{l2vpns[0].name}",
+            f"{terminations[1].pk},{l2vpns[0].name}",
+            f"{terminations[2].pk},{l2vpns[0].name}",
+        )
+
+        cls.bulk_edit_data = {}
+
+    # TODO: Fix L2VPNTerminationImportForm validation to support bulk updates
+    def test_bulk_update_objects_with_permission(self):
+        pass
+
+    #
+    # Custom assertions
+    #
+
+    # TODO: Remove this
+    def assertInstanceEqual(self, instance, data, exclude=None, api=False):
+        """
+        Override parent
+        """
+        if exclude is None:
+            exclude = []
+
+        fields = [k for k in data.keys() if k not in exclude]
+        model_dict = self.model_to_dict(instance, fields=fields, api=api)
+
+        # Omit any dictionary keys which are not instance attributes or have been excluded
+        relevant_data = {
+            k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
+        }
+
+        # Handle relations on the model
+        for k, v in model_dict.items():
+            if isinstance(v, object) and hasattr(v, 'first'):
+                model_dict[k] = v.first().pk
+
+        self.assertDictEqual(model_dict, relevant_data)

+ 16 - 0
netbox/vpn/urls.py

@@ -62,4 +62,20 @@ urlpatterns = [
     path('ipsec-profiles/delete/', views.IPSecProfileBulkDeleteView.as_view(), name='ipsecprofile_bulk_delete'),
     path('ipsec-profiles/<int:pk>/', include(get_model_urls('vpn', 'ipsecprofile'))),
 
+    # L2VPN
+    path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'),
+    path('l2vpns/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'),
+    path('l2vpns/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'),
+    path('l2vpns/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'),
+    path('l2vpns/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'),
+    path('l2vpns/<int:pk>/', include(get_model_urls('vpn', 'l2vpn'))),
+
+    # L2VPN terminations
+    path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'),
+    path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'),
+    path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'),
+    path('l2vpn-terminations/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'),
+    path('l2vpn-terminations/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'),
+    path('l2vpn-terminations/<int:pk>/', include(get_model_urls('vpn', 'l2vpntermination'))),
+
 ]

+ 111 - 0
netbox/vpn/views.py

@@ -1,4 +1,6 @@
+from ipam.tables import RouteTargetTable
 from netbox.views import generic
+from tenancy.views import ObjectContactsView
 from utilities.utils import count_related
 from utilities.views import register_model_view
 from . import filtersets, forms, tables
@@ -332,3 +334,112 @@ class IPSecProfileBulkDeleteView(generic.BulkDeleteView):
     queryset = IPSecProfile.objects.all()
     filterset = filtersets.IPSecProfileFilterSet
     table = tables.IPSecProfileTable
+
+
+# L2VPN
+
+class L2VPNListView(generic.ObjectListView):
+    queryset = L2VPN.objects.all()
+    table = tables.L2VPNTable
+    filterset = filtersets.L2VPNFilterSet
+    filterset_form = forms.L2VPNFilterForm
+
+
+@register_model_view(L2VPN)
+class L2VPNView(generic.ObjectView):
+    queryset = L2VPN.objects.all()
+
+    def get_extra_context(self, request, instance):
+        import_targets_table = RouteTargetTable(
+            instance.import_targets.prefetch_related('tenant'),
+            orderable=False
+        )
+        export_targets_table = RouteTargetTable(
+            instance.export_targets.prefetch_related('tenant'),
+            orderable=False
+        )
+
+        return {
+            'import_targets_table': import_targets_table,
+            'export_targets_table': export_targets_table,
+        }
+
+
+@register_model_view(L2VPN, 'edit')
+class L2VPNEditView(generic.ObjectEditView):
+    queryset = L2VPN.objects.all()
+    form = forms.L2VPNForm
+
+
+@register_model_view(L2VPN, 'delete')
+class L2VPNDeleteView(generic.ObjectDeleteView):
+    queryset = L2VPN.objects.all()
+
+
+class L2VPNBulkImportView(generic.BulkImportView):
+    queryset = L2VPN.objects.all()
+    model_form = forms.L2VPNImportForm
+
+
+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
+
+
+@register_model_view(L2VPN, 'contacts')
+class L2VPNContactsView(ObjectContactsView):
+    queryset = L2VPN.objects.all()
+
+
+#
+# L2VPN terminations
+#
+
+class L2VPNTerminationListView(generic.ObjectListView):
+    queryset = L2VPNTermination.objects.all()
+    table = tables.L2VPNTerminationTable
+    filterset = filtersets.L2VPNTerminationFilterSet
+    filterset_form = forms.L2VPNTerminationFilterForm
+
+
+@register_model_view(L2VPNTermination)
+class L2VPNTerminationView(generic.ObjectView):
+    queryset = L2VPNTermination.objects.all()
+
+
+@register_model_view(L2VPNTermination, 'edit')
+class L2VPNTerminationEditView(generic.ObjectEditView):
+    queryset = L2VPNTermination.objects.all()
+    form = forms.L2VPNTerminationForm
+    template_name = 'vpn/l2vpntermination_edit.html'
+
+
+@register_model_view(L2VPNTermination, 'delete')
+class L2VPNTerminationDeleteView(generic.ObjectDeleteView):
+    queryset = L2VPNTermination.objects.all()
+
+
+class L2VPNTerminationBulkImportView(generic.BulkImportView):
+    queryset = L2VPNTermination.objects.all()
+    model_form = forms.L2VPNTerminationImportForm
+
+
+class L2VPNTerminationBulkEditView(generic.BulkEditView):
+    queryset = L2VPNTermination.objects.all()
+    filterset = filtersets.L2VPNTerminationFilterSet
+    table = tables.L2VPNTerminationTable
+    form = forms.L2VPNTerminationBulkEditForm
+
+
+class L2VPNTerminationBulkDeleteView(generic.BulkDeleteView):
+    queryset = L2VPNTermination.objects.all()
+    filterset = filtersets.L2VPNTerminationFilterSet
+    table = tables.L2VPNTerminationTable