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

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.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
-from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 from timezone_field.rest_framework import TimeZoneSerializerField
 from timezone_field.rest_framework import TimeZoneSerializerField
 
 
@@ -12,8 +12,7 @@ from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
 from extras.api.nested_serializers import NestedConfigTemplateSerializer
 from extras.api.nested_serializers import NestedConfigTemplateSerializer
 from ipam.api.nested_serializers import (
 from ipam.api.nested_serializers import (
-    NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
-    NestedVRFSerializer,
+    NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
 )
 )
 from ipam.models import ASN, VLAN
 from ipam.models import ASN, VLAN
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 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 users.api.nested_serializers import NestedUserSerializer
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from virtualization.api.nested_serializers import NestedClusterSerializer
 from virtualization.api.nested_serializers import NestedClusterSerializer
+from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer
 from wireless.api.nested_serializers import NestedWirelessLANSerializer, NestedWirelessLinkSerializer
 from wireless.api.nested_serializers import NestedWirelessLANSerializer, NestedWirelessLinkSerializer
 from wireless.choices import *
 from wireless.choices import *
 from wireless.models import WirelessLAN
 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.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
 from ipam.filtersets import PrimaryIPFilterSet
 from ipam.filtersets import PrimaryIPFilterSet
-from ipam.models import ASN, L2VPN, IPAddress, VRF
+from ipam.models import ASN, IPAddress, VRF
 from netbox.filtersets import (
 from netbox.filtersets import (
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
 )
 )
@@ -17,6 +17,7 @@ from utilities.filters import (
     TreeNodeMultipleChoiceFilter,
     TreeNodeMultipleChoiceFilter,
 )
 )
 from virtualization.models import Cluster
 from virtualization.models import Cluster
+from vpn.models import L2VPN
 from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
 from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
 from .choices import *
 from .choices import *
 from .constants import *
 from .constants import *

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

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

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

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

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

@@ -316,8 +316,8 @@ INTERFACE_BUTTONS = """
       {% if perms.dcim.add_interface %}
       {% 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>
         <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 %}
       {% 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 %}
       {% endif %}
       {% if perms.ipam.add_fhrpgroupassignment %}
       {% 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>
         <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 rest_framework import serializers
 
 
 from ipam import models
 from ipam import models
-from ipam.models.l2vpn import L2VPNTermination, L2VPN
 from netbox.api.serializers import WritableNestedSerializer
 from netbox.api.serializers import WritableNestedSerializer
 from .field_serializers import IPAddressField
 from .field_serializers import IPAddressField
 
 
@@ -14,8 +13,6 @@ __all__ = [
     'NestedFHRPGroupAssignmentSerializer',
     'NestedFHRPGroupAssignmentSerializer',
     'NestedIPAddressSerializer',
     'NestedIPAddressSerializer',
     'NestedIPRangeSerializer',
     'NestedIPRangeSerializer',
-    'NestedL2VPNSerializer',
-    'NestedL2VPNTerminationSerializer',
     'NestedPrefixSerializer',
     'NestedPrefixSerializer',
     'NestedRIRSerializer',
     'NestedRIRSerializer',
     'NestedRoleSerializer',
     'NestedRoleSerializer',
@@ -223,28 +220,3 @@ class NestedServiceSerializer(WritableNestedSerializer):
     class Meta:
     class Meta:
         model = models.Service
         model = models.Service
         fields = ['id', 'url', 'display', 'name', 'protocol', 'ports']
         fields = ['id', 'url', 'display', 'name', 'protocol', 'ports']
-
-#
-# 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 tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
 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 .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',
             'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses',
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
             '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('vlans', views.VLANViewSet)
 router.register('service-templates', views.ServiceTemplateViewSet)
 router.register('service-templates', views.ServiceTemplateViewSet)
 router.register('services', views.ServiceViewSet)
 router.register('services', views.ServiceViewSet)
-router.register('l2vpns', views.L2VPNViewSet)
-router.register('l2vpn-terminations', views.L2VPNTerminationViewSet)
 
 
 app_name = 'ipam-api'
 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 dcim.models import Site
 from ipam import filtersets
 from ipam import filtersets
 from ipam.models import *
 from ipam.models import *
-from ipam.models import L2VPN, L2VPNTermination
 from ipam.utils import get_next_available_prefix
 from ipam.utils import get_next_available_prefix
 from netbox.api.viewsets import NetBoxModelViewSet
 from netbox.api.viewsets import NetBoxModelViewSet
 from netbox.api.viewsets.mixins import ObjectValidationMixin
 from netbox.api.viewsets.mixins import ObjectValidationMixin
@@ -178,18 +177,6 @@ class ServiceViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.ServiceFilterSet
     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
 # Views
 #
 #

+ 0 - 49
netbox/ipam/choices.py

@@ -172,52 +172,3 @@ class ServiceProtocolChoices(ChoiceSet):
         (PROTOCOL_UDP, 'UDP'),
         (PROTOCOL_UDP, 'UDP'),
         (PROTOCOL_SCTP, 'SCTP'),
         (PROTOCOL_SCTP, 'SCTP'),
     )
     )
-
-
-class L2VPNTypeChoices(ChoiceSet):
-    TYPE_VPLS = 'vpls'
-    TYPE_VPWS = 'vpws'
-    TYPE_EPL = 'epl'
-    TYPE_EVPL = 'evpl'
-    TYPE_EPLAN = 'ep-lan'
-    TYPE_EVPLAN = 'evp-lan'
-    TYPE_EPTREE = 'ep-tree'
-    TYPE_EVPTREE = 'evp-tree'
-    TYPE_VXLAN = 'vxlan'
-    TYPE_VXLAN_EVPN = 'vxlan-evpn'
-    TYPE_MPLS_EVPN = 'mpls-evpn'
-    TYPE_PBB_EVPN = 'pbb-evpn'
-
-    CHOICES = (
-        ('VPLS', (
-            (TYPE_VPWS, 'VPWS'),
-            (TYPE_VPLS, 'VPLS'),
-        )),
-        ('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
 # 16-bit port number
 SERVICE_PORT_MIN = 1
 SERVICE_PORT_MIN = 1
 SERVICE_PORT_MAX = 65535
 SERVICE_PORT_MAX = 65535
-
-L2VPN_ASSIGNMENT_MODELS = Q(
-    Q(app_label='dcim', model='interface') |
-    Q(app_label='ipam', model='vlan') |
-    Q(app_label='virtualization', model='vminterface')
-)

+ 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.core.exceptions import ValidationError
 from django.db.models import Q
 from django.db.models import Q
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
-from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
 
 
 from dcim.models import Device, Interface, Region, Site, SiteGroup
 from dcim.models import Device, Interface, Region, Site, SiteGroup
@@ -15,6 +15,7 @@ from utilities.filters import (
     ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
     ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
 )
 )
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
+from vpn.models import L2VPN
 from .choices import *
 from .choices import *
 from .models import *
 from .models import *
 
 
@@ -26,8 +27,6 @@ __all__ = (
     'FHRPGroupFilterSet',
     'FHRPGroupFilterSet',
     'IPAddressFilterSet',
     'IPAddressFilterSet',
     'IPRangeFilterSet',
     'IPRangeFilterSet',
-    'L2VPNFilterSet',
-    'L2VPNTerminationFilterSet',
     'PrefixFilterSet',
     'PrefixFilterSet',
     'PrimaryIPFilterSet',
     'PrimaryIPFilterSet',
     'RIRFilterSet',
     'RIRFilterSet',
@@ -1059,182 +1058,6 @@ class ServiceFilterSet(NetBoxModelFilterSet):
         return queryset.filter(qs_filter)
         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):
 class PrimaryIPFilterSet(django_filters.FilterSet):
     """
     """
     An inheritable FilterSet for models which support primary IP assignment.
     An inheritable FilterSet for models which support primary IP assignment.

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

@@ -23,8 +23,6 @@ __all__ = (
     'FHRPGroupBulkEditForm',
     'FHRPGroupBulkEditForm',
     'IPAddressBulkEditForm',
     'IPAddressBulkEditForm',
     'IPRangeBulkEditForm',
     'IPRangeBulkEditForm',
-    'L2VPNBulkEditForm',
-    'L2VPNTerminationBulkEditForm',
     'PrefixBulkEditForm',
     'PrefixBulkEditForm',
     'RIRBulkEditForm',
     'RIRBulkEditForm',
     'RoleBulkEditForm',
     'RoleBulkEditForm',
@@ -596,32 +594,3 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
 
 
 class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
 class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
     model = Service
     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 import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from dcim.models import Device, Interface, Site
 from dcim.models import Device, Interface, Site
@@ -21,8 +20,6 @@ __all__ = (
     'FHRPGroupImportForm',
     'FHRPGroupImportForm',
     'IPAddressImportForm',
     'IPAddressImportForm',
     'IPRangeImportForm',
     'IPRangeImportForm',
-    'L2VPNImportForm',
-    'L2VPNTerminationImportForm',
     'PrefixImportForm',
     'PrefixImportForm',
     'RIRImportForm',
     'RIRImportForm',
     'RoleImportForm',
     'RoleImportForm',
@@ -529,92 +526,3 @@ class ServiceImportForm(NetBoxModelImportForm):
                 )
                 )
 
 
         return self.cleaned_data['ipaddresses']
         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 import forms
-from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from dcim.models import Location, Rack, Region, Site, SiteGroup, Device
 from dcim.models import Location, Rack, Region, Site, SiteGroup, Device
@@ -9,10 +8,9 @@ from ipam.models import *
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.forms import TenancyFilterForm
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
 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 virtualization.models import VirtualMachine
+from vpn.models import L2VPN
 
 
 __all__ = (
 __all__ = (
     'AggregateFilterForm',
     'AggregateFilterForm',
@@ -21,8 +19,6 @@ __all__ = (
     'FHRPGroupFilterForm',
     'FHRPGroupFilterForm',
     'IPAddressFilterForm',
     'IPAddressFilterForm',
     'IPRangeFilterForm',
     'IPRangeFilterForm',
-    'L2VPNFilterForm',
-    'L2VPNTerminationFilterForm',
     'PrefixFilterForm',
     'PrefixFilterForm',
     'RIRFilterForm',
     'RIRFilterForm',
     'RoleFilterForm',
     'RoleFilterForm',
@@ -539,90 +535,3 @@ class ServiceFilterForm(ServiceTemplateFilterForm):
         label=_('Virtual Machine'),
         label=_('Virtual Machine'),
     )
     )
     tag = TagFilterField(model)
     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',
     'IPAddressBulkAddForm',
     'IPAddressForm',
     'IPAddressForm',
     'IPRangeForm',
     'IPRangeForm',
-    'L2VPNForm',
-    'L2VPNTerminationForm',
     'PrefixForm',
     'PrefixForm',
     'RIRForm',
     'RIRForm',
     'RoleForm',
     'RoleForm',
@@ -754,97 +752,3 @@ class ServiceCreateForm(ServiceForm):
                 self.cleaned_data['description'] = service_template.description
                 self.cleaned_data['description'] = service_template.description
         elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
         elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
             raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.")
             raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.")
-
-
-#
-# L2VPN
-#
-
-
-class L2VPNForm(TenancyForm, NetBoxModelForm):
-    slug = SlugField()
-    import_targets = DynamicModelMultipleChoiceField(
-        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
 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 netbox.graphql.fields import ObjectField, ObjectListField
-
+from utilities.graphql_optimizer import gql_query_optimizer
 from .types import *
 from .types import *
 
 
 
 
@@ -38,18 +37,6 @@ class IPAMQuery(graphene.ObjectType):
     def resolve_ip_range_list(root, info, **kwargs):
     def resolve_ip_range_list(root, info, **kwargs):
         return gql_query_optimizer(models.IPRange.objects.all(), info)
         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 = ObjectField(PrefixType)
     prefix_list = ObjectListField(PrefixType)
     prefix_list = ObjectListField(PrefixType)
 
 

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

@@ -1,6 +1,5 @@
 import graphene
 import graphene
 
 
-from extras.graphql.mixins import ContactsMixin
 from ipam import filtersets, models
 from ipam import filtersets, models
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
@@ -13,8 +12,6 @@ __all__ = (
     'FHRPGroupAssignmentType',
     'FHRPGroupAssignmentType',
     'IPAddressType',
     'IPAddressType',
     'IPRangeType',
     'IPRangeType',
-    'L2VPNType',
-    'L2VPNTerminationType',
     'PrefixType',
     'PrefixType',
     'RIRType',
     'RIRType',
     'RoleType',
     'RoleType',
@@ -188,19 +185,3 @@ class VRFType(NetBoxObjectType):
         model = models.VRF
         model = models.VRF
         fields = '__all__'
         fields = '__all__'
         filterset_class = filtersets.VRFFilterSet
         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 .fhrp import *
 from .vrfs import *
 from .vrfs import *
 from .ip import *
 from .ip import *
-from .l2vpn import *
 from .services import *
 from .services import *
 from .vlans import *
 from .vlans import *
-
-__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,
         null=True,
         help_text=_("The primary function of this VLAN")
         help_text=_("The primary function of this VLAN")
     )
     )
-
     l2vpn_terminations = GenericRelation(
     l2vpn_terminations = GenericRelation(
-        to='ipam.L2VPNTermination',
+        to='vpn.L2VPNTermination',
         content_type_field='assigned_object_type',
         content_type_field='assigned_object_type',
         object_id_field='assigned_object_id',
         object_id_field='assigned_object_id',
         related_query_name='vlan'
         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 netbox.search import SearchIndex, register_search
+from . import models
 
 
 
 
 @register_search
 @register_search
@@ -69,18 +69,6 @@ class IPRangeIndex(SearchIndex):
     display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
     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
 @register_search
 class PrefixIndex(SearchIndex):
 class PrefixIndex(SearchIndex):
     model = models.Prefix
     model = models.Prefix

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

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

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

@@ -1100,96 +1100,3 @@ class ServiceTest(APIViewTestCases.APIViewTestCase):
                 'ports': [6],
                 'ports': [6],
             },
             },
         ]
         ]
-
-
-class L2VPNTest(APIViewTestCases.APIViewTestCase):
-    model = L2VPN
-    brief_fields = ['display', 'id', 'identifier', 'name', 'slug', 'type', 'url']
-    create_data = [
-        {
-            'name': 'L2VPN 4',
-            'slug': 'l2vpn-4',
-            'type': 'vxlan',
-            'identifier': 33343344
-        },
-        {
-            'name': 'L2VPN 5',
-            'slug': 'l2vpn-5',
-            'type': 'vxlan',
-            'identifier': 33343345
-        },
-        {
-            'name': 'L2VPN 6',
-            'slug': 'l2vpn-6',
-            'type': 'vpws',
-            'identifier': 33343346
-        },
-    ]
-    bulk_update_data = {
-        'description': 'New description',
-    }
-
-    @classmethod
-    def setUpTestData(cls):
-
-        l2vpns = (
-            L2VPN(name='L2VPN 1', 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.choices import *
 from ipam.filtersets import *
 from ipam.filtersets import *
 from ipam.models import *
 from ipam.models import *
+from tenancy.models import Tenant, TenantGroup
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
-from tenancy.models import Tenant, TenantGroup
 
 
 
 
 class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -1616,163 +1616,3 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'ipaddress': [str(ips[0].address), str(ips[1].address)]}
         params = {'ipaddress': [str(ips[0].address), str(ips[1].address)]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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.core.exceptions import ValidationError
 from django.test import TestCase, override_settings
 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):
 class TestAggregate(TestCase):
@@ -539,76 +538,3 @@ class TestVLANGroup(TestCase):
 
 
         VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
         VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
         self.assertEqual(vlangroup.get_next_available_vid(), 105)
         self.assertEqual(vlangroup.get_next_available_vid(), 105)
-
-
-class 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.choices import *
 from ipam.models import *
 from ipam.models import *
 from tenancy.models import Tenant
 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):
 class ASNRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -986,142 +986,3 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertEqual(instance.protocol, service_template.protocol)
         self.assertEqual(instance.protocol, service_template.protocol)
         self.assertEqual(instance.ports, service_template.ports)
         self.assertEqual(instance.ports, service_template.ports)
         self.assertEqual(instance.description, service_template.description)
         self.assertEqual(instance.description, service_template.description)
-
-
-class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
-    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/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
     path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
     path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
     path('services/<int:pk>/', include(get_model_urls('ipam', 'service'))),
     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.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.db.models.expressions import RawSQL
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
@@ -9,7 +9,6 @@ from circuits.models import Provider
 from dcim.filtersets import InterfaceFilterSet
 from dcim.filtersets import InterfaceFilterSet
 from dcim.models import Interface, Site
 from dcim.models import Interface, Site
 from netbox.views import generic
 from netbox.views import generic
-from tenancy.views import ObjectContactsView
 from utilities.tables import get_table_ordering
 from utilities.tables import get_table_ordering
 from utilities.utils import count_related
 from utilities.utils import count_related
 from utilities.views import ViewTab, register_model_view
 from utilities.views import ViewTab, register_model_view
@@ -19,7 +18,6 @@ from . import filtersets, forms, tables
 from .choices import PrefixStatusChoices
 from .choices import PrefixStatusChoices
 from .constants import *
 from .constants import *
 from .models import *
 from .models import *
-from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable
 from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
 from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
 
 
 
 
@@ -1243,112 +1241,3 @@ class ServiceBulkDeleteView(generic.BulkDeleteView):
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     filterset = filtersets.ServiceFilterSet
     filterset = filtersets.ServiceFilterSet
     table = tables.ServiceTable
     table = tables.ServiceTable
-
-
-# L2VPN
-
-class L2VPNListView(generic.ObjectListView):
-    queryset = L2VPN.objects.all()
-    table = L2VPNTable
-    filterset = filtersets.L2VPNFilterSet
-    filterset_form = forms.L2VPNFilterForm
-
-
-@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(
         MenuGroup(
             label=_('L2VPNs'),
             label=_('L2VPNs'),
             items=(
             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(
         MenuGroup(

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

@@ -59,7 +59,7 @@
       <div class="card">
       <div class="card">
         <h5 class="card-header">{% trans "Importing L2VPNs" %}</h5>
         <h5 class="card-header">{% trans "Importing L2VPNs" %}</h5>
         <div class="card-body htmx-container table-responsive"
         <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"
           hx-trigger="load"
         ></div>
         ></div>
       </div>
       </div>
@@ -68,7 +68,7 @@
       <div class="card">
       <div class="card">
         <h5 class="card-header">{% trans "Exporting L2VPNs" %}</h5>
         <h5 class="card-header">{% trans "Exporting L2VPNs" %}</h5>
         <div class="card-body htmx-container table-responsive"
         <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"
           hx-trigger="load"
         ></div>
         ></div>
       </div>
       </div>

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

@@ -34,7 +34,7 @@
         </table>
         </table>
       </div>
       </div>
     </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 %}
     {% plugin_left_page object %}
 	</div>
 	</div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
@@ -56,12 +56,12 @@
     <div class="card">
     <div class="card">
       <h5 class="card-header">{% trans "Terminations" %}</h5>
       <h5 class="card-header">{% trans "Terminations" %}</h5>
       <div class="card-body htmx-container table-responsive"
       <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"
         hx-trigger="load"
       ></div>
       ></div>
-      {% if perms.ipam.add_l2vpntermination %}
+      {% if perms.vpn.add_l2vpntermination %}
         <div class="card-footer text-end noprint">
         <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" %}
             <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Termination" %}
           </a>
           </a>
         </div>
         </div>

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

@@ -25,7 +25,7 @@
 	</div>
 	</div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
         {% include 'inc/panels/custom_fields.html' %}
         {% 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>
 </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 dcim.choices import InterfaceModeChoices
 from extras.api.nested_serializers import NestedConfigTemplateSerializer
 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 ipam.models import VLAN
 from netbox.api.fields import ChoiceField, SerializedPKRelatedField
 from netbox.api.fields import ChoiceField, SerializedPKRelatedField
 from netbox.api.serializers import NetBoxModelSerializer
 from netbox.api.serializers import NetBoxModelSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualDisk, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualDisk, VirtualMachine, VMInterface
+from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer
 from .nested_serializers import *
 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 dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from extras.forms import LocalConfigContextFilterForm
 from extras.forms import LocalConfigContextFilterForm
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
-from ipam.models import L2VPN, VRF
+from ipam.models import VRF
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
 from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import *
 from virtualization.models import *
+from vpn.models import L2VPN
 
 
 __all__ = (
 __all__ = (
     'ClusterFilterForm',
     'ClusterFilterForm',

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

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

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

@@ -24,8 +24,8 @@ VMINTERFACE_BUTTONS = """
       {% if perms.ipam.add_ipaddress %}
       {% 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>
         <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 %}
       {% 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 %}
       {% endif %}
       {% if perms.ipam.add_fhrpgroupassignment %}
       {% 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>
         <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',
     'NestedIPSecPolicySerializer',
     'NestedIPSecProfileSerializer',
     'NestedIPSecProfileSerializer',
     'NestedIPSecProposalSerializer',
     'NestedIPSecProposalSerializer',
+    'NestedL2VPNSerializer',
+    'NestedL2VPNTerminationSerializer',
     'NestedTunnelSerializer',
     'NestedTunnelSerializer',
     'NestedTunnelTerminationSerializer',
     'NestedTunnelTerminationSerializer',
 )
 )
@@ -82,3 +84,28 @@ class NestedIPSecProfileSerializer(WritableNestedSerializer):
     class Meta:
     class Meta:
         model = models.IPSecProfile
         model = models.IPSecProfile
         fields = ('id', 'url', 'display', 'name')
         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 drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 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.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import NetBoxModelSerializer
 from netbox.api.serializers import NetBoxModelSerializer
 from netbox.constants import NESTED_SERIALIZER_PREFIX
 from netbox.constants import NESTED_SERIALIZER_PREFIX
@@ -18,6 +19,8 @@ __all__ = (
     'IPSecPolicySerializer',
     'IPSecPolicySerializer',
     'IPSecProfileSerializer',
     'IPSecProfileSerializer',
     'IPSecProposalSerializer',
     'IPSecProposalSerializer',
+    'L2VPNSerializer',
+    'L2VPNTerminationSerializer',
     'TunnelSerializer',
     'TunnelSerializer',
     'TunnelTerminationSerializer',
     'TunnelTerminationSerializer',
 )
 )
@@ -191,3 +194,54 @@ class IPSecProfileSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'comments', 'tags',
             'id', 'url', 'display', 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'comments', 'tags',
             'custom_fields', 'created', 'last_updated',
             '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('ipsec-profiles', views.IPSecProfileViewSet)
 router.register('tunnels', views.TunnelViewSet)
 router.register('tunnels', views.TunnelViewSet)
 router.register('tunnel-terminations', views.TunnelTerminationViewSet)
 router.register('tunnel-terminations', views.TunnelTerminationViewSet)
+router.register('l2vpns', views.L2VPNViewSet)
+router.register('l2vpn-terminations', views.L2VPNTerminationViewSet)
 
 
 app_name = 'vpn-api'
 app_name = 'vpn-api'
 urlpatterns = router.urls
 urlpatterns = router.urls

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

@@ -12,6 +12,8 @@ __all__ = (
     'IPSecPolicyViewSet',
     'IPSecPolicyViewSet',
     'IPSecProfileViewSet',
     'IPSecProfileViewSet',
     'IPSecProposalViewSet',
     'IPSecProposalViewSet',
+    'L2VPNViewSet',
+    'L2VPNTerminationViewSet',
     'TunnelTerminationViewSet',
     'TunnelTerminationViewSet',
     'TunnelViewSet',
     'TunnelViewSet',
     'VPNRootView',
     'VPNRootView',
@@ -72,3 +74,15 @@ class IPSecProfileViewSet(NetBoxModelViewSet):
     queryset = IPSecProfile.objects.all()
     queryset = IPSecProfile.objects.all()
     serializer_class = serializers.IPSecProfileSerializer
     serializer_class = serializers.IPSecProfileSerializer
     filterset_class = filtersets.IPSecProfileFilterSet
     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_33, _('Group {n}').format(n=33)),
         (GROUP_34, _('Group {n}').format(n=34)),
         (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.db.models import Q
 from django.utils.translation import gettext as _
 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 netbox.filtersets import NetBoxModelFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
 from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
-from virtualization.models import VMInterface
+from virtualization.models import VirtualMachine, VMInterface
 from .choices import *
 from .choices import *
 from .models import *
 from .models import *
 
 
@@ -17,6 +17,8 @@ __all__ = (
     'IPSecPolicyFilterSet',
     'IPSecPolicyFilterSet',
     'IPSecProfileFilterSet',
     'IPSecProfileFilterSet',
     'IPSecProposalFilterSet',
     'IPSecProposalFilterSet',
+    'L2VPNFilterSet',
+    'L2VPNTerminationFilterSet',
     'TunnelFilterSet',
     'TunnelFilterSet',
     'TunnelTerminationFilterSet',
     'TunnelTerminationFilterSet',
 )
 )
@@ -239,3 +241,175 @@ class IPSecProfileFilterSet(NetBoxModelFilterSet):
             Q(description__icontains=value) |
             Q(description__icontains=value) |
             Q(comments__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',
     'IPSecPolicyBulkEditForm',
     'IPSecProfileBulkEditForm',
     'IPSecProfileBulkEditForm',
     'IPSecProposalBulkEditForm',
     'IPSecProposalBulkEditForm',
+    'L2VPNBulkEditForm',
+    'L2VPNTerminationBulkEditForm',
     'TunnelBulkEditForm',
     'TunnelBulkEditForm',
     'TunnelTerminationBulkEditForm',
     'TunnelTerminationBulkEditForm',
 )
 )
@@ -241,3 +243,32 @@ class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm):
     nullable_fields = (
     nullable_fields = (
         'description', 'comments',
         '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 django.utils.translation import gettext_lazy as _
 
 
 from dcim.models import Device, Interface
 from dcim.models import Device, Interface
-from ipam.models import IPAddress
+from ipam.models import IPAddress, VLAN
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField
 from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField
@@ -15,6 +16,8 @@ __all__ = (
     'IPSecPolicyImportForm',
     'IPSecPolicyImportForm',
     'IPSecProfileImportForm',
     'IPSecProfileImportForm',
     'IPSecProposalImportForm',
     'IPSecProposalImportForm',
+    'L2VPNImportForm',
+    'L2VPNTerminationImportForm',
     'TunnelImportForm',
     'TunnelImportForm',
     'TunnelTerminationImportForm',
     'TunnelTerminationImportForm',
 )
 )
@@ -228,3 +231,92 @@ class IPSecProfileImportForm(NetBoxModelImportForm):
         fields = (
         fields = (
             'name', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags',
             '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 import forms
+from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 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 netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 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.choices import *
+from vpn.constants import L2VPN_ASSIGNMENT_MODELS
 from vpn.models import *
 from vpn.models import *
 
 
 __all__ = (
 __all__ = (
@@ -13,6 +21,8 @@ __all__ = (
     'IPSecPolicyFilterForm',
     'IPSecPolicyFilterForm',
     'IPSecProfileFilterForm',
     'IPSecProfileFilterForm',
     'IPSecProposalFilterForm',
     'IPSecProposalFilterForm',
+    'L2VPNFilterForm',
+    'L2VPNTerminationFilterForm',
     'TunnelFilterForm',
     'TunnelFilterForm',
     'TunnelTerminationFilterForm',
     'TunnelTerminationFilterForm',
 )
 )
@@ -180,3 +190,90 @@ class IPSecProfileFilterForm(NetBoxModelFilterSetForm):
         label=_('IPSec policy')
         label=_('IPSec policy')
     )
     )
     tag = TagFilterField(model)
     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 import forms
+from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from dcim.models import Device, Interface
 from dcim.models import Device, Interface
-from ipam.models import IPAddress
+from ipam.models import IPAddress, RouteTarget, VLAN
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 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.utils import add_blank_choice
 from utilities.forms.widgets import HTMXSelect
 from utilities.forms.widgets import HTMXSelect
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
@@ -18,6 +19,8 @@ __all__ = (
     'IPSecPolicyForm',
     'IPSecPolicyForm',
     'IPSecProfileForm',
     'IPSecProfileForm',
     'IPSecProposalForm',
     'IPSecProposalForm',
+    'L2VPNForm',
+    'L2VPNTerminationForm',
     'TunnelCreateForm',
     'TunnelCreateForm',
     'TunnelForm',
     'TunnelForm',
     'TunnelTerminationForm',
     'TunnelTerminationForm',
@@ -355,3 +358,96 @@ class IPSecProfileForm(NetBoxModelForm):
         fields = [
         fields = [
             'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags',
             '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):
     def resolve_ipsec_proposal_list(root, info, **kwargs):
         return gql_query_optimizer(models.IPSecProposal.objects.all(), info)
         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 = ObjectField(TunnelType)
     tunnel_list = ObjectListField(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 netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
 from vpn import filtersets, models
 from vpn import filtersets, models
 
 
@@ -8,6 +10,8 @@ __all__ = (
     'IPSecPolicyType',
     'IPSecPolicyType',
     'IPSecProfileType',
     'IPSecProfileType',
     'IPSecProposalType',
     'IPSecProposalType',
+    'L2VPNType',
+    'L2VPNTerminationType',
     'TunnelTerminationType',
     'TunnelTerminationType',
     'TunnelType',
     'TunnelType',
 )
 )
@@ -67,3 +71,19 @@ class IPSecProfileType(OrganizationalObjectType):
         model = models.IPSecProfile
         model = models.IPSecProfile
         fields = '__all__'
         fields = '__all__'
         filterset_class = filtersets.IPSecProfileFilterSet
         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 .crypto import *
+from .l2vpn import *
 from .tunnels 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 django.utils.translation import gettext_lazy as _
 
 
 from core.models import ContentType
 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 import NetBoxModel, PrimaryModel
 from netbox.models.features import ContactsMixin
 from netbox.models.features import ContactsMixin
+from vpn.choices import L2VPNTypeChoices
+from vpn.constants import L2VPN_ASSIGNMENT_MODELS
 
 
 __all__ = (
 __all__ = (
     'L2VPN',
     'L2VPN',
@@ -69,7 +69,7 @@ class L2VPN(ContactsMixin, PrimaryModel):
         return f'{self.name}'
         return f'{self.name}'
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
-        return reverse('ipam:l2vpn', args=[self.pk])
+        return reverse('vpn:l2vpn', args=[self.pk])
 
 
     @cached_property
     @cached_property
     def can_add_termination(self):
     def can_add_termination(self):
@@ -81,7 +81,7 @@ class L2VPN(ContactsMixin, PrimaryModel):
 
 
 class L2VPNTermination(NetBoxModel):
 class L2VPNTermination(NetBoxModel):
     l2vpn = models.ForeignKey(
     l2vpn = models.ForeignKey(
-        to='ipam.L2VPN',
+        to='vpn.L2VPN',
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
         related_name='terminations'
         related_name='terminations'
     )
     )
@@ -99,7 +99,7 @@ class L2VPNTermination(NetBoxModel):
 
 
     clone_fields = ('l2vpn',)
     clone_fields = ('l2vpn',)
     prerequisite_models = (
     prerequisite_models = (
-        'ipam.L2VPN',
+        'vpn.L2VPN',
     )
     )
 
 
     class Meta:
     class Meta:
@@ -107,7 +107,7 @@ class L2VPNTermination(NetBoxModel):
         constraints = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('assigned_object_type', 'assigned_object_id'),
                 fields=('assigned_object_type', 'assigned_object_id'),
-                name='ipam_l2vpntermination_assigned_object'
+                name='vpn_l2vpntermination_assigned_object'
             ),
             ),
         )
         )
         verbose_name = _('L2VPN termination')
         verbose_name = _('L2VPN termination')
@@ -119,7 +119,7 @@ class L2VPNTermination(NetBoxModel):
         return super().__str__()
         return super().__str__()
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
-        return reverse('ipam:l2vpntermination', args=[self.pk])
+        return reverse('vpn:l2vpntermination', args=[self.pk])
 
 
     def clean(self):
     def clean(self):
         # Only check is assigned_object is set.  Required otherwise we have an Integrity Error thrown.
         # 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),
         ('comments', 5000),
     )
     )
     display_attrs = ('description',)
     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
 import django_tables2 as tables
 from django.utils.translation import gettext_lazy as _
 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 netbox.tables import NetBoxTable, columns
 from vpn.models import *
 from vpn.models import *
 
 
@@ -12,88 +10,9 @@ __all__ = (
     'IPSecPolicyTable',
     'IPSecPolicyTable',
     'IPSecProposalTable',
     'IPSecProposalTable',
     'IPSecProfileTable',
     '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):
 class IKEProposalTable(NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         verbose_name=_('Name'),
         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
 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 netbox.tables import NetBoxTable, columns
 from tenancy.tables import TenancyColumnsMixin
 from tenancy.tables import TenancyColumnsMixin
+from vpn.models import L2VPN, L2VPNTermination
 
 
 __all__ = (
 __all__ = (
     'L2VPNTable',
     'L2VPNTable',
@@ -37,7 +37,7 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name=_('Comments'),
         verbose_name=_('Comments'),
     )
     )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
-        url_name='ipam:l2vpn_list'
+        url_name='vpn:l2vpn_list'
     )
     )
 
 
     class Meta(NetBoxTable.Meta):
     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.choices import InterfaceTypeChoices
 from dcim.models import Interface
 from dcim.models import Interface
+from ipam.models import VLAN
 from utilities.testing import APITestCase, APIViewTestCases, create_test_device
 from utilities.testing import APITestCase, APIViewTestCases, create_test_device
 from vpn.choices import *
 from vpn.choices import *
 from vpn.models import *
 from vpn.models import *
@@ -471,3 +472,96 @@ class IPSecProfileTest(APIViewTestCases.APIViewTestCase):
             'ipsec_policy': ipsec_policies[1].pk,
             'ipsec_policy': ipsec_policies[1].pk,
             'description': 'New description',
             '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 django.test import TestCase
 
 
 from dcim.choices import InterfaceTypeChoices
 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.choices import *
 from vpn.filtersets import *
 from vpn.filtersets import *
 from vpn.models import *
 from vpn.models import *
-from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 
 
 
 
 class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests):
 class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -590,3 +591,163 @@ class IPSecProfileTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'ipsec_policy': [ipsec_policies[0].name, ipsec_policies[1].name]}
         params = {'ipsec_policy': [ipsec_policies[0].name, ipsec_policies[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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.choices import InterfaceTypeChoices
 from dcim.models import Interface
 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.choices import *
 from vpn.models import *
 from vpn.models import *
-from utilities.testing import ViewTestCases, create_tags, create_test_device
 
 
 
 
 class TunnelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class TunnelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -506,3 +507,142 @@ class IPSecProfileTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'ike_policy': ike_policies[1].pk,
             'ike_policy': ike_policies[1].pk,
             'ipsec_policy': ipsec_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/delete/', views.IPSecProfileBulkDeleteView.as_view(), name='ipsecprofile_bulk_delete'),
     path('ipsec-profiles/<int:pk>/', include(get_model_urls('vpn', 'ipsecprofile'))),
     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 netbox.views import generic
+from tenancy.views import ObjectContactsView
 from utilities.utils import count_related
 from utilities.utils import count_related
 from utilities.views import register_model_view
 from utilities.views import register_model_view
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
@@ -332,3 +334,112 @@ class IPSecProfileBulkDeleteView(generic.BulkDeleteView):
     queryset = IPSecProfile.objects.all()
     queryset = IPSecProfile.objects.all()
     filterset = filtersets.IPSecProfileFilterSet
     filterset = filtersets.IPSecProfileFilterSet
     table = tables.IPSecProfileTable
     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