Merge branch 'feature' into 4867-multiple-mac-addresses

This commit is contained in:
Brian Tiemann 2024-10-30 17:55:33 -04:00
commit 5849f8d2dc
49 changed files with 1311 additions and 119 deletions

View File

@ -141,3 +141,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

@ -55,3 +55,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, MACAddress,
)
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
@ -197,6 +197,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)
@ -226,7 +227,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,
@ -1778,6 +1778,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

@ -8,7 +8,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
@ -1499,6 +1499,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(
@ -1508,7 +1513,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')
@ -1521,7 +1526,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

@ -543,6 +543,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

@ -11,14 +11,14 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from django.views.generic import View
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
@ -2659,12 +2659,21 @@ 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_table,
'child_interfaces_table': child_interfaces_table,
'mac_addresses_table': mac_addresses_table,
'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

@ -1180,7 +1180,8 @@ class ScriptView(BaseScriptView):
data=form.cleaned_data,
request=copy_safe_request(request),
job_timeout=script.python_class.job_timeout,
commit=form.cleaned_data.pop('_commit')
commit=form.cleaned_data.pop('_commit'),
name=script.name
)
return redirect('extras:script_result', job_pk=job.pk)

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

@ -3,12 +3,13 @@ from django.db.models import Prefetch
from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
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

@ -195,6 +195,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

@ -4,7 +4,7 @@ from django.contrib import messages
from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from django.views.generic import View
from core.models import Job, ObjectChange

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 %}
@ -371,6 +375,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

@ -37,101 +37,104 @@
{% endif %}
</div>
</h2>
{% if module.scripts %}
<table class="table table-hover scripts">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Last Run" %}</th>
<th>{% trans "Status" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for script in module.scripts.all %}
{% with last_job=script.get_latest_jobs|first %}
<tr>
<td>
{% if script.is_executable %}
<a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
{% with scripts=module.scripts.all %}
{% if scripts %}
<table class="table table-hover scripts">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Last Run" %}</th>
<th>{% trans "Status" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for script in scripts %}
{% with last_job=script.get_latest_jobs|first %}
<tr>
<td>
{% if script.is_executable %}
<a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
{% else %}
<a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
<span class="text-danger">
<i class="mdi mdi-alert" title="{% trans "Script is no longer present in the source file" %}"></i>
</span>
{% endif %}
</td>
<td>{{ script.python_class.Meta.description|markdown|placeholder }}</td>
{% if last_job %}
<td>
<a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
</td>
<td>
{% badge last_job.get_status_display last_job.get_status_color %}
</td>
{% else %}
<a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
<span class="text-danger">
<i class="mdi mdi-alert" title="{% trans "Script is no longer present in the source file" %}"></i>
</span>
<td class="text-muted">{% trans "Never" %}</td>
<td>{{ ''|placeholder }}</td>
{% endif %}
</td>
<td>{{ script.python_class.Meta.description|markdown|placeholder }}</td>
<td>
{% if request.user|can_run:script and script.is_executable %}
<div class="float-end d-print-none">
<form action="{% url 'extras:script' script.pk %}" method="post">
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary btn-sm">
{% if last_job %}
<i class="mdi mdi-replay"></i> {% trans "Run Again" %}
{% else %}
<i class="mdi mdi-play"></i> {% trans "Run Script" %}
{% endif %}
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% if last_job %}
<td>
<a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
</td>
<td>
{% badge last_job.get_status_display last_job.get_status_color %}
</td>
{% else %}
<td class="text-muted">{% trans "Never" %}</td>
<td>{{ ''|placeholder }}</td>
{% for test_name, data in last_job.data.tests.items %}
<tr>
<td colspan="4" class="method">
<span class="ps-3">{{ test_name }}</span>
</td>
<td class="text-end text-nowrap script-stats">
<span class="badge text-bg-success">{{ data.success }}</span>
<span class="badge text-bg-info">{{ data.info }}</span>
<span class="badge text-bg-warning">{{ data.warning }}</span>
<span class="badge text-bg-danger">{{ data.failure }}</span>
</td>
</tr>
{% endfor %}
{% elif not last_job.data.log %}
{# legacy #}
{% for method, stats in last_job.data.items %}
<tr>
<td colspan="4" class="method">
<span class="ps-3">{{ method }}</span>
</td>
<td class="text-end text-nowrap report-stats">
<span class="badge bg-success">{{ stats.success }}</span>
<span class="badge bg-info">{{ stats.info }}</span>
<span class="badge bg-warning">{{ stats.warning }}</span>
<span class="badge bg-danger">{{ stats.failure }}</span>
</td>
</tr>
{% endfor %}
{% endif %}
<td>
{% if request.user|can_run:script and script.is_executable %}
<div class="float-end d-print-none">
<form action="{% url 'extras:script' script.pk %}" method="post">
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary btn-sm">
{% if last_job %}
<i class="mdi mdi-replay"></i> {% trans "Run Again" %}
{% else %}
<i class="mdi mdi-play"></i> {% trans "Run Script" %}
{% endif %}
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% if last_job %}
{% for test_name, data in last_job.data.tests.items %}
<tr>
<td colspan="4" class="method">
<span class="ps-3">{{ test_name }}</span>
</td>
<td class="text-end text-nowrap script-stats">
<span class="badge text-bg-success">{{ data.success }}</span>
<span class="badge text-bg-info">{{ data.info }}</span>
<span class="badge text-bg-warning">{{ data.warning }}</span>
<span class="badge text-bg-danger">{{ data.failure }}</span>
</td>
</tr>
{% endfor %}
{% elif not last_job.data.log %}
{# legacy #}
{% for method, stats in last_job.data.items %}
<tr>
<td colspan="4" class="method">
<span class="ps-3">{{ method }}</span>
</td>
<td class="text-end text-nowrap report-stats">
<span class="badge bg-success">{{ stats.success }}</span>
<span class="badge bg-info">{{ stats.info }}</span>
<span class="badge bg-warning">{{ stats.warning }}</span>
<span class="badge bg-danger">{{ stats.failure }}</span>
</td>
</tr>
{% endfor %}
{% endif %}
{% endwith %}
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body">
<div class="alert alert-warning" role="alert">
<i class="mdi mdi-alert"></i> Could not load scripts from {{ module.name }}
{% endwith %}
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body">
<div class="alert alert-warning" role="alert">
<i class="mdi mdi-alert"></i>
{% blocktrans with module=module.name %}Could not load scripts from module {{ module }}{% endblocktrans %}
</div>
</div>
</div>
{% endif %}
{% endif %}
{% endwith %}
</div>
{% empty %}
<div class="alert alert-info" role="alert">

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' %}
@ -118,6 +122,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

@ -1,6 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from netbox.views import generic
from utilities.query import count_related

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-10-28 19:20+0000\n"
"POT-Creation-Date: 2024-10-29 21:00+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"

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

@ -6,7 +6,7 @@ from django.db.models import Prefetch, Sum
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from django.views.generic.base import RedirectView
from jinja2.exceptions import TemplateError
@ -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,
}