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 4c1bb437ef.

* 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>
This commit is contained in:
bctiemann 2024-10-30 17:09:46 -04:00 committed by GitHub
parent 19c5c32965
commit f74a9a1c76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 1210 additions and 22 deletions

View File

@ -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).

View File

@ -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.

View File

@ -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.

View File

@ -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).

View File

@ -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')

View File

@ -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()

View File

@ -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(

View File

@ -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')]]

View File

@ -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'),
),
]

View File

@ -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

View File

@ -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()

View File

@ -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,
}

View File

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

View File

@ -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')

View File

@ -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)

View File

@ -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

View File

@ -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',

View File

@ -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'),

View File

@ -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'),

View File

@ -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 = (

View File

@ -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'),

View File

@ -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):

View File

@ -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()

View File

@ -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__',

View File

@ -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'),
),
]

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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']

View File

@ -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

View File

@ -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

View File

@ -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'),

View File

@ -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
#

View File

@ -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(

View File

@ -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">

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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" %}

View File

@ -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')

View File

@ -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',

View File

@ -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')]]

View File

@ -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'),
),
]

View File

@ -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()

View File

@ -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,
}