2
0
Эх сурвалжийг харах

Fixes: #7336 - VLAN Translation (#17745)

* VLANTranslationPolicy and VLANTranslationRule models and all associated UI classes

* Change VLANTranslationPolicy to a PrimaryModel and make name unique

* Add serializer classes to InterfaceSerializer

* Remake migrations

* Add GraphQL typing

* Skip tagged models in test

* Missing migration

* Remove get_absolute_url methods

* Remove package-lock.json

* Rebuild migration and add constraints and field options

* Rebuild migrations

* Use DynamicModelChoiceField for policy field

* Make vlan_translation_policy fields on filtersets more consistent with existing __name convention

* Add vlan_translation_table to VMInterface detail page

* Add vlan_translation_policy to VMInterfaceSerializer

* Move vlan_translation_policy fields to model and filterset mixins

* Protect in-use policies against deletion

* Add vlan_translation_policy to fields in VMInterfaceSerializer

* Cleanup indentation

* Remove unnecessary ordering column

* Rebuild migrations

* Search methods and registration

* Ensure 'id' column is present by default

* Add graphql types/filters/schema for VLANTranslationRule

* Filterset tests

* View tests

* API and viewset tests (incomplete)

* Add tags to VLANTranslationRuleForm

* Complete viewset tests for VLANTranslationRule

* Make VLANTranslationRule.policy nullable (but still required)

* Revert "Make VLANTranslationRule.policy nullable (but still required)"

This reverts commit 4c1bb437ef1a0a3593e5fbb87f08a0f158ea8c47.

* Revert nullability

* Explicitly prefetch policy in graphql

* Documentation of new and affected models

* Add note about select_related in graphql

* Rework policy/rule documentation

* Move vlan_translation_policy into 802.1Q Switching fieldset

* Remove redundant InterfaceVLANTranslationTable

* Conditionally include vlan_translation_table in interface.html and vminterface.html

* Add description field to VLANTranslationRule

* Define vlan_translation_table conditionally

* Add policy (name) filter to VLANTranslationRuleFilterSet

* Revert changes to adding-models.md (moved to another PR)

* Dynamic table for linked rules in vlantranslationpolicy.html

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
bctiemann 1 жил өмнө
parent
commit
f74a9a1c76
44 өөрчлөгдсөн 1210 нэмэгдсэн , 22 устгасан
  1. 4 0
      docs/models/dcim/interface.md
  2. 26 0
      docs/models/ipam/vlantranslationpolicy.md
  3. 19 0
      docs/models/ipam/vlantranslationrule.md
  4. 4 0
      docs/models/virtualization/vminterface.md
  5. 3 2
      netbox/dcim/api/serializers_/device_components.py
  6. 12 1
      netbox/dcim/filtersets.py
  7. 8 3
      netbox/dcim/forms/model_forms.py
  8. 1 0
      netbox/dcim/graphql/types.py
  9. 20 0
      netbox/dcim/migrations/0195_interface_vlan_translation_policy.py
  10. 7 0
      netbox/dcim/models/device_components.py
  11. 23 5
      netbox/dcim/tests/test_filtersets.py
  12. 10 1
      netbox/dcim/views.py
  13. 2 0
      netbox/extras/tests/test_filtersets.py
  14. 19 1
      netbox/ipam/api/serializers_/vlans.py
  15. 2 0
      netbox/ipam/api/urls.py
  16. 12 0
      netbox/ipam/api/views.py
  17. 49 0
      netbox/ipam/filtersets.py
  18. 32 0
      netbox/ipam/forms/bulk_edit.py
  19. 16 0
      netbox/ipam/forms/bulk_import.py
  20. 39 0
      netbox/ipam/forms/filtersets.py
  21. 33 0
      netbox/ipam/forms/model_forms.py
  22. 14 0
      netbox/ipam/graphql/filters.py
  23. 6 0
      netbox/ipam/graphql/schema.py
  24. 20 0
      netbox/ipam/graphql/types.py
  25. 62 0
      netbox/ipam/migrations/0074_vlantranslationpolicy_vlantranslationrule.py
  26. 73 1
      netbox/ipam/models/vlans.py
  27. 21 0
      netbox/ipam/search.py
  28. 53 0
      netbox/ipam/tables/vlans.py
  29. 106 0
      netbox/ipam/tests/test_api.py
  30. 93 0
      netbox/ipam/tests/test_filtersets.py
  31. 115 0
      netbox/ipam/tests/test_views.py
  32. 16 0
      netbox/ipam/urls.py
  33. 105 0
      netbox/ipam/views.py
  34. 2 0
      netbox/netbox/navigation/menu.py
  35. 11 0
      netbox/templates/dcim/interface.html
  36. 55 0
      netbox/templates/ipam/vlantranslationpolicy.html
  37. 45 0
      netbox/templates/ipam/vlantranslationrule.html
  38. 11 0
      netbox/templates/virtualization/vminterface.html
  39. 3 1
      netbox/virtualization/api/serializers_/virtualmachines.py
  40. 8 3
      netbox/virtualization/forms/model_forms.py
  41. 1 0
      netbox/virtualization/graphql/types.py
  42. 20 0
      netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py
  43. 19 3
      netbox/virtualization/tests/test_filtersets.py
  44. 10 1
      netbox/virtualization/views.py

+ 4 - 0
docs/models/dcim/interface.md

@@ -142,3 +142,7 @@ The configured channel width of a wireless interface, in MHz. This is typically
 ### Wireless LANs
 
 The [wireless LANs](../wireless/wirelesslan.md) for which this interface carries traffic. (Valid for wireless interfaces only.)
+
+### VLAN Translation Policy
+
+The [VLAN translation policy](../ipam/vlantranslationpolicy.md) that applies to this interface (optional).

+ 26 - 0
docs/models/ipam/vlantranslationpolicy.md

@@ -0,0 +1,26 @@
+# VLAN Translation Policies
+
+VLAN translation is a feature that consists of VLAN translation policies and [VLAN translation rules](./vlantranslationrule.md). Many rules can belong to a policy, and each rule defines a mapping of a local to remote VLAN ID (VID). A policy can then be assigned to an [Interface](../dcim/interface.md) or [VMInterface](../virtualization/vminterface.md), and all VLAN translation rules associated with that policy will be visible in the interface details.
+
+There are uniqueness constraints on `(policy, local_vid)` and on `(policy, remote_vid)` in the `VLANTranslationRule` model. Thus, you cannot have multiple rules linked to the same policy that have the same local VID or the same remote VID. A set of policies and rules might look like this:
+
+Policy 1:
+- Rule: 100 -> 200
+- Rule: 101 -> 201
+
+Policy 2:
+- Rule: 100 -> 300
+- Rule: 101 -> 301
+
+However this is not allowed:
+
+Policy 3:
+- Rule: 100 -> 200
+- Rule: 100 -> 300
+
+
+## Fields
+
+### Name
+
+A unique human-friendly name.

+ 19 - 0
docs/models/ipam/vlantranslationrule.md

@@ -0,0 +1,19 @@
+# VLAN Translation Rules
+
+A VLAN translation rule represents a one-to-one mapping of a local VLAN ID (VID) to a remote VID. Many rules can belong to a single policy.
+
+See [VLAN translation policies](./vlantranslationpolicy.md) for an overview of the VLAN Translation feature.
+
+## Fields
+
+### Policy
+
+The [VLAN Translation Policy](./vlantranslationpolicy.md) to which this rule belongs.
+
+### Local VID
+
+VLAN ID (1-4094) in the local network which is to be translated to a remote VID.
+
+### Remote VID
+
+VLAN ID (1-4094) in the remote network to which the local VID will be translated.

+ 4 - 0
docs/models/virtualization/vminterface.md

@@ -56,3 +56,7 @@ The tagged VLANs which are configured to be carried by this interface. Valid onl
 ### VRF
 
 The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned.
+
+### VLAN Translation Policy
+
+The [VLAN translation policy](../ipam/vlantranslationpolicy.md) that applies to this interface (optional).

+ 3 - 2
netbox/dcim/api/serializers_/device_components.py

@@ -8,7 +8,7 @@ from dcim.models import (
     ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
     RearPort, VirtualDeviceContext,
 )
-from ipam.api.serializers_.vlans import VLANSerializer
+from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
 from ipam.api.serializers_.vrfs import VRFSerializer
 from ipam.models import VLAN
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
@@ -196,6 +196,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
         required=False,
         many=True
     )
+    vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True)
     vrf = VRFSerializer(nested=True, required=False, allow_null=True)
     l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
     wireless_link = NestedWirelessLinkSerializer(read_only=True, allow_null=True)
@@ -225,7 +226,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
             'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link',
             'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
             'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
-            'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
+            'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', 'vlan_translation_policy'
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 

+ 12 - 1
netbox/dcim/filtersets.py

@@ -8,7 +8,7 @@ from circuits.models import CircuitTermination
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate
 from ipam.filtersets import PrimaryIPFilterSet
-from ipam.models import ASN, IPAddress, VRF
+from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF
 from netbox.choices import ColorChoices
 from netbox.filtersets import (
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
@@ -1629,6 +1629,17 @@ class CommonInterfaceFilterSet(django_filters.FilterSet):
         to_field_name='identifier',
         label=_('L2VPN'),
     )
+    vlan_translation_policy_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='vlan_translation_policy',
+        queryset=VLANTranslationPolicy.objects.all(),
+        label=_('VLAN Translation Policy (ID)'),
+    )
+    vlan_translation_policy = django_filters.ModelMultipleChoiceFilter(
+        field_name='vlan_translation_policy__name',
+        queryset=VLANTranslationPolicy.objects.all(),
+        to_field_name='name',
+        label=_('VLAN Translation Policy'),
+    )
 
     def filter_vlan_id(self, queryset, name, value):
         value = value.strip()

+ 8 - 3
netbox/dcim/forms/model_forms.py

@@ -7,7 +7,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.models import *
 from extras.models import ConfigTemplate
-from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
+from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from users.models import User
@@ -1382,6 +1382,11 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         required=False,
         label=_('WWN')
     )
+    vlan_translation_policy = DynamicModelChoiceField(
+        queryset=VLANTranslationPolicy.objects.all(),
+        required=False,
+        label=_('VLAN Translation Policy')
+    )
 
     fieldsets = (
         FieldSet(
@@ -1391,7 +1396,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
-        FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')),
+        FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy', name=_('802.1Q Switching')),
         FieldSet(
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
             name=_('Wireless')
@@ -1404,7 +1409,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
             'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
             'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
-            'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
+            'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'vlan_translation_policy',
         ]
         widgets = {
             'speed': NumberWithOptions(

+ 1 - 0
netbox/dcim/graphql/types.py

@@ -385,6 +385,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
     wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')] | None
     untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
+    vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
 
     vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
     tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]

+ 20 - 0
netbox/dcim/migrations/0195_interface_vlan_translation_policy.py

@@ -0,0 +1,20 @@
+# Generated by Django 5.0.9 on 2024-10-11 19:45
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0194_charfield_null_choices'),
+        ('ipam', '0074_vlantranslationpolicy_vlantranslationrule'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='vlan_translation_policy',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlantranslationpolicy'),
+        ),
+    ]

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

@@ -547,6 +547,13 @@ class BaseInterface(models.Model):
         blank=True,
         verbose_name=_('bridge interface')
     )
+    vlan_translation_policy = models.ForeignKey(
+        to='ipam.VLANTranslationPolicy',
+        on_delete=models.PROTECT,
+        null=True,
+        blank=True,
+        verbose_name=_('VLAN Translation Policy'),
+    )
 
     class Meta:
         abstract = True

+ 23 - 5
netbox/dcim/tests/test_filtersets.py

@@ -4,7 +4,7 @@ from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
 from dcim.choices import *
 from dcim.filtersets import *
 from dcim.models import *
-from ipam.models import ASN, IPAddress, RIR, VRF
+from ipam.models import ASN, IPAddress, RIR, VLANTranslationPolicy, VRF
 from netbox.choices import ColorChoices, WeightUnitChoices
 from tenancy.models import Tenant, TenantGroup
 from users.models import User
@@ -3669,6 +3669,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         VirtualDeviceContext.objects.bulk_create(vdcs)
 
+        vlan_translation_policies = (
+            VLANTranslationPolicy(name='Policy 1'),
+            VLANTranslationPolicy(name='Policy 2'),
+            VLANTranslationPolicy(name='Policy 3'),
+        )
+        VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies)
+
         interfaces = (
             Interface(
                 device=devices[0],
@@ -3686,7 +3693,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 speed=1000000,
                 duplex='half',
                 poe_mode=InterfacePoEModeChoices.MODE_PSE,
-                poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
+                poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
+                vlan_translation_policy=vlan_translation_policies[0],
             ),
             Interface(
                 device=devices[1],
@@ -3711,7 +3719,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 speed=1000000,
                 duplex='full',
                 poe_mode=InterfacePoEModeChoices.MODE_PD,
-                poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
+                poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
+                vlan_translation_policy=vlan_translation_policies[0],
             ),
             Interface(
                 device=devices[3],
@@ -3729,7 +3738,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 speed=100000,
                 duplex='half',
                 poe_mode=InterfacePoEModeChoices.MODE_PSE,
-                poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
+                poe_type=InterfacePoETypeChoices.TYPE_2_8023AT,
+                vlan_translation_policy=vlan_translation_policies[1],
             ),
             Interface(
                 device=devices[4],
@@ -3742,7 +3752,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 speed=100000,
                 duplex='full',
                 poe_mode=InterfacePoEModeChoices.MODE_PD,
-                poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
+                poe_type=InterfacePoETypeChoices.TYPE_2_8023AT,
+                vlan_translation_policy=vlan_translation_policies[1],
             ),
             Interface(
                 device=devices[4],
@@ -4016,6 +4027,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         params = {'vdc_identifier': vdc.values_list('identifier', flat=True)}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
+    def test_vlan_translation_policy(self):
+        vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2]
+        params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'vlan_translation_policy': [vlan_translation_policies[0].name, vlan_translation_policies[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
 
 class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = FrontPort.objects.all()

+ 10 - 1
netbox/dcim/views.py

@@ -18,7 +18,7 @@ from jinja2.exceptions import TemplateError
 from circuits.models import Circuit, CircuitTermination
 from extras.views import ObjectConfigContextView
 from ipam.models import ASN, IPAddress, VLANGroup
-from ipam.tables import InterfaceVLANTable
+from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
 from netbox.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
 from tenancy.views import ObjectContactsView
@@ -2580,11 +2580,20 @@ class InterfaceView(generic.ObjectView):
             orderable=False
         )
 
+        # Get VLAN translation rules
+        vlan_translation_table = None
+        if instance.vlan_translation_policy:
+            vlan_translation_table = VLANTranslationRuleTable(
+                data=instance.vlan_translation_policy.rules.all(),
+                orderable=False
+            )
+
         return {
             'vdc_table': vdc_table,
             'bridge_interfaces_table': bridge_interfaces_tables,
             'child_interfaces_table': child_interfaces_tables,
             'vlan_table': vlan_table,
+            'vlan_translation_table': vlan_translation_table,
         }
 
 

+ 2 - 0
netbox/extras/tests/test_filtersets.py

@@ -1172,6 +1172,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         'virtualmachine',
         'vlan',
         'vlangroup',
+        'vlantranslationpolicy',
+        'vlantranslationrule',
         'vminterface',
         'vrf',
         'webhook',

+ 19 - 1
netbox/ipam/api/serializers_/vlans.py

@@ -5,7 +5,7 @@ from rest_framework import serializers
 from dcim.api.serializers_.sites import SiteSerializer
 from ipam.choices import *
 from ipam.constants import VLANGROUP_SCOPE_TYPES
-from ipam.models import VLAN, VLANGroup
+from ipam.models import VLAN, VLANGroup, VLANTranslationPolicy, VLANTranslationRule
 from netbox.api.fields import ChoiceField, ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField
 from netbox.api.serializers import NetBoxModelSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
@@ -18,6 +18,8 @@ __all__ = (
     'CreateAvailableVLANSerializer',
     'VLANGroupSerializer',
     'VLANSerializer',
+    'VLANTranslationPolicySerializer',
+    'VLANTranslationRuleSerializer',
 )
 
 
@@ -110,3 +112,19 @@ class CreateAvailableVLANSerializer(NetBoxModelSerializer):
     def validate(self, data):
         # Bypass model validation since we don't have a VID yet
         return data
+
+
+class VLANTranslationRuleSerializer(NetBoxModelSerializer):
+
+    class Meta:
+        model = VLANTranslationRule
+        fields = ['id', 'policy', 'local_vid', 'remote_vid']
+
+
+class VLANTranslationPolicySerializer(NetBoxModelSerializer):
+    rules = VLANTranslationRuleSerializer(many=True, read_only=True)
+
+    class Meta:
+        model = VLANTranslationPolicy
+        fields = ['id', 'url', 'name', 'description', 'display', 'rules']
+        brief_fields = ('id', 'url', 'name', 'description', 'display')

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

@@ -21,6 +21,8 @@ router.register('fhrp-groups', views.FHRPGroupViewSet)
 router.register('fhrp-group-assignments', views.FHRPGroupAssignmentViewSet)
 router.register('vlan-groups', views.VLANGroupViewSet)
 router.register('vlans', views.VLANViewSet)
+router.register('vlan-translation-policies', views.VLANTranslationPolicyViewSet)
+router.register('vlan-translation-rules', views.VLANTranslationRuleViewSet)
 router.register('service-templates', views.ServiceTemplateViewSet)
 router.register('services', views.ServiceViewSet)
 

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

@@ -143,6 +143,18 @@ class VLANViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.VLANFilterSet
 
 
+class VLANTranslationPolicyViewSet(NetBoxModelViewSet):
+    queryset = VLANTranslationPolicy.objects.all()
+    serializer_class = serializers.VLANTranslationPolicySerializer
+    filterset_class = filtersets.VLANTranslationPolicyFilterSet
+
+
+class VLANTranslationRuleViewSet(NetBoxModelViewSet):
+    queryset = VLANTranslationRule.objects.all()
+    serializer_class = serializers.VLANTranslationRuleSerializer
+    filterset_class = filtersets.VLANTranslationRuleFilterSet
+
+
 class ServiceTemplateViewSet(NetBoxModelViewSet):
     queryset = ServiceTemplate.objects.all()
     serializer_class = serializers.ServiceTemplateSerializer

+ 49 - 0
netbox/ipam/filtersets.py

@@ -37,6 +37,8 @@ __all__ = (
     'ServiceTemplateFilterSet',
     'VLANFilterSet',
     'VLANGroupFilterSet',
+    'VLANTranslationPolicyFilterSet',
+    'VLANTranslationRuleFilterSet',
     'VRFFilterSet',
 )
 
@@ -1104,6 +1106,53 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         )
 
 
+class VLANTranslationPolicyFilterSet(NetBoxModelFilterSet):
+
+    class Meta:
+        model = VLANTranslationPolicy
+        fields = ('id', 'name', 'description')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )
+        return queryset.filter(qs_filter)
+
+
+class VLANTranslationRuleFilterSet(NetBoxModelFilterSet):
+    policy_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=VLANTranslationPolicy.objects.all(),
+        label=_('VLAN Translation Policy (ID)'),
+    )
+    policy = django_filters.ModelMultipleChoiceFilter(
+        field_name='policy__name',
+        queryset=VLANTranslationPolicy.objects.all(),
+        to_field_name='name',
+        label=_('VLAN Translation Policy (name)'),
+    )
+
+    class Meta:
+        model = VLANTranslationRule
+        fields = ('id', 'policy_id', 'policy', 'local_vid', 'remote_vid', 'description')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(policy__name__icontains=value)
+        )
+        try:
+            int_value = int(value.strip())
+            qs_filter |= Q(local_vid=int_value)
+            qs_filter |= Q(remote_vid=int_value)
+        except ValueError:
+            pass
+        return queryset.filter(qs_filter)
+
+
 class ServiceTemplateFilterSet(NetBoxModelFilterSet):
     port = NumericArrayFilter(
         field_name='ports',

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

@@ -34,6 +34,8 @@ __all__ = (
     'ServiceTemplateBulkEditForm',
     'VLANBulkEditForm',
     'VLANGroupBulkEditForm',
+    'VLANTranslationPolicyBulkEditForm',
+    'VLANTranslationRuleBulkEditForm',
     'VRFBulkEditForm',
 )
 
@@ -537,6 +539,36 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
     )
 
 
+class VLANTranslationPolicyBulkEditForm(NetBoxModelBulkEditForm):
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+
+    model = VLANTranslationPolicy
+    fieldsets = (
+        FieldSet('description'),
+    )
+    nullable_fields = ('description',)
+
+
+class VLANTranslationRuleBulkEditForm(NetBoxModelBulkEditForm):
+    policy = DynamicModelChoiceField(
+        label=_('Policy'),
+        queryset=VLANTranslationPolicy.objects.all(),
+        selector=True
+    )
+    local_vid = forms.IntegerField(required=False)
+    remote_vid = forms.IntegerField(required=False)
+
+    model = VLANTranslationRule
+    fieldsets = (
+        FieldSet('policy', 'local_vid', 'remote_vid'),
+    )
+    fields = ('policy', 'local_vid', 'remote_vid')
+
+
 class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
     protocol = forms.ChoiceField(
         label=_('Protocol'),

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

@@ -29,6 +29,8 @@ __all__ = (
     'ServiceTemplateImportForm',
     'VLANImportForm',
     'VLANGroupImportForm',
+    'VLANTranslationPolicyImportForm',
+    'VLANTranslationRuleImportForm',
     'VRFImportForm',
 )
 
@@ -465,6 +467,20 @@ class VLANImportForm(NetBoxModelImportForm):
         fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments', 'tags')
 
 
+class VLANTranslationPolicyImportForm(NetBoxModelImportForm):
+
+    class Meta:
+        model = VLANTranslationPolicy
+        fields = ('name', 'description', 'tags')
+
+
+class VLANTranslationRuleImportForm(NetBoxModelImportForm):
+
+    class Meta:
+        model = VLANTranslationRule
+        fields = ('policy', 'local_vid', 'remote_vid')
+
+
 class ServiceTemplateImportForm(NetBoxModelImportForm):
     protocol = CSVChoiceField(
         label=_('Protocol'),

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

@@ -28,6 +28,8 @@ __all__ = (
     'ServiceTemplateFilterForm',
     'VLANFilterForm',
     'VLANGroupFilterForm',
+    'VLANTranslationPolicyFilterForm',
+    'VLANTranslationRuleFilterForm',
     'VRFFilterForm',
 )
 
@@ -461,6 +463,43 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
 
 
+class VLANTranslationPolicyFilterForm(NetBoxModelFilterSetForm):
+    model = VLANTranslationPolicy
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('name', name=_('Attributes')),
+    )
+    name = forms.CharField(
+        required=False,
+        label=_('Name')
+    )
+    tag = TagFilterField(model)
+
+
+class VLANTranslationRuleFilterForm(NetBoxModelFilterSetForm):
+    model = VLANTranslationRule
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('policy_id', 'local_vid', 'remote_vid', name=_('Attributes')),
+    )
+    tag = TagFilterField(model)
+    policy_id = DynamicModelMultipleChoiceField(
+        queryset=VLANTranslationPolicy.objects.all(),
+        required=False,
+        label=_('VLAN Translation Policy')
+    )
+    local_vid = forms.IntegerField(
+        min_value=1,
+        required=False,
+        label=_('Local VLAN ID')
+    )
+    remote_vid = forms.IntegerField(
+        min_value=1,
+        required=False,
+        label=_('Remote VLAN ID')
+    )
+
+
 class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VLAN
     fieldsets = (

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

@@ -41,6 +41,8 @@ __all__ = (
     'ServiceTemplateForm',
     'VLANForm',
     'VLANGroupForm',
+    'VLANTranslationPolicyForm',
+    'VLANTranslationRuleForm',
     'VRFForm',
 )
 
@@ -691,6 +693,37 @@ class VLANForm(TenancyForm, NetBoxModelForm):
         ]
 
 
+class VLANTranslationPolicyForm(NetBoxModelForm):
+
+    fieldsets = (
+        FieldSet('name', 'description', 'tags', name=_('VLAN Translation Policy')),
+    )
+
+    class Meta:
+        model = VLANTranslationPolicy
+        fields = [
+            'name', 'description', 'tags',
+        ]
+
+
+class VLANTranslationRuleForm(NetBoxModelForm):
+    policy = DynamicModelChoiceField(
+        label=_('Policy'),
+        queryset=VLANTranslationPolicy.objects.all(),
+        selector=True
+    )
+
+    fieldsets = (
+        FieldSet('policy', 'local_vid', 'remote_vid', 'description', 'tags', name=_('VLAN Translation Rule')),
+    )
+
+    class Meta:
+        model = VLANTranslationRule
+        fields = [
+            'policy', 'local_vid', 'remote_vid', 'description', 'tags',
+        ]
+
+
 class ServiceTemplateForm(NetBoxModelForm):
     ports = NumericArrayField(
         label=_('Ports'),

+ 14 - 0
netbox/ipam/graphql/filters.py

@@ -19,6 +19,8 @@ __all__ = (
     'ServiceTemplateFilter',
     'VLANFilter',
     'VLANGroupFilter',
+    'VLANTranslationPolicyFilter',
+    'VLANTranslationRuleFilter',
     'VRFFilter',
 )
 
@@ -113,6 +115,18 @@ class VLANGroupFilter(BaseFilterMixin):
     pass
 
 
+@strawberry_django.filter(models.VLANTranslationPolicy, lookups=True)
+@autotype_decorator(filtersets.VLANTranslationPolicyFilterSet)
+class VLANTranslationPolicyFilter(BaseFilterMixin):
+    pass
+
+
+@strawberry_django.filter(models.VLANTranslationRule, lookups=True)
+@autotype_decorator(filtersets.VLANTranslationRuleFilterSet)
+class VLANTranslationRuleFilter(BaseFilterMixin):
+    pass
+
+
 @strawberry_django.filter(models.VRF, lookups=True)
 @autotype_decorator(filtersets.VRFFilterSet)
 class VRFFilter(BaseFilterMixin):

+ 6 - 0
netbox/ipam/graphql/schema.py

@@ -53,5 +53,11 @@ class IPAMQuery:
     vlan_group: VLANGroupType = strawberry_django.field()
     vlan_group_list: List[VLANGroupType] = strawberry_django.field()
 
+    vlan_translation_policy: VLANTranslationPolicyType = strawberry_django.field()
+    vlan_translation_policy_list: List[VLANTranslationPolicyType] = strawberry_django.field()
+
+    vlan_translation_rule: VLANTranslationRuleType = strawberry_django.field()
+    vlan_translation_rule_list: List[VLANTranslationRuleType] = strawberry_django.field()
+
     vrf: VRFType = strawberry_django.field()
     vrf_list: List[VRFType] = strawberry_django.field()

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

@@ -27,6 +27,8 @@ __all__ = (
     'ServiceTemplateType',
     'VLANType',
     'VLANGroupType',
+    'VLANTranslationPolicyType',
+    'VLANTranslationRuleType',
     'VRFType',
 )
 
@@ -274,6 +276,24 @@ class VLANGroupType(OrganizationalObjectType):
         return self.scope
 
 
+@strawberry_django.type(
+    models.VLANTranslationPolicy,
+    fields='__all__',
+    filters=VLANTranslationPolicyFilter
+)
+class VLANTranslationPolicyType(NetBoxObjectType):
+    rules: List[Annotated["VLANTranslationRuleType", strawberry.lazy('ipam.graphql.types')]]
+
+
+@strawberry_django.type(
+    models.VLANTranslationRule,
+    fields='__all__',
+    filters=VLANTranslationRuleFilter
+)
+class VLANTranslationRuleType(NetBoxObjectType):
+    policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] = strawberry_django.field(select_related=["policy"])
+
+
 @strawberry_django.type(
     models.VRF,
     fields='__all__',

+ 62 - 0
netbox/ipam/migrations/0074_vlantranslationpolicy_vlantranslationrule.py

@@ -0,0 +1,62 @@
+# Generated by Django 5.0.9 on 2024-10-11 19:45
+
+import django.core.validators
+import django.db.models.deletion
+import taggit.managers
+import utilities.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0121_customfield_related_object_filter'),
+        ('ipam', '0073_charfield_null_choices'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='VLANTranslationPolicy',
+            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)),
+                ('comments', models.TextField(blank=True)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'VLAN translation policy',
+                'verbose_name_plural': 'VLAN translation policies',
+                'ordering': ('name',),
+            },
+        ),
+        migrations.CreateModel(
+            name='VLANTranslationRule',
+            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)),
+                ('local_vid', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)])),
+                ('remote_vid', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)])),
+                ('policy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='ipam.vlantranslationpolicy')),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'VLAN translation rule',
+                'ordering': ('policy', 'local_vid',),
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='vlantranslationrule',
+            constraint=models.UniqueConstraint(fields=('policy', 'local_vid'), name='ipam_vlantranslationrule_unique_policy_local_vid'),
+        ),
+        migrations.AddConstraint(
+            model_name='vlantranslationrule',
+            constraint=models.UniqueConstraint(fields=('policy', 'remote_vid'), name='ipam_vlantranslationrule_unique_policy_remote_vid'),
+        ),
+    ]

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

@@ -10,13 +10,15 @@ from dcim.models import Interface
 from ipam.choices import *
 from ipam.constants import *
 from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
-from netbox.models import OrganizationalModel, PrimaryModel
+from netbox.models import OrganizationalModel, PrimaryModel, NetBoxModel
 from utilities.data import check_ranges_overlap, ranges_to_string
 from virtualization.models import VMInterface
 
 __all__ = (
     'VLAN',
     'VLANGroup',
+    'VLANTranslationPolicy',
+    'VLANTranslationRule',
 )
 
 
@@ -273,3 +275,73 @@ class VLAN(PrimaryModel):
     @property
     def l2vpn_termination(self):
         return self.l2vpn_terminations.first()
+
+
+class VLANTranslationPolicy(PrimaryModel):
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        unique=True,
+    )
+
+    class Meta:
+        verbose_name = _('VLAN translation policy')
+        verbose_name_plural = _('VLAN translation policies')
+        ordering = ('name',)
+
+    def __str__(self):
+        return self.name
+
+
+class VLANTranslationRule(NetBoxModel):
+    policy = models.ForeignKey(
+        to=VLANTranslationPolicy,
+        related_name='rules',
+        on_delete=models.CASCADE,
+    )
+    description = models.CharField(
+        verbose_name=_('description'),
+        max_length=200,
+        blank=True
+    )
+    local_vid = models.PositiveSmallIntegerField(
+        verbose_name=_('Local VLAN ID'),
+            validators=(
+            MinValueValidator(VLAN_VID_MIN),
+            MaxValueValidator(VLAN_VID_MAX)
+        ),
+        help_text=_("Numeric VLAN ID (1-4094)")
+    )
+    remote_vid = models.PositiveSmallIntegerField(
+        verbose_name=_('Remote VLAN ID'),
+            validators=(
+            MinValueValidator(VLAN_VID_MIN),
+            MaxValueValidator(VLAN_VID_MAX)
+        ),
+        help_text=_("Numeric VLAN ID (1-4094)")
+    )
+    prerequisite_models = (
+        'ipam.VLANTranslationPolicy',
+    )
+
+    class Meta:
+        verbose_name = _('VLAN translation rule')
+        ordering = ('policy', 'local_vid',)
+        constraints = (
+            models.UniqueConstraint(
+                fields=('policy', 'local_vid'),
+                name='%(app_label)s_%(class)s_unique_policy_local_vid'
+            ),
+            models.UniqueConstraint(
+                fields=('policy', 'remote_vid'),
+                name='%(app_label)s_%(class)s_unique_policy_remote_vid'
+            ),
+        )
+
+    def __str__(self):
+        return f'{self.local_vid} -> {self.remote_vid} ({self.policy})'
+
+    def to_objectchange(self, action):
+        objectchange = super().to_objectchange(action)
+        objectchange.related_object = self.policy
+        return objectchange

+ 21 - 0
netbox/ipam/search.py

@@ -160,6 +160,27 @@ class VLANGroupIndex(SearchIndex):
     display_attrs = ('scope_type', 'description')
 
 
+@register_search
+class VLANTranslationPolicyIndex(SearchIndex):
+    model = models.VLANTranslationPolicy
+    fields = (
+        ('name', 100),
+        ('description', 500),
+    )
+    display_attrs = ('description',)
+
+
+@register_search
+class VLANTranslationRuleIndex(SearchIndex):
+    model = models.VLANTranslationRule
+    fields = (
+        ('policy', 100),
+        ('local_vid', 200),
+        ('remote_vid', 200),
+    )
+    display_attrs = ('policy', 'local_vid', 'remote_vid')
+
+
 @register_search
 class VRFIndex(SearchIndex):
     model = models.VRF

+ 53 - 0
netbox/ipam/tables/vlans.py

@@ -16,6 +16,8 @@ __all__ = (
     'VLANMembersTable',
     'VLANTable',
     'VLANVirtualMachinesTable',
+    'VLANTranslationPolicyTable',
+    'VLANTranslationRuleTable',
 )
 
 AVAILABLE_LABEL = mark_safe('<span class="badge text-bg-success">Available</span>')
@@ -244,3 +246,54 @@ class InterfaceVLANTable(NetBoxTable):
     def __init__(self, interface, *args, **kwargs):
         self.interface = interface
         super().__init__(*args, **kwargs)
+
+
+#
+# VLAN Translation
+#
+
+class VLANTranslationPolicyTable(NetBoxTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True
+    )
+    description = tables.Column(
+        verbose_name=_('Description'),
+    )
+    tags = columns.TagColumn(
+        url_name='ipam:vlantranslationpolicy_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = VLANTranslationPolicy
+        fields = (
+            'pk', 'id', 'name', 'description', 'tags', 'created', 'last_updated',
+        )
+        default_columns = ('pk', 'name', 'description')
+
+
+class VLANTranslationRuleTable(NetBoxTable):
+    policy = tables.Column(
+        verbose_name=_('Policy'),
+        linkify=True
+    )
+    local_vid = tables.Column(
+        verbose_name=_('Local VID'),
+        linkify=True
+    )
+    remote_vid = tables.Column(
+        verbose_name=_('Remote VID'),
+    )
+    description = tables.Column(
+        verbose_name=_('Description'),
+    )
+    tags = columns.TagColumn(
+        url_name='ipam:vlantranslationrule_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = VLANTranslationRule
+        fields = (
+            'pk', 'id', 'name', 'policy', 'local_vid', 'remote_vid', 'description', 'tags', 'created', 'last_updated',
+        )
+        default_columns = ('pk', 'policy', 'local_vid', 'remote_vid', 'description')

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

@@ -1020,6 +1020,112 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
         self.assertTrue(content['detail'].startswith('Unable to delete object.'))
 
 
+class VLANTranslationPolicyTest(APIViewTestCases.APIViewTestCase):
+    model = VLANTranslationPolicy
+    brief_fields = ['description', 'display', 'id', 'name', 'url',]
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        vlan_translation_policies = (
+            VLANTranslationPolicy(
+                name='Policy 1',
+                description='foobar1',
+            ),
+            VLANTranslationPolicy(
+                name='Policy 2',
+                description='foobar2',
+            ),
+            VLANTranslationPolicy(
+                name='Policy 3',
+                description='foobar3',
+            ),
+        )
+        VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies)
+
+        cls.create_data = [
+            {
+                'name': 'Policy 4',
+                'description': 'foobar4',
+            },
+            {
+                'name': 'Policy 5',
+                'description': 'foobar5',
+            },
+            {
+                'name': 'Policy 6',
+                'description': 'foobar6',
+            },
+        ]
+
+
+class VLANTranslationRuleTest(APIViewTestCases.APIViewTestCase):
+    model = VLANTranslationRule
+    brief_fields = ['id', 'local_vid', 'policy', 'remote_vid',]
+
+    @classmethod
+    def setUpTestData(cls):
+
+        vlan_translation_policies = (
+            VLANTranslationPolicy(
+                name='Policy 1',
+                description='foobar1',
+            ),
+            VLANTranslationPolicy(
+                name='Policy 2',
+                description='foobar2',
+            ),
+        )
+        VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies)
+
+        vlan_translation_rules = (
+            VLANTranslationRule(
+                policy=vlan_translation_policies[0],
+                local_vid=100,
+                remote_vid=200,
+                description='foo',
+            ),
+            VLANTranslationRule(
+                policy=vlan_translation_policies[0],
+                local_vid=101,
+                remote_vid=201,
+                description='bar',
+            ),
+            VLANTranslationRule(
+                policy=vlan_translation_policies[1],
+                local_vid=102,
+                remote_vid=202,
+                description='baz',
+            ),
+        )
+        VLANTranslationRule.objects.bulk_create(vlan_translation_rules)
+
+        cls.create_data = [
+            {
+                'policy': vlan_translation_policies[0].pk,
+                'local_vid': 300,
+                'remote_vid': 400,
+            },
+            {
+                'policy': vlan_translation_policies[0].pk,
+                'local_vid': 301,
+                'remote_vid': 401,
+            },
+            {
+                'policy': vlan_translation_policies[1].pk,
+                'local_vid': 302,
+                'remote_vid': 402,
+            },
+        ]
+
+        cls.bulk_update_data = {
+            'policy': vlan_translation_policies[1].pk,
+        }
+
+
 class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ServiceTemplate
     brief_fields = ['description', 'display', 'id', 'name', 'ports', 'protocol', 'url']

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

@@ -1898,6 +1898,99 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
+class VLANTranslationPolicyTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = VLANTranslationPolicy.objects.all()
+    filterset = VLANTranslationPolicyFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        vlan_translation_policies = (
+            VLANTranslationPolicy(
+                name='Policy 1',
+                description='foobar1',
+            ),
+            VLANTranslationPolicy(
+                name='Policy 2',
+                description='foobar2',
+            ),
+            VLANTranslationPolicy(
+                name='Policy 3',
+                description='foobar3',
+            ),
+        )
+        VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies)
+
+    def test_name(self):
+        params = {'name': ['Policy 1', 'Policy 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class VLANTranslationRuleTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = VLANTranslationRule.objects.all()
+    filterset = VLANTranslationRuleFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        vlan_translation_policies = (
+            VLANTranslationPolicy(
+                name='Policy 1',
+                description='foobar1',
+            ),
+            VLANTranslationPolicy(
+                name='Policy 2',
+                description='foobar2',
+            ),
+        )
+        VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies)
+
+        vlan_translation_rules = (
+            VLANTranslationRule(
+                policy=vlan_translation_policies[0],
+                local_vid=100,
+                remote_vid=200,
+                description='foo',
+            ),
+            VLANTranslationRule(
+                policy=vlan_translation_policies[0],
+                local_vid=101,
+                remote_vid=201,
+                description='bar',
+            ),
+            VLANTranslationRule(
+                policy=vlan_translation_policies[1],
+                local_vid=100,
+                remote_vid=200,
+                description='baz',
+            ),
+        )
+        VLANTranslationRule.objects.bulk_create(vlan_translation_rules)
+
+    def test_policy_id(self):
+        policies = VLANTranslationPolicy.objects.all()[:2]
+        params = {'policy_id': [policies[0].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'policy': [policies[0].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_local_vid(self):
+        params = {'local_vid': [100]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_remote_vid(self):
+        params = {'remote_vid': [200]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['foo', 'bar']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ServiceTemplate.objects.all()
     filterset = ServiceTemplateFilterSet

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

@@ -863,6 +863,121 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
 
+class VLANTranslationPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = VLANTranslationPolicy
+
+    @classmethod
+    def setUpTestData(cls):
+
+        vlan_translation_policies = (
+            VLANTranslationPolicy(
+                name='Policy 1',
+                description='foobar1',
+            ),
+            VLANTranslationPolicy(
+                name='Policy 2',
+                description='foobar2',
+            ),
+            VLANTranslationPolicy(
+                name='Policy 3',
+                description='foobar3',
+            ),
+        )
+        VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'Policy999',
+            'description': 'A new VLAN Translation Policy',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,description",
+            "Policy101,foobar1",
+            "Policy102,foobar2",
+            "Policy103,foobar3",
+        )
+
+        cls.csv_update_data = (
+            "id,name,description",
+            f"{vlan_translation_policies[0].pk},Policy101,New description 1",
+            f"{vlan_translation_policies[1].pk},Policy102,New description 2",
+            f"{vlan_translation_policies[2].pk},Policy103,New description 3",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
+
+class VLANTranslationRuleTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = VLANTranslationRule
+
+    @classmethod
+    def setUpTestData(cls):
+
+        vlan_translation_policies = (
+            VLANTranslationPolicy(
+                name='Policy 1',
+                description='foobar1',
+            ),
+            VLANTranslationPolicy(
+                name='Policy 2',
+                description='foobar2',
+            ),
+            VLANTranslationPolicy(
+                name='Policy 3',
+                description='foobar3',
+            ),
+        )
+        VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies)
+
+        vlan_translation_rules = (
+            VLANTranslationRule(
+                policy=vlan_translation_policies[0],
+                local_vid=100,
+                remote_vid=200,
+            ),
+            VLANTranslationRule(
+                policy=vlan_translation_policies[0],
+                local_vid=101,
+                remote_vid=201,
+            ),
+            VLANTranslationRule(
+                policy=vlan_translation_policies[1],
+                local_vid=102,
+                remote_vid=202,
+            ),
+        )
+        VLANTranslationRule.objects.bulk_create(vlan_translation_rules)
+
+        cls.form_data = {
+            'policy': vlan_translation_policies[0].pk,
+            'local_vid': 300,
+            'remote_vid': 400,
+        }
+
+        cls.csv_data = (
+            "policy,local_vid,remote_vid",
+            f"{vlan_translation_policies[0].pk},103,203",
+            f"{vlan_translation_policies[0].pk},104,204",
+            f"{vlan_translation_policies[1].pk},105,205",
+        )
+
+        cls.csv_update_data = (
+            "id,policy,local_vid,remote_vid",
+            f"{vlan_translation_rules[0].pk},{vlan_translation_policies[1].pk},105,205",
+            f"{vlan_translation_rules[1].pk},{vlan_translation_policies[1].pk},106,206",
+            f"{vlan_translation_rules[2].pk},{vlan_translation_policies[0].pk},107,207",
+        )
+
+        cls.bulk_edit_data = {
+            'policy': vlan_translation_policies[2].pk,
+        }
+
+
 class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = ServiceTemplate
 

+ 16 - 0
netbox/ipam/urls.py

@@ -116,6 +116,22 @@ urlpatterns = [
     path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
     path('vlans/<int:pk>/', include(get_model_urls('ipam', 'vlan'))),
 
+    # VLAN Translation Policies
+    path('vlan-translation-policies/', views.VLANTranslationPolicyListView.as_view(), name='vlantranslationpolicy_list'),
+    path('vlan-translation-policies/add/', views.VLANTranslationPolicyEditView.as_view(), name='vlantranslationpolicy_add'),
+    path('vlan-translation-policies/import/', views.VLANTranslationPolicyBulkImportView.as_view(), name='vlantranslationpolicy_import'),
+    path('vlan-translation-policies/edit/', views.VLANTranslationPolicyBulkEditView.as_view(), name='vlantranslationpolicy_bulk_edit'),
+    path('vlan-translation-policies/delete/', views.VLANTranslationPolicyBulkDeleteView.as_view(), name='vlantranslationpolicy_bulk_delete'),
+    path('vlan-translation-policies/<int:pk>/', include(get_model_urls('ipam', 'vlantranslationpolicy'))),
+
+    # VLAN Translation Rules
+    path('vlan-translation-rules/', views.VLANTranslationRuleListView.as_view(), name='vlantranslationrule_list'),
+    path('vlan-translation-rules/add/', views.VLANTranslationRuleEditView.as_view(), name='vlantranslationrule_add'),
+    path('vlan-translation-rules/import/', views.VLANTranslationRuleBulkImportView.as_view(), name='vlantranslationrule_import'),
+    path('vlan-translation-rules/edit/', views.VLANTranslationRuleBulkEditView.as_view(), name='vlantranslationrule_bulk_edit'),
+    path('vlan-translation-rules/delete/', views.VLANTranslationRuleBulkDeleteView.as_view(), name='vlantranslationrule_bulk_delete'),
+    path('vlan-translation-rules/<int:pk>/', include(get_model_urls('ipam', 'vlantranslationrule'))),
+
     # Service templates
     path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'),
     path('service-templates/add/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_add'),

+ 105 - 0
netbox/ipam/views.py

@@ -9,6 +9,7 @@ from circuits.models import Provider
 from dcim.filtersets import InterfaceFilterSet
 from dcim.forms import InterfaceFilterForm
 from dcim.models import Interface, Site
+from ipam.tables import VLANTranslationRuleTable
 from netbox.views import generic
 from tenancy.views import ObjectContactsView
 from utilities.query import count_related
@@ -986,6 +987,110 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
         return queryset
 
 
+#
+# VLAN Translation Policies
+#
+
+class VLANTranslationPolicyListView(generic.ObjectListView):
+    queryset = VLANTranslationPolicy.objects.all()
+    filterset = filtersets.VLANTranslationPolicyFilterSet
+    filterset_form = forms.VLANTranslationPolicyFilterForm
+    table = tables.VLANTranslationPolicyTable
+
+
+@register_model_view(VLANTranslationPolicy)
+class VLANTranslationPolicyView(GetRelatedModelsMixin, generic.ObjectView):
+    queryset = VLANTranslationPolicy.objects.all()
+
+    def get_extra_context(self, request, instance):
+        vlan_translation_table = VLANTranslationRuleTable(
+            data=instance.rules.all(),
+            orderable=False
+        )
+        return {
+            'vlan_translation_table': vlan_translation_table,
+        }
+
+
+@register_model_view(VLANTranslationPolicy, 'edit')
+class VLANTranslationPolicyEditView(generic.ObjectEditView):
+    queryset = VLANTranslationPolicy.objects.all()
+    form = forms.VLANTranslationPolicyForm
+
+
+@register_model_view(VLANTranslationPolicy, 'delete')
+class VLANTranslationPolicyDeleteView(generic.ObjectDeleteView):
+    queryset = VLANTranslationPolicy.objects.all()
+
+
+class VLANTranslationPolicyBulkImportView(generic.BulkImportView):
+    queryset = VLANTranslationPolicy.objects.all()
+    model_form = forms.VLANTranslationPolicyImportForm
+
+
+class VLANTranslationPolicyBulkEditView(generic.BulkEditView):
+    queryset = VLANTranslationPolicy.objects.all()
+    filterset = filtersets.VLANTranslationPolicyFilterSet
+    table = tables.VLANTranslationPolicyTable
+    form = forms.VLANTranslationPolicyBulkEditForm
+
+
+class VLANTranslationPolicyBulkDeleteView(generic.BulkDeleteView):
+    queryset = VLANTranslationPolicy.objects.all()
+    filterset = filtersets.VLANTranslationPolicyFilterSet
+    table = tables.VLANTranslationPolicyTable
+
+
+#
+# VLAN Translation Rules
+#
+
+class VLANTranslationRuleListView(generic.ObjectListView):
+    queryset = VLANTranslationRule.objects.all()
+    filterset = filtersets.VLANTranslationRuleFilterSet
+    filterset_form = forms.VLANTranslationRuleFilterForm
+    table = tables.VLANTranslationRuleTable
+
+
+@register_model_view(VLANTranslationRule)
+class VLANTranslationRuleView(GetRelatedModelsMixin, generic.ObjectView):
+    queryset = VLANTranslationRule.objects.all()
+
+    def get_extra_context(self, request, instance):
+        return {
+            'related_models': self.get_related_models(request, instance),
+        }
+
+
+@register_model_view(VLANTranslationRule, 'edit')
+class VLANTranslationRuleEditView(generic.ObjectEditView):
+    queryset = VLANTranslationRule.objects.all()
+    form = forms.VLANTranslationRuleForm
+
+
+@register_model_view(VLANTranslationRule, 'delete')
+class VLANTranslationRuleDeleteView(generic.ObjectDeleteView):
+    queryset = VLANTranslationRule.objects.all()
+
+
+class VLANTranslationRuleBulkImportView(generic.BulkImportView):
+    queryset = VLANTranslationRule.objects.all()
+    model_form = forms.VLANTranslationRuleImportForm
+
+
+class VLANTranslationRuleBulkEditView(generic.BulkEditView):
+    queryset = VLANTranslationRule.objects.all()
+    filterset = filtersets.VLANTranslationRuleFilterSet
+    table = tables.VLANTranslationRuleTable
+    form = forms.VLANTranslationRuleBulkEditForm
+
+
+class VLANTranslationRuleBulkDeleteView(generic.BulkDeleteView):
+    queryset = VLANTranslationRule.objects.all()
+    filterset = filtersets.VLANTranslationRuleFilterSet
+    table = tables.VLANTranslationRuleTable
+
+
 #
 # FHRP groups
 #

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

@@ -194,6 +194,8 @@ IPAM_MENU = Menu(
             items=(
                 get_model_item('ipam', 'vlan', _('VLANs')),
                 get_model_item('ipam', 'vlangroup', _('VLAN Groups')),
+                get_model_item('ipam', 'vlantranslationpolicy', _('VLAN Translation Policies')),
+                get_model_item('ipam', 'vlantranslationrule', _('VLAN Translation Rules')),
             ),
         ),
         MenuGroup(

+ 11 - 0
netbox/templates/dcim/interface.html

@@ -133,6 +133,10 @@
             <th scope="row">{% trans "VRF" %}</th>
             <td>{{ object.vrf|linkify|placeholder }}</td>
           </tr>
+          <tr>
+            <th scope="row">{% trans "VLAN Translation" %}</th>
+            <td>{{ object.vlan_translation_policy|linkify|placeholder }}</td>
+          </tr>
         </table>
       </div>
       {% if not object.is_virtual %}
@@ -355,6 +359,13 @@
       {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
     </div>
   </div>
+  {% if object.vlan_translation_policy %}
+    <div class="row mb-3">
+      <div class="col col-md-12">
+        {% include 'inc/panel_table.html' with table=vlan_translation_table heading="VLAN Translation" %}
+      </div>
+    </div>
+  {% endif %}
   {% if object.is_bridge %}
     <div class="row mb-3">
       <div class="col col-md-12">

+ 55 - 0
netbox/templates/ipam/vlantranslationpolicy.html

@@ -0,0 +1,55 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-4">
+      <div class="card">
+        <h2 class="card-header">{% trans "VLAN Translation Policy" %}</h2>
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Name" %}</th>
+            <td>{{ object.name|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Description" %}</th>
+            <td>{{ object.description|placeholder }}</td>
+          </tr>
+        </table>
+      </div>
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-8">
+      {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/comments.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row mb-3">
+    <div class="col col-md-12">
+      <div class="card">
+        <h2 class="card-header">
+          {% trans "VLAN Translation Rules" %}
+          {% if perms.ipam.add_vlantranslationrule %}
+            <div class="card-actions">
+              <a href="{% url 'ipam:vlantranslationrule_add' %}?device={{ object.device.pk }}&policy={{ object.pk }}&return_url={{ object.get_absolute_url }}"
+                 class="btn btn-ghost-primary btn-sm">
+                <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Rule" %}
+              </a>
+            </div>
+          {% endif %}
+        </h2>
+        {% htmx_table 'ipam:vlantranslationrule_list' policy_id=object.pk %}
+      </div>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 45 - 0
netbox/templates/ipam/vlantranslationrule.html

@@ -0,0 +1,45 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-4">
+      <div class="card">
+        <h2 class="card-header">{% trans "VLAN Translation Rule" %}</h2>
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Policy" %}</th>
+            <td>{{ object.policy|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Local VID" %}</th>
+            <td>{{ object.local_vid }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Remote VID" %}</th>
+            <td>{{ object.remote_vid }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Description" %}</th>
+            <td>{{ object.description }}</td>
+          </tr>
+        </table>
+      </div>
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-8">
+      {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/comments.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 11 - 0
netbox/templates/virtualization/vminterface.html

@@ -67,6 +67,10 @@
                     <th scope="row">{% trans "Tunnel" %}</th>
                     <td>{{ object.tunnel_termination.tunnel|linkify|placeholder }}</td>
                 </tr>
+                <tr>
+                    <th scope="row">{% trans "VLAN Translation" %}</th>
+                    <td>{{ object.vlan_translation_policy|linkify|placeholder }}</td>
+                </tr>
             </table>
         </div>
         {% include 'inc/panels/tags.html' %}
@@ -100,6 +104,13 @@
         {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
     </div>
 </div>
+{% if object.vlan_translation_policy %}
+    <div class="row mb-3">
+        <div class="col col-md-12">
+            {% include 'inc/panel_table.html' with table=vlan_translation_table heading="VLAN Translation" %}
+        </div>
+    </div>
+{% endif %}
 <div class="row mb-3">
     <div class="col col-md-12">
         {% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}

+ 3 - 1
netbox/virtualization/api/serializers_/virtualmachines.py

@@ -8,7 +8,7 @@ from dcim.api.serializers_.sites import SiteSerializer
 from dcim.choices import InterfaceModeChoices
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from ipam.api.serializers_.ip import IPAddressSerializer
-from ipam.api.serializers_.vlans import VLANSerializer
+from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
 from ipam.api.serializers_.vrfs import VRFSerializer
 from ipam.models import VLAN
 from netbox.api.fields import ChoiceField, SerializedPKRelatedField
@@ -89,6 +89,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
         required=False,
         many=True
     )
+    vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True)
     vrf = VRFSerializer(nested=True, required=False, allow_null=True)
     l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
     count_ipaddresses = serializers.IntegerField(read_only=True)
@@ -105,6 +106,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
             'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu',
             'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'l2vpn_termination',
             'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups',
+            'vlan_translation_policy',
         ]
         brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
 

+ 8 - 3
netbox/virtualization/forms/model_forms.py

@@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _
 from dcim.forms.common import InterfaceCommonForm
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from extras.models import ConfigTemplate
-from ipam.models import IPAddress, VLAN, VLANGroup, VRF
+from ipam.models import IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from utilities.forms import ConfirmationForm
@@ -343,20 +343,25 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
         required=False,
         label=_('VRF')
     )
+    vlan_translation_policy = DynamicModelChoiceField(
+        queryset=VLANTranslationPolicy.objects.all(),
+        required=False,
+        label=_('VLAN Translation Policy')
+    )
 
     fieldsets = (
         FieldSet('virtual_machine', 'name', 'description', 'tags', name=_('Interface')),
         FieldSet('vrf', 'mac_address', name=_('Addressing')),
         FieldSet('mtu', 'enabled', name=_('Operation')),
         FieldSet('parent', 'bridge', name=_('Related Interfaces')),
-        FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')),
+        FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy', name=_('802.1Q Switching')),
     )
 
     class Meta:
         model = VMInterface
         fields = [
             'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
-            'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
+            'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'vlan_translation_policy',
         ]
         labels = {
             'mode': '802.1Q Mode',

+ 1 - 0
netbox/virtualization/graphql/types.py

@@ -100,6 +100,7 @@ class VMInterfaceType(IPAddressesMixin, ComponentType):
     bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None
     untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
+    vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
 
     tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
     bridge_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]

+ 20 - 0
netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py

@@ -0,0 +1,20 @@
+# Generated by Django 5.0.9 on 2024-10-11 19:45
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0074_vlantranslationpolicy_vlantranslationrule'),
+        ('virtualization', '0041_charfield_null_choices'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='vminterface',
+            name='vlan_translation_policy',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlantranslationpolicy'),
+        ),
+    ]

+ 19 - 3
netbox/virtualization/tests/test_filtersets.py

@@ -1,7 +1,7 @@
 from django.test import TestCase
 
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
-from ipam.models import IPAddress, VRF
+from ipam.models import IPAddress, VLANTranslationPolicy, VRF
 from tenancy.models import Tenant, TenantGroup
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
 from virtualization.choices import *
@@ -561,6 +561,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         VirtualMachine.objects.bulk_create(vms)
 
+        vlan_translation_policies = (
+            VLANTranslationPolicy(name='Policy 1'),
+            VLANTranslationPolicy(name='Policy 2'),
+            VLANTranslationPolicy(name='Policy 3'),
+        )
+        VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies)
+
         interfaces = (
             VMInterface(
                 virtual_machine=vms[0],
@@ -569,7 +576,8 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
                 mtu=100,
                 mac_address='00-00-00-00-00-01',
                 vrf=vrfs[0],
-                description='foobar1'
+                description='foobar1',
+                vlan_translation_policy=vlan_translation_policies[0],
             ),
             VMInterface(
                 virtual_machine=vms[1],
@@ -578,7 +586,8 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
                 mtu=200,
                 mac_address='00-00-00-00-00-02',
                 vrf=vrfs[1],
-                description='foobar2'
+                description='foobar2',
+                vlan_translation_policy=vlan_translation_policies[0],
             ),
             VMInterface(
                 virtual_machine=vms[2],
@@ -658,6 +667,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_vlan_translation_policy(self):
+        vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2]
+        params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'vlan_translation_policy': [vlan_translation_policies[0].name, vlan_translation_policies[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class VirtualDiskTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VirtualDisk.objects.all()

+ 10 - 1
netbox/virtualization/views.py

@@ -16,7 +16,7 @@ from dcim.models import Device
 from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
 from ipam.models import IPAddress
-from ipam.tables import InterfaceVLANTable
+from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
 from netbox.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
 from tenancy.views import ObjectContactsView
@@ -516,6 +516,14 @@ class VMInterfaceView(generic.ObjectView):
             orderable=False
         )
 
+        # Get VLAN translation rules
+        vlan_translation_table = None
+        if instance.vlan_translation_policy:
+            vlan_translation_table = VLANTranslationRuleTable(
+                data=instance.vlan_translation_policy.rules.all(),
+                orderable=False
+            )
+
         # Get assigned VLANs and annotate whether each is tagged or untagged
         vlans = []
         if instance.untagged_vlan is not None:
@@ -533,6 +541,7 @@ class VMInterfaceView(generic.ObjectView):
         return {
             'child_interfaces_table': child_interfaces_tables,
             'vlan_table': vlan_table,
+            'vlan_translation_table': vlan_translation_table,
         }