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

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 год назад
Родитель
Сommit
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
 ### Wireless LANs
 
 
 The [wireless LANs](../wireless/wirelesslan.md) for which this interface carries traffic. (Valid for wireless interfaces only.)
 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
 ### VRF
 
 
 The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned.
 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,
     ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
     RearPort, VirtualDeviceContext,
     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.api.serializers_.vrfs import VRFSerializer
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
@@ -196,6 +196,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
         required=False,
         required=False,
         many=True
         many=True
     )
     )
+    vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True)
     vrf = VRFSerializer(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)
     l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
     wireless_link = NestedWirelessLinkSerializer(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',
             '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',
             'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
             'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
             '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')
         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.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, IPAddress, VRF
+from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
 from netbox.filtersets import (
 from netbox.filtersets import (
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
@@ -1629,6 +1629,17 @@ class CommonInterfaceFilterSet(django_filters.FilterSet):
         to_field_name='identifier',
         to_field_name='identifier',
         label=_('L2VPN'),
         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):
     def filter_vlan_id(self, queryset, name, value):
         value = value.strip()
         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.constants import *
 from dcim.models import *
 from dcim.models import *
 from extras.models import ConfigTemplate
 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 netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from users.models import User
 from users.models import User
@@ -1382,6 +1382,11 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         required=False,
         required=False,
         label=_('WWN')
         label=_('WWN')
     )
     )
+    vlan_translation_policy = DynamicModelChoiceField(
+        queryset=VLANTranslationPolicy.objects.all(),
+        required=False,
+        label=_('VLAN Translation Policy')
+    )
 
 
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
@@ -1391,7 +1396,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
         FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         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(
         FieldSet(
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
             name=_('Wireless')
             name=_('Wireless')
@@ -1404,7 +1409,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
             'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
             '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',
             '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',
             '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 = {
         widgets = {
             'speed': NumberWithOptions(
             '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
     wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')] | None
     untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     vrf: Annotated["VRFType", 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')]]
     vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
     tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.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,
         blank=True,
         verbose_name=_('bridge interface')
         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:
     class Meta:
         abstract = True
         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.choices import *
 from dcim.filtersets import *
 from dcim.filtersets import *
 from dcim.models 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 netbox.choices import ColorChoices, WeightUnitChoices
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from users.models import User
 from users.models import User
@@ -3669,6 +3669,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         )
         VirtualDeviceContext.objects.bulk_create(vdcs)
         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 = (
         interfaces = (
             Interface(
             Interface(
                 device=devices[0],
                 device=devices[0],
@@ -3686,7 +3693,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 speed=1000000,
                 speed=1000000,
                 duplex='half',
                 duplex='half',
                 poe_mode=InterfacePoEModeChoices.MODE_PSE,
                 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(
             Interface(
                 device=devices[1],
                 device=devices[1],
@@ -3711,7 +3719,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 speed=1000000,
                 speed=1000000,
                 duplex='full',
                 duplex='full',
                 poe_mode=InterfacePoEModeChoices.MODE_PD,
                 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(
             Interface(
                 device=devices[3],
                 device=devices[3],
@@ -3729,7 +3738,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 speed=100000,
                 speed=100000,
                 duplex='half',
                 duplex='half',
                 poe_mode=InterfacePoEModeChoices.MODE_PSE,
                 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(
             Interface(
                 device=devices[4],
                 device=devices[4],
@@ -3742,7 +3752,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 speed=100000,
                 speed=100000,
                 duplex='full',
                 duplex='full',
                 poe_mode=InterfacePoEModeChoices.MODE_PD,
                 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(
             Interface(
                 device=devices[4],
                 device=devices[4],
@@ -4016,6 +4027,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         params = {'vdc_identifier': vdc.values_list('identifier', flat=True)}
         params = {'vdc_identifier': vdc.values_list('identifier', flat=True)}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         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):
 class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = FrontPort.objects.all()
     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 circuits.models import Circuit, CircuitTermination
 from extras.views import ObjectConfigContextView
 from extras.views import ObjectConfigContextView
 from ipam.models import ASN, IPAddress, VLANGroup
 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.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
 from netbox.views import generic
 from tenancy.views import ObjectContactsView
 from tenancy.views import ObjectContactsView
@@ -2580,11 +2580,20 @@ class InterfaceView(generic.ObjectView):
             orderable=False
             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 {
         return {
             'vdc_table': vdc_table,
             'vdc_table': vdc_table,
             'bridge_interfaces_table': bridge_interfaces_tables,
             'bridge_interfaces_table': bridge_interfaces_tables,
             'child_interfaces_table': child_interfaces_tables,
             'child_interfaces_table': child_interfaces_tables,
             'vlan_table': vlan_table,
             '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',
         'virtualmachine',
         'vlan',
         'vlan',
         'vlangroup',
         'vlangroup',
+        'vlantranslationpolicy',
+        'vlantranslationrule',
         'vminterface',
         'vminterface',
         'vrf',
         'vrf',
         'webhook',
         '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 dcim.api.serializers_.sites import SiteSerializer
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import VLANGROUP_SCOPE_TYPES
 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.fields import ChoiceField, ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField
 from netbox.api.serializers import NetBoxModelSerializer
 from netbox.api.serializers import NetBoxModelSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
@@ -18,6 +18,8 @@ __all__ = (
     'CreateAvailableVLANSerializer',
     'CreateAvailableVLANSerializer',
     'VLANGroupSerializer',
     'VLANGroupSerializer',
     'VLANSerializer',
     'VLANSerializer',
+    'VLANTranslationPolicySerializer',
+    'VLANTranslationRuleSerializer',
 )
 )
 
 
 
 
@@ -110,3 +112,19 @@ class CreateAvailableVLANSerializer(NetBoxModelSerializer):
     def validate(self, data):
     def validate(self, data):
         # Bypass model validation since we don't have a VID yet
         # Bypass model validation since we don't have a VID yet
         return data
         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('fhrp-group-assignments', views.FHRPGroupAssignmentViewSet)
 router.register('vlan-groups', views.VLANGroupViewSet)
 router.register('vlan-groups', views.VLANGroupViewSet)
 router.register('vlans', views.VLANViewSet)
 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('service-templates', views.ServiceTemplateViewSet)
 router.register('services', views.ServiceViewSet)
 router.register('services', views.ServiceViewSet)
 
 

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

@@ -143,6 +143,18 @@ class VLANViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.VLANFilterSet
     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):
 class ServiceTemplateViewSet(NetBoxModelViewSet):
     queryset = ServiceTemplate.objects.all()
     queryset = ServiceTemplate.objects.all()
     serializer_class = serializers.ServiceTemplateSerializer
     serializer_class = serializers.ServiceTemplateSerializer

+ 49 - 0
netbox/ipam/filtersets.py

@@ -37,6 +37,8 @@ __all__ = (
     'ServiceTemplateFilterSet',
     'ServiceTemplateFilterSet',
     'VLANFilterSet',
     'VLANFilterSet',
     'VLANGroupFilterSet',
     'VLANGroupFilterSet',
+    'VLANTranslationPolicyFilterSet',
+    'VLANTranslationRuleFilterSet',
     'VRFFilterSet',
     '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):
 class ServiceTemplateFilterSet(NetBoxModelFilterSet):
     port = NumericArrayFilter(
     port = NumericArrayFilter(
         field_name='ports',
         field_name='ports',

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

@@ -34,6 +34,8 @@ __all__ = (
     'ServiceTemplateBulkEditForm',
     'ServiceTemplateBulkEditForm',
     'VLANBulkEditForm',
     'VLANBulkEditForm',
     'VLANGroupBulkEditForm',
     'VLANGroupBulkEditForm',
+    'VLANTranslationPolicyBulkEditForm',
+    'VLANTranslationRuleBulkEditForm',
     'VRFBulkEditForm',
     '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):
 class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
     protocol = forms.ChoiceField(
     protocol = forms.ChoiceField(
         label=_('Protocol'),
         label=_('Protocol'),

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

@@ -29,6 +29,8 @@ __all__ = (
     'ServiceTemplateImportForm',
     'ServiceTemplateImportForm',
     'VLANImportForm',
     'VLANImportForm',
     'VLANGroupImportForm',
     'VLANGroupImportForm',
+    'VLANTranslationPolicyImportForm',
+    'VLANTranslationRuleImportForm',
     'VRFImportForm',
     'VRFImportForm',
 )
 )
 
 
@@ -465,6 +467,20 @@ class VLANImportForm(NetBoxModelImportForm):
         fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments', 'tags')
         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):
 class ServiceTemplateImportForm(NetBoxModelImportForm):
     protocol = CSVChoiceField(
     protocol = CSVChoiceField(
         label=_('Protocol'),
         label=_('Protocol'),

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

@@ -28,6 +28,8 @@ __all__ = (
     'ServiceTemplateFilterForm',
     'ServiceTemplateFilterForm',
     'VLANFilterForm',
     'VLANFilterForm',
     'VLANGroupFilterForm',
     'VLANGroupFilterForm',
+    'VLANTranslationPolicyFilterForm',
+    'VLANTranslationRuleFilterForm',
     'VRFFilterForm',
     'VRFFilterForm',
 )
 )
 
 
@@ -461,6 +463,43 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     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):
 class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VLAN
     model = VLAN
     fieldsets = (
     fieldsets = (

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

@@ -41,6 +41,8 @@ __all__ = (
     'ServiceTemplateForm',
     'ServiceTemplateForm',
     'VLANForm',
     'VLANForm',
     'VLANGroupForm',
     'VLANGroupForm',
+    'VLANTranslationPolicyForm',
+    'VLANTranslationRuleForm',
     'VRFForm',
     '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):
 class ServiceTemplateForm(NetBoxModelForm):
     ports = NumericArrayField(
     ports = NumericArrayField(
         label=_('Ports'),
         label=_('Ports'),

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

@@ -19,6 +19,8 @@ __all__ = (
     'ServiceTemplateFilter',
     'ServiceTemplateFilter',
     'VLANFilter',
     'VLANFilter',
     'VLANGroupFilter',
     'VLANGroupFilter',
+    'VLANTranslationPolicyFilter',
+    'VLANTranslationRuleFilter',
     'VRFFilter',
     'VRFFilter',
 )
 )
 
 
@@ -113,6 +115,18 @@ class VLANGroupFilter(BaseFilterMixin):
     pass
     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)
 @strawberry_django.filter(models.VRF, lookups=True)
 @autotype_decorator(filtersets.VRFFilterSet)
 @autotype_decorator(filtersets.VRFFilterSet)
 class VRFFilter(BaseFilterMixin):
 class VRFFilter(BaseFilterMixin):

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

@@ -53,5 +53,11 @@ class IPAMQuery:
     vlan_group: VLANGroupType = strawberry_django.field()
     vlan_group: VLANGroupType = strawberry_django.field()
     vlan_group_list: List[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: VRFType = strawberry_django.field()
     vrf_list: List[VRFType] = strawberry_django.field()
     vrf_list: List[VRFType] = strawberry_django.field()

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

@@ -27,6 +27,8 @@ __all__ = (
     'ServiceTemplateType',
     'ServiceTemplateType',
     'VLANType',
     'VLANType',
     'VLANGroupType',
     'VLANGroupType',
+    'VLANTranslationPolicyType',
+    'VLANTranslationRuleType',
     'VRFType',
     'VRFType',
 )
 )
 
 
@@ -274,6 +276,24 @@ class VLANGroupType(OrganizationalObjectType):
         return self.scope
         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(
 @strawberry_django.type(
     models.VRF,
     models.VRF,
     fields='__all__',
     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.choices import *
 from ipam.constants import *
 from ipam.constants import *
 from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
 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 utilities.data import check_ranges_overlap, ranges_to_string
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
 
 
 __all__ = (
 __all__ = (
     'VLAN',
     'VLAN',
     'VLANGroup',
     'VLANGroup',
+    'VLANTranslationPolicy',
+    'VLANTranslationRule',
 )
 )
 
 
 
 
@@ -273,3 +275,73 @@ class VLAN(PrimaryModel):
     @property
     @property
     def l2vpn_termination(self):
     def l2vpn_termination(self):
         return self.l2vpn_terminations.first()
         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')
     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
 @register_search
 class VRFIndex(SearchIndex):
 class VRFIndex(SearchIndex):
     model = models.VRF
     model = models.VRF

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

@@ -16,6 +16,8 @@ __all__ = (
     'VLANMembersTable',
     'VLANMembersTable',
     'VLANTable',
     'VLANTable',
     'VLANVirtualMachinesTable',
     'VLANVirtualMachinesTable',
+    'VLANTranslationPolicyTable',
+    'VLANTranslationRuleTable',
 )
 )
 
 
 AVAILABLE_LABEL = mark_safe('<span class="badge text-bg-success">Available</span>')
 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):
     def __init__(self, interface, *args, **kwargs):
         self.interface = interface
         self.interface = interface
         super().__init__(*args, **kwargs)
         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.'))
         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):
 class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ServiceTemplate
     model = ServiceTemplate
     brief_fields = ['description', 'display', 'id', 'name', 'ports', 'protocol', 'url']
     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)
         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):
 class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ServiceTemplate.objects.all()
     queryset = ServiceTemplate.objects.all()
     filterset = ServiceTemplateFilterSet
     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):
 class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = ServiceTemplate
     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/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
     path('vlans/<int:pk>/', include(get_model_urls('ipam', 'vlan'))),
     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
     # Service templates
     path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'),
     path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'),
     path('service-templates/add/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_add'),
     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.filtersets import InterfaceFilterSet
 from dcim.forms import InterfaceFilterForm
 from dcim.forms import InterfaceFilterForm
 from dcim.models import Interface, Site
 from dcim.models import Interface, Site
+from ipam.tables import VLANTranslationRuleTable
 from netbox.views import generic
 from netbox.views import generic
 from tenancy.views import ObjectContactsView
 from tenancy.views import ObjectContactsView
 from utilities.query import count_related
 from utilities.query import count_related
@@ -986,6 +987,110 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
         return queryset
         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
 # FHRP groups
 #
 #

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

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

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

@@ -133,6 +133,10 @@
             <th scope="row">{% trans "VRF" %}</th>
             <th scope="row">{% trans "VRF" %}</th>
             <td>{{ object.vrf|linkify|placeholder }}</td>
             <td>{{ object.vrf|linkify|placeholder }}</td>
           </tr>
           </tr>
+          <tr>
+            <th scope="row">{% trans "VLAN Translation" %}</th>
+            <td>{{ object.vlan_translation_policy|linkify|placeholder }}</td>
+          </tr>
         </table>
         </table>
       </div>
       </div>
       {% if not object.is_virtual %}
       {% if not object.is_virtual %}
@@ -355,6 +359,13 @@
       {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
       {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
     </div>
     </div>
   </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 %}
   {% if object.is_bridge %}
     <div class="row mb-3">
     <div class="row mb-3">
       <div class="col col-md-12">
       <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>
                     <th scope="row">{% trans "Tunnel" %}</th>
                     <td>{{ object.tunnel_termination.tunnel|linkify|placeholder }}</td>
                     <td>{{ object.tunnel_termination.tunnel|linkify|placeholder }}</td>
                 </tr>
                 </tr>
+                <tr>
+                    <th scope="row">{% trans "VLAN Translation" %}</th>
+                    <td>{{ object.vlan_translation_policy|linkify|placeholder }}</td>
+                </tr>
             </table>
             </table>
         </div>
         </div>
         {% include 'inc/panels/tags.html' %}
         {% include 'inc/panels/tags.html' %}
@@ -100,6 +104,13 @@
         {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
         {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
     </div>
     </div>
 </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="row mb-3">
     <div class="col col-md-12">
     <div class="col col-md-12">
         {% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
         {% 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 dcim.choices import InterfaceModeChoices
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from ipam.api.serializers_.ip import IPAddressSerializer
 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.api.serializers_.vrfs import VRFSerializer
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.api.fields import ChoiceField, SerializedPKRelatedField
 from netbox.api.fields import ChoiceField, SerializedPKRelatedField
@@ -89,6 +89,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
         required=False,
         required=False,
         many=True
         many=True
     )
     )
+    vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True)
     vrf = VRFSerializer(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)
     l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
     count_ipaddresses = serializers.IntegerField(read_only=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',
             'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu',
             'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'l2vpn_termination',
             'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'l2vpn_termination',
             'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups',
             'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups',
+            'vlan_translation_policy',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
         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.forms.common import InterfaceCommonForm
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from extras.models import ConfigTemplate
 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 netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
@@ -343,20 +343,25 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
         required=False,
         required=False,
         label=_('VRF')
         label=_('VRF')
     )
     )
+    vlan_translation_policy = DynamicModelChoiceField(
+        queryset=VLANTranslationPolicy.objects.all(),
+        required=False,
+        label=_('VLAN Translation Policy')
+    )
 
 
     fieldsets = (
     fieldsets = (
         FieldSet('virtual_machine', 'name', 'description', 'tags', name=_('Interface')),
         FieldSet('virtual_machine', 'name', 'description', 'tags', name=_('Interface')),
         FieldSet('vrf', 'mac_address', name=_('Addressing')),
         FieldSet('vrf', 'mac_address', name=_('Addressing')),
         FieldSet('mtu', 'enabled', name=_('Operation')),
         FieldSet('mtu', 'enabled', name=_('Operation')),
         FieldSet('parent', 'bridge', name=_('Related Interfaces')),
         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:
     class Meta:
         model = VMInterface
         model = VMInterface
         fields = [
         fields = [
             'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
             '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 = {
         labels = {
             'mode': '802.1Q Mode',
             '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
     bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None
     untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     vrf: Annotated["VRFType", 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')]]
     tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
     bridge_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.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 django.test import TestCase
 
 
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 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 tenancy.models import Tenant, TenantGroup
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
 from virtualization.choices import *
 from virtualization.choices import *
@@ -561,6 +561,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         VirtualMachine.objects.bulk_create(vms)
         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 = (
         interfaces = (
             VMInterface(
             VMInterface(
                 virtual_machine=vms[0],
                 virtual_machine=vms[0],
@@ -569,7 +576,8 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
                 mtu=100,
                 mtu=100,
                 mac_address='00-00-00-00-00-01',
                 mac_address='00-00-00-00-00-01',
                 vrf=vrfs[0],
                 vrf=vrfs[0],
-                description='foobar1'
+                description='foobar1',
+                vlan_translation_policy=vlan_translation_policies[0],
             ),
             ),
             VMInterface(
             VMInterface(
                 virtual_machine=vms[1],
                 virtual_machine=vms[1],
@@ -578,7 +586,8 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
                 mtu=200,
                 mtu=200,
                 mac_address='00-00-00-00-00-02',
                 mac_address='00-00-00-00-00-02',
                 vrf=vrfs[1],
                 vrf=vrfs[1],
-                description='foobar2'
+                description='foobar2',
+                vlan_translation_policy=vlan_translation_policies[0],
             ),
             ),
             VMInterface(
             VMInterface(
                 virtual_machine=vms[2],
                 virtual_machine=vms[2],
@@ -658,6 +667,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
 class VirtualDiskTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VirtualDisk.objects.all()
     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 dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
 from extras.views import ObjectConfigContextView
 from ipam.models import IPAddress
 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.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
 from netbox.views import generic
 from tenancy.views import ObjectContactsView
 from tenancy.views import ObjectContactsView
@@ -516,6 +516,14 @@ class VMInterfaceView(generic.ObjectView):
             orderable=False
             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
         # Get assigned VLANs and annotate whether each is tagged or untagged
         vlans = []
         vlans = []
         if instance.untagged_vlan is not None:
         if instance.untagged_vlan is not None:
@@ -533,6 +541,7 @@ class VMInterfaceView(generic.ObjectView):
         return {
         return {
             'child_interfaces_table': child_interfaces_tables,
             'child_interfaces_table': child_interfaces_tables,
             'vlan_table': vlan_table,
             'vlan_table': vlan_table,
+            'vlan_translation_table': vlan_translation_table,
         }
         }