Merge feature and rebuild migrations

This commit is contained in:
Brian Tiemann 2024-11-01 11:26:31 -04:00
commit 6d2a0a4968
66 changed files with 1146 additions and 294 deletions

View File

@ -21,13 +21,9 @@ Designates the termination as forming either the A or Z end of the circuit.
If selected, the circuit termination will be considered "connected" even if no cable has been connected to it in NetBox. If selected, the circuit termination will be considered "connected" even if no cable has been connected to it in NetBox.
### Site ### Termination
The [site](../dcim/site.md) with which this circuit termination is associated. Once created, a cable can be connected between the circuit termination and a device interface (or similar component). The [region](../dcim/region.md), [site group](../dcim/sitegroup.md), [site](../dcim/site.md), [location](../dcim/location.md) or [provider network](./providernetwork.md) with which this circuit termination is associated. Once created, a cable can be connected between the circuit termination and a device interface (or similar component).
### Provider Network
Circuits which do not connect to a site modeled by NetBox can instead be terminated to a [provider network](./providernetwork.md) representing an unknown network operated by a [provider](./provider.md).
### Port Speed ### Port Speed

View File

@ -119,6 +119,10 @@ The "native" (untagged) VLAN for the interface. Valid only when one of the above
The tagged VLANs which are configured to be carried by this interface. Valid only for the "tagged" 802.1Q mode above. The tagged VLANs which are configured to be carried by this interface. Valid only for the "tagged" 802.1Q mode above.
### Q-in-Q SVLAN
The assigned service VLAN (for Q-in-Q/802.1ad interfaces).
### Wireless Role ### Wireless Role
Indicates the configured role for wireless interfaces (access point or station). Indicates the configured role for wireless interfaces (access point or station).

View File

@ -26,3 +26,11 @@ The user-defined functional [role](./role.md) assigned to the VLAN.
### VLAN Group or Site ### VLAN Group or Site
The [VLAN group](./vlangroup.md) or [site](../dcim/site.md) to which the VLAN is assigned. The [VLAN group](./vlangroup.md) or [site](../dcim/site.md) to which the VLAN is assigned.
### Q-in-Q Role
For VLANs which comprise a Q-in-Q/IEEE 802.1ad topology, this field indicates whether the VLAN is treated as a service or customer VLAN.
### Q-in-Q Service VLAN
The designated parent service VLAN for a Q-in-Q customer VLAN. This may be set only for Q-in-Q custom VLANs.

View File

@ -52,6 +52,10 @@ The "native" (untagged) VLAN for the interface. Valid only when one of the above
The tagged VLANs which are configured to be carried by this interface. Valid only for the "tagged" 802.1Q mode above. The tagged VLANs which are configured to be carried by this interface. Valid only for the "tagged" 802.1Q mode above.
### Q-in-Q SVLAN
The assigned service VLAN (for Q-in-Q/802.1ad interfaces).
### VRF ### VRF
The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned. The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned.

View File

@ -1,11 +1,16 @@
from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices
from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
from circuits.models import Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType from circuits.models import Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType
from dcim.api.serializers_.cables import CabledObjectSerializer from dcim.api.serializers_.cables import CabledObjectSerializer
from dcim.api.serializers_.sites import SiteSerializer from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
from netbox.api.fields import ChoiceField, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
from netbox.choices import DistanceUnitChoices from netbox.choices import DistanceUnitChoices
from tenancy.api.serializers_.tenants import TenantSerializer from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model
from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer
@ -33,16 +38,33 @@ class CircuitTypeSerializer(NetBoxModelSerializer):
class CircuitCircuitTerminationSerializer(WritableNestedSerializer): class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
site = SiteSerializer(nested=True, allow_null=True) termination_type = ContentTypeField(
queryset=ContentType.objects.filter(
model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES
),
allow_null=True,
required=False,
default=None
)
termination_id = serializers.IntegerField(allow_null=True, required=False, default=None)
termination = serializers.SerializerMethodField(read_only=True)
provider_network = ProviderNetworkSerializer(nested=True, allow_null=True) provider_network = ProviderNetworkSerializer(nested=True, allow_null=True)
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'id', 'url', 'display_url', 'display', 'termination_type', 'termination_id', 'termination', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'description', 'xconnect_id', 'description',
] ]
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_termination(self, obj):
if obj.termination_id is None:
return None
serializer = get_serializer_for_model(obj.termination)
context = {'request': self.context['request']}
return serializer(obj.termination, nested=True, context=context).data
class CircuitGroupSerializer(NetBoxModelSerializer): class CircuitGroupSerializer(NetBoxModelSerializer):
tenant = TenantSerializer(nested=True, required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True)
@ -95,18 +117,35 @@ class CircuitSerializer(NetBoxModelSerializer):
class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer): class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
circuit = CircuitSerializer(nested=True) circuit = CircuitSerializer(nested=True)
site = SiteSerializer(nested=True, required=False, allow_null=True) termination_type = ContentTypeField(
queryset=ContentType.objects.filter(
model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES
),
allow_null=True,
required=False,
default=None
)
termination_id = serializers.IntegerField(allow_null=True, required=False, default=None)
termination = serializers.SerializerMethodField(read_only=True)
provider_network = ProviderNetworkSerializer(nested=True, required=False, allow_null=True) provider_network = ProviderNetworkSerializer(nested=True, required=False, allow_null=True)
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'id', 'url', 'display_url', 'display', 'circuit', 'term_side', 'termination_type', 'termination_id', 'termination', 'provider_network', 'port_speed',
'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end',
'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied') brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_termination(self, obj):
if obj.termination_id is None:
return None
serializer = get_serializer_for_model(obj.termination)
context = {'request': self.context['request']}
return serializer(obj.termination, nested=True, context=context).data
class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_): class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
circuit = CircuitSerializer(nested=True) circuit = CircuitSerializer(nested=True)

View File

@ -0,0 +1,4 @@
# models values for ContentTypes which may be CircuitTermination termination types
CIRCUIT_TERMINATION_TERMINATION_TYPES = (
'region', 'sitegroup', 'site', 'location', 'providernetwork',
)

View File

@ -3,11 +3,11 @@ from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from dcim.filtersets import CabledObjectFilterSet from dcim.filtersets import CabledObjectFilterSet
from dcim.models import Region, Site, SiteGroup from dcim.models import Location, Region, Site, SiteGroup
from ipam.models import ASN from ipam.models import ASN
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
from .choices import * from .choices import *
from .models import * from .models import *
@ -26,37 +26,37 @@ __all__ = (
class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.all(),
field_name='circuits__terminations__site__region', field_name='circuits__terminations___region',
lookup_expr='in', lookup_expr='in',
label=_('Region (ID)'), label=_('Region (ID)'),
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.all(),
field_name='circuits__terminations__site__region', field_name='circuits__terminations___region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
label=_('Region (slug)'), label=_('Region (slug)'),
) )
site_group_id = TreeNodeMultipleChoiceFilter( site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
field_name='circuits__terminations__site__group', field_name='circuits__terminations___site_group',
lookup_expr='in', lookup_expr='in',
label=_('Site group (ID)'), label=_('Site group (ID)'),
) )
site_group = TreeNodeMultipleChoiceFilter( site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
field_name='circuits__terminations__site__group', field_name='circuits__terminations___site_group',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
label=_('Site group (slug)'), label=_('Site group (slug)'),
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
field_name='circuits__terminations__site', field_name='circuits__terminations___site',
queryset=Site.objects.all(), queryset=Site.objects.all(),
label=_('Site'), label=_('Site'),
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='circuits__terminations__site__slug', field_name='circuits__terminations___site__slug',
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
label=_('Site (slug)'), label=_('Site (slug)'),
@ -173,7 +173,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
label=_('Provider account (account)'), label=_('Provider account (account)'),
) )
provider_network_id = django_filters.ModelMultipleChoiceFilter( provider_network_id = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__provider_network', field_name='terminations___provider_network',
queryset=ProviderNetwork.objects.all(), queryset=ProviderNetwork.objects.all(),
label=_('Provider network (ID)'), label=_('Provider network (ID)'),
) )
@ -193,37 +193,37 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.all(),
field_name='terminations__site__region', field_name='terminations___region',
lookup_expr='in', lookup_expr='in',
label=_('Region (ID)'), label=_('Region (ID)'),
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.all(),
field_name='terminations__site__region', field_name='terminations___region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
label=_('Region (slug)'), label=_('Region (slug)'),
) )
site_group_id = TreeNodeMultipleChoiceFilter( site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
field_name='terminations__site__group', field_name='terminations___site_group',
lookup_expr='in', lookup_expr='in',
label=_('Site group (ID)'), label=_('Site group (ID)'),
) )
site_group = TreeNodeMultipleChoiceFilter( site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
field_name='terminations__site__group', field_name='terminations___site_group',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
label=_('Site group (slug)'), label=_('Site group (slug)'),
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__site', field_name='terminations___site',
queryset=Site.objects.all(), queryset=Site.objects.all(),
label=_('Site (ID)'), label=_('Site (ID)'),
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__site__slug', field_name='terminations___site__slug',
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
label=_('Site (slug)'), label=_('Site (slug)'),
@ -263,18 +263,60 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
queryset=Circuit.objects.all(), queryset=Circuit.objects.all(),
label=_('Circuit'), label=_('Circuit'),
) )
termination_type = ContentTypeFilter()
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
to_field_name='slug',
label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='_site_group',
lookup_expr='in',
label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='_site_group',
lookup_expr='in',
to_field_name='slug',
label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.all(),
field_name='_site',
label=_('Site (ID)'), label=_('Site (ID)'),
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug', field_name='_site__slug',
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
label=_('Site (slug)'), label=_('Site (slug)'),
) )
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
label=_('Location (ID)'),
)
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
to_field_name='slug',
label=_('Location (slug)'),
)
provider_network_id = django_filters.ModelMultipleChoiceFilter( provider_network_id = django_filters.ModelMultipleChoiceFilter(
queryset=ProviderNetwork.objects.all(), queryset=ProviderNetwork.objects.all(),
field_name='_provider_network',
label=_('ProviderNetwork (ID)'), label=_('ProviderNetwork (ID)'),
) )
provider_id = django_filters.ModelMultipleChoiceFilter( provider_id = django_filters.ModelMultipleChoiceFilter(
@ -292,7 +334,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = ( fields = (
'id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'mark_connected', 'id', 'termination_id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'mark_connected',
'pp_info', 'cable_end', 'pp_info', 'cable_end',
) )

View File

@ -1,17 +1,23 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices
from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
from circuits.models import * from circuits.models import *
from dcim.models import Site from dcim.models import Site
from ipam.models import ASN from ipam.models import ASN
from netbox.choices import DistanceUnitChoices from netbox.choices import DistanceUnitChoices
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import add_blank_choice from utilities.forms import add_blank_choice, get_field_value
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import (
from utilities.forms.rendering import FieldSet, TabbedGroups ColorField, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions )
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, HTMXSelect, NumberWithOptions
from utilities.templatetags.builtins.filters import bettertitle
__all__ = ( __all__ = (
'CircuitBulkEditForm', 'CircuitBulkEditForm',
@ -197,15 +203,18 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
max_length=200, max_length=200,
required=False required=False
) )
site = DynamicModelChoiceField( termination_type = ContentTypeChoiceField(
label=_('Site'), queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
queryset=Site.objects.all(), widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
required=False required=False,
label=_('Termination type')
) )
provider_network = DynamicModelChoiceField( termination = DynamicModelChoiceField(
label=_('Provider Network'), label=_('Termination'),
queryset=ProviderNetwork.objects.all(), queryset=Site.objects.none(), # Initial queryset
required=False required=False,
disabled=True,
selector=True
) )
port_speed = forms.IntegerField( port_speed = forms.IntegerField(
required=False, required=False,
@ -225,15 +234,26 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
fieldsets = ( fieldsets = (
FieldSet( FieldSet(
'description', 'description',
TabbedGroups( 'termination_type', 'termination',
FieldSet('site', name=_('Site')),
FieldSet('provider_network', name=_('Provider Network')),
),
'mark_connected', name=_('Circuit Termination') 'mark_connected', name=_('Circuit Termination')
), ),
FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')), FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')),
) )
nullable_fields = ('description') nullable_fields = ('description', 'termination')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if termination_type_id := get_field_value(self, 'termination_type'):
try:
termination_type = ContentType.objects.get(pk=termination_type_id)
model = termination_type.model_class()
self.fields['termination'].queryset = model.objects.all()
self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
self.fields['termination'].disabled = False
self.fields['termination'].label = _(bettertitle(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm): class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm):

View File

@ -1,13 +1,14 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from circuits.choices import * from circuits.choices import *
from circuits.constants import *
from circuits.models import * from circuits.models import *
from dcim.models import Site
from netbox.choices import DistanceUnitChoices from netbox.choices import DistanceUnitChoices
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
__all__ = ( __all__ = (
'CircuitImportForm', 'CircuitImportForm',
@ -127,17 +128,10 @@ class BaseCircuitTerminationImportForm(forms.ModelForm):
label=_('Termination'), label=_('Termination'),
choices=CircuitTerminationSideChoices, choices=CircuitTerminationSideChoices,
) )
site = CSVModelChoiceField( termination_type = CSVContentTypeField(
label=_('Site'), queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
queryset=Site.objects.all(), required=False,
to_field_name='name', label=_('Termination type (app & model)')
required=False
)
provider_network = CSVModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(),
to_field_name='name',
required=False
) )
@ -145,9 +139,12 @@ class CircuitTerminationImportRelatedForm(BaseCircuitTerminationImportForm):
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'circuit', 'term_side', 'termination_type', 'termination_id', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info', 'description' 'pp_info', 'description'
] ]
labels = {
'termination_id': _('Termination ID'),
}
class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm): class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm):
@ -155,9 +152,12 @@ class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTermination
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'circuit', 'term_side', 'termination_type', 'termination_id', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info', 'description', 'tags' 'pp_info', 'description', 'tags'
] ]
labels = {
'termination_id': _('Termination ID'),
}
class CircuitGroupImportForm(NetBoxModelImportForm): class CircuitGroupImportForm(NetBoxModelImportForm):

View File

@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, CircuitTerminationSideChoices from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, CircuitTerminationSideChoices
from circuits.models import * from circuits.models import *
from dcim.models import Region, Site, SiteGroup from dcim.models import Location, Region, Site, SiteGroup
from ipam.models import ASN from ipam.models import ASN
from netbox.choices import DistanceUnitChoices from netbox.choices import DistanceUnitChoices
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
@ -207,18 +207,29 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('circuit_id', 'term_side', name=_('Circuit')), FieldSet('circuit_id', 'term_side', name=_('Circuit')),
FieldSet('provider_id', 'provider_network_id', name=_('Provider')), FieldSet('provider_id', name=_('Provider')),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Termination')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
) )
site_id = DynamicModelMultipleChoiceField( site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False, required=False,
query_params={
'region_id': '$region_id',
'site_group_id': '$site_group_id',
},
label=_('Site') label=_('Site')
) )
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
label=_('Location')
)
circuit_id = DynamicModelMultipleChoiceField( circuit_id = DynamicModelMultipleChoiceField(
queryset=Circuit.objects.all(), queryset=Circuit.objects.all(),
required=False, required=False,

View File

@ -1,14 +1,19 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from circuits.choices import CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices from circuits.choices import CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices
from circuits.constants import *
from circuits.models import * from circuits.models import *
from dcim.models import Site from dcim.models import Site
from ipam.models import ASN from ipam.models import ASN
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField from utilities.forms import get_field_value
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.fields import CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
from utilities.forms.widgets import DatePicker, NumberWithOptions from utilities.forms.rendering import FieldSet, InlineFields
from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
from utilities.templatetags.builtins.filters import bettertitle
__all__ = ( __all__ = (
'CircuitForm', 'CircuitForm',
@ -144,26 +149,24 @@ class CircuitTerminationForm(NetBoxModelForm):
queryset=Circuit.objects.all(), queryset=Circuit.objects.all(),
selector=True selector=True
) )
site = DynamicModelChoiceField( termination_type = ContentTypeChoiceField(
label=_('Site'), queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
queryset=Site.objects.all(), widget=HTMXSelect(),
required=False, required=False,
selector=True label=_('Termination type')
) )
provider_network = DynamicModelChoiceField( termination = DynamicModelChoiceField(
label=_('Provider network'), label=_('Termination'),
queryset=ProviderNetwork.objects.all(), queryset=Site.objects.none(), # Initial queryset
required=False, required=False,
disabled=True,
selector=True selector=True
) )
fieldsets = ( fieldsets = (
FieldSet( FieldSet(
'circuit', 'term_side', 'description', 'tags', 'circuit', 'term_side', 'description', 'tags',
TabbedGroups( 'termination_type', 'termination',
FieldSet('site', name=_('Site')),
FieldSet('provider_network', name=_('Provider Network')),
),
'mark_connected', name=_('Circuit Termination') 'mark_connected', name=_('Circuit Termination')
), ),
FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')), FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')),
@ -172,7 +175,7 @@ class CircuitTerminationForm(NetBoxModelForm):
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'circuit', 'term_side', 'site', 'provider_network', 'mark_connected', 'port_speed', 'upstream_speed', 'circuit', 'term_side', 'termination_type', 'mark_connected', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'tags', 'xconnect_id', 'pp_info', 'description', 'tags',
] ]
widgets = { widgets = {
@ -184,6 +187,36 @@ class CircuitTerminationForm(NetBoxModelForm):
), ),
} }
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance is not None and instance.termination:
initial['termination'] = instance.termination
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
if termination_type_id := get_field_value(self, 'termination_type'):
try:
termination_type = ContentType.objects.get(pk=termination_type_id)
model = termination_type.model_class()
self.fields['termination'].queryset = model.objects.all()
self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
self.fields['termination'].disabled = False
self.fields['termination'].label = _(bettertitle(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
if self.instance and termination_type_id != self.instance.termination_type_id:
self.initial['termination'] = None
def clean(self):
super().clean()
# Assign the selected termination (if any)
self.instance.termination = self.cleaned_data.get('termination')
class CircuitGroupForm(TenancyForm, NetBoxModelForm): class CircuitGroupForm(TenancyForm, NetBoxModelForm):
slug = SlugField() slug = SlugField()

View File

@ -1,4 +1,4 @@
from typing import Annotated, List from typing import Annotated, List, Union
import strawberry import strawberry
import strawberry_django import strawberry_django
@ -59,13 +59,21 @@ class ProviderNetworkType(NetBoxObjectType):
@strawberry_django.type( @strawberry_django.type(
models.CircuitTermination, models.CircuitTermination,
fields='__all__', exclude=('termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'),
filters=CircuitTerminationFilter filters=CircuitTerminationFilter
) )
class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType): class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')] circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]
provider_network: Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')] | None
site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None @strawberry_django.field
def termination(self) -> Annotated[Union[
Annotated["LocationType", strawberry.lazy('dcim.graphql.types')],
Annotated["RegionType", strawberry.lazy('dcim.graphql.types')],
Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')],
], strawberry.union("CircuitTerminationTerminationType")] | None:
return self.termination
@strawberry_django.type( @strawberry_django.type(

View File

@ -0,0 +1,56 @@
import django.db.models.deletion
from django.db import migrations, models
def copy_site_assignments(apps, schema_editor):
"""
Copy site ForeignKey values to the Termination GFK.
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
Site = apps.get_model('dcim', 'Site')
CircuitTermination.objects.filter(site__isnull=False).update(
termination_type=ContentType.objects.get_for_model(Site),
termination_id=models.F('site_id')
)
ProviderNetwork = apps.get_model('circuits', 'ProviderNetwork')
CircuitTermination.objects.filter(provider_network__isnull=False).update(
termination_type=ContentType.objects.get_for_model(ProviderNetwork),
termination_id=models.F('provider_network_id')
)
class Migration(migrations.Migration):
dependencies = [
('circuits', '0046_charfield_null_choices'),
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0193_poweroutlet_color'),
]
operations = [
migrations.AddField(
model_name='circuittermination',
name='termination_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='circuittermination',
name='termination_type',
field=models.ForeignKey(
blank=True,
limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location', 'providernetwork'))),
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype',
),
),
# Copy over existing site assignments
migrations.RunPython(
code=copy_site_assignments,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,90 @@
# Generated by Django 5.0.9 on 2024-10-21 17:34
import django.db.models.deletion
from django.db import migrations, models
def populate_denormalized_fields(apps, schema_editor):
"""
Copy site ForeignKey values to the Termination GFK.
"""
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
terminations = CircuitTermination.objects.filter(site__isnull=False).prefetch_related('site')
for termination in terminations:
termination._region_id = termination.site.region_id
termination._site_group_id = termination.site.group_id
termination._site_id = termination.site_id
# Note: Location cannot be set prior to migration
CircuitTermination.objects.bulk_update(terminations, ['_region', '_site_group', '_site'])
class Migration(migrations.Migration):
dependencies = [
('circuits', '0047_circuittermination__termination'),
]
operations = [
migrations.AddField(
model_name='circuittermination',
name='_location',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='circuit_terminations',
to='dcim.location',
),
),
migrations.AddField(
model_name='circuittermination',
name='_region',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='circuit_terminations',
to='dcim.region',
),
),
migrations.AddField(
model_name='circuittermination',
name='_site',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='circuit_terminations',
to='dcim.site',
),
),
migrations.AddField(
model_name='circuittermination',
name='_site_group',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='circuit_terminations',
to='dcim.sitegroup',
),
),
# Populate denormalized FK values
migrations.RunPython(
code=populate_denormalized_fields,
reverse_code=migrations.RunPython.noop
),
# Delete the site ForeignKey
migrations.RemoveField(
model_name='circuittermination',
name='site',
),
migrations.RenameField(
model_name='circuittermination',
old_name='provider_network',
new_name='_provider_network',
),
]

View File

@ -1,9 +1,13 @@
from django.apps import apps
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from circuits.choices import * from circuits.choices import *
from circuits.constants import *
from dcim.models import CabledObjectModel from dcim.models import CabledObjectModel
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
from netbox.models.mixins import DistanceMixin from netbox.models.mixins import DistanceMixin
@ -230,22 +234,24 @@ class CircuitTermination(
term_side = models.CharField( term_side = models.CharField(
max_length=1, max_length=1,
choices=CircuitTerminationSideChoices, choices=CircuitTerminationSideChoices,
verbose_name=_('termination') verbose_name=_('termination side')
) )
site = models.ForeignKey( termination_type = models.ForeignKey(
to='dcim.Site', to='contenttypes.ContentType',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='circuit_terminations', limit_choices_to=Q(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
related_name='+',
blank=True, blank=True,
null=True null=True
) )
provider_network = models.ForeignKey( termination_id = models.PositiveBigIntegerField(
to='circuits.ProviderNetwork',
on_delete=models.PROTECT,
related_name='circuit_terminations',
blank=True, blank=True,
null=True null=True
) )
termination = GenericForeignKey(
ct_field='termination_type',
fk_field='termination_id'
)
port_speed = models.PositiveIntegerField( port_speed = models.PositiveIntegerField(
verbose_name=_('port speed (Kbps)'), verbose_name=_('port speed (Kbps)'),
blank=True, blank=True,
@ -276,6 +282,43 @@ class CircuitTermination(
blank=True blank=True
) )
# Cached associations to enable efficient filtering
_provider_network = models.ForeignKey(
to='circuits.ProviderNetwork',
on_delete=models.PROTECT,
related_name='circuit_terminations',
blank=True,
null=True
)
_location = models.ForeignKey(
to='dcim.Location',
on_delete=models.CASCADE,
related_name='circuit_terminations',
blank=True,
null=True
)
_site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
related_name='circuit_terminations',
blank=True,
null=True
)
_region = models.ForeignKey(
to='dcim.Region',
on_delete=models.CASCADE,
related_name='circuit_terminations',
blank=True,
null=True
)
_site_group = models.ForeignKey(
to='dcim.SiteGroup',
on_delete=models.CASCADE,
related_name='circuit_terminations',
blank=True,
null=True
)
class Meta: class Meta:
ordering = ['circuit', 'term_side'] ordering = ['circuit', 'term_side']
constraints = ( constraints = (
@ -297,10 +340,35 @@ class CircuitTermination(
super().clean() super().clean()
# Must define either site *or* provider network # Must define either site *or* provider network
if self.site is None and self.provider_network is None: if self.termination is None:
raise ValidationError(_("A circuit termination must attach to either a site or a provider network.")) raise ValidationError(_("A circuit termination must attach to termination."))
if self.site and self.provider_network:
raise ValidationError(_("A circuit termination cannot attach to both a site and a provider network.")) def save(self, *args, **kwargs):
# Cache objects associated with the terminating object (for filtering)
self.cache_related_objects()
super().save(*args, **kwargs)
def cache_related_objects(self):
self._provider_network = self._region = self._site_group = self._site = self._location = None
if self.termination_type:
termination_type = self.termination_type.model_class()
if termination_type == apps.get_model('dcim', 'region'):
self._region = self.termination
elif termination_type == apps.get_model('dcim', 'sitegroup'):
self._site_group = self.termination
elif termination_type == apps.get_model('dcim', 'site'):
self._region = self.termination.region
self._site_group = self.termination.group
self._site = self.termination
elif termination_type == apps.get_model('dcim', 'location'):
self._region = self.termination.site.region
self._site_group = self.termination.site.group
self._site = self.termination.site
self._location = self.termination
elif termination_type == apps.get_model('circuits', 'providernetwork'):
self._provider_network = self.termination
cache_related_objects.alters_data = True
def to_objectchange(self, action): def to_objectchange(self, action):
objectchange = super().to_objectchange(action) objectchange = super().to_objectchange(action)
@ -314,7 +382,7 @@ class CircuitTermination(
def get_peer_termination(self): def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A' peer_side = 'Z' if self.term_side == 'A' else 'A'
try: try:
return CircuitTermination.objects.prefetch_related('site').get( return CircuitTermination.objects.prefetch_related('termination').get(
circuit=self.circuit, circuit=self.circuit,
term_side=peer_side term_side=peer_side
) )

View File

@ -18,10 +18,8 @@ __all__ = (
CIRCUITTERMINATION_LINK = """ CIRCUITTERMINATION_LINK = """
{% if value.site %} {% if value.termination %}
<a href="{{ value.site.get_absolute_url }}">{{ value.site }}</a> <a href="{{ value.termination.get_absolute_url }}">{{ value.termination }}</a>
{% elif value.provider_network %}
<a href="{{ value.provider_network.get_absolute_url }}">{{ value.provider_network }}</a>
{% endif %} {% endif %}
""" """
@ -63,12 +61,12 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
verbose_name=_('Account') verbose_name=_('Account')
) )
status = columns.ChoiceFieldColumn() status = columns.ChoiceFieldColumn()
termination_a = tables.TemplateColumn( termination_a = columns.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK, template_code=CIRCUITTERMINATION_LINK,
orderable=False, orderable=False,
verbose_name=_('Side A') verbose_name=_('Side A')
) )
termination_z = tables.TemplateColumn( termination_z = columns.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK, template_code=CIRCUITTERMINATION_LINK,
orderable=False, orderable=False,
verbose_name=_('Side Z') verbose_name=_('Side Z')
@ -110,22 +108,54 @@ class CircuitTerminationTable(NetBoxTable):
linkify=True, linkify=True,
accessor='circuit.provider' accessor='circuit.provider'
) )
term_side = tables.Column(
verbose_name=_('Side')
)
termination_type = columns.ContentTypeColumn(
verbose_name=_('Termination Type'),
)
termination = tables.Column(
verbose_name=_('Termination Point'),
linkify=True
)
# Termination types
site = tables.Column( site = tables.Column(
verbose_name=_('Site'), verbose_name=_('Site'),
linkify=True linkify=True,
accessor='_site'
)
site_group = tables.Column(
verbose_name=_('Site Group'),
linkify=True,
accessor='_sitegroup'
)
region = tables.Column(
verbose_name=_('Region'),
linkify=True,
accessor='_region'
)
location = tables.Column(
verbose_name=_('Location'),
linkify=True,
accessor='_location'
) )
provider_network = tables.Column( provider_network = tables.Column(
verbose_name=_('Provider Network'), verbose_name=_('Provider Network'),
linkify=True linkify=True,
accessor='_provider_network'
) )
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = CircuitTermination model = CircuitTermination
fields = ( fields = (
'pk', 'id', 'circuit', 'provider', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'pk', 'id', 'circuit', 'provider', 'term_side', 'termination_type', 'termination', 'site_group', 'region',
'xconnect_id', 'pp_info', 'description', 'created', 'last_updated', 'actions', 'site', 'location', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
'description', 'created', 'last_updated', 'actions',
)
default_columns = (
'pk', 'id', 'circuit', 'provider', 'term_side', 'termination_type', 'termination', 'description',
) )
default_columns = ('pk', 'id', 'circuit', 'provider', 'term_side', 'description')
class CircuitGroupTable(NetBoxTable): class CircuitGroupTable(NetBoxTable):

View File

@ -181,10 +181,10 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
Circuit.objects.bulk_create(circuits) Circuit.objects.bulk_create(circuits)
circuit_terminations = ( circuit_terminations = (
CircuitTermination(circuit=circuits[0], term_side=SIDE_A, site=sites[0]), CircuitTermination(circuit=circuits[0], term_side=SIDE_A, termination=sites[0]),
CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, provider_network=provider_networks[0]), CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, termination=provider_networks[0]),
CircuitTermination(circuit=circuits[1], term_side=SIDE_A, site=sites[1]), CircuitTermination(circuit=circuits[1], term_side=SIDE_A, termination=sites[1]),
CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, provider_network=provider_networks[1]), CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, termination=provider_networks[1]),
) )
CircuitTermination.objects.bulk_create(circuit_terminations) CircuitTermination.objects.bulk_create(circuit_terminations)
@ -192,13 +192,15 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
{ {
'circuit': circuits[2].pk, 'circuit': circuits[2].pk,
'term_side': SIDE_A, 'term_side': SIDE_A,
'site': sites[0].pk, 'termination_type': 'dcim.site',
'termination_id': sites[0].pk,
'port_speed': 200000, 'port_speed': 200000,
}, },
{ {
'circuit': circuits[2].pk, 'circuit': circuits[2].pk,
'term_side': SIDE_Z, 'term_side': SIDE_Z,
'provider_network': provider_networks[0].pk, 'termination_type': 'circuits.providernetwork',
'termination_id': provider_networks[0].pk,
'port_speed': 200000, 'port_speed': 200000,
}, },
] ]

View File

@ -70,10 +70,12 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
Circuit.objects.bulk_create(circuits) Circuit.objects.bulk_create(circuits)
CircuitTermination.objects.bulk_create(( circuit_terminations = (
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'),
CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'), CircuitTermination(circuit=circuits[1], termination=sites[0], term_side='A'),
)) )
for ct in circuit_terminations:
ct.save()
def test_q(self): def test_q(self):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
@ -233,14 +235,15 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
Circuit.objects.bulk_create(circuits) Circuit.objects.bulk_create(circuits)
circuit_terminations = (( circuit_terminations = ((
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'),
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'), CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A'),
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'), CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A'),
CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'),
CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'),
CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), CircuitTermination(circuit=circuits[5], termination=provider_networks[2], term_side='A'),
)) ))
CircuitTermination.objects.bulk_create(circuit_terminations) for ct in circuit_terminations:
ct.save()
def test_q(self): def test_q(self):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
@ -384,18 +387,19 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
Circuit.objects.bulk_create(circuits) Circuit.objects.bulk_create(circuits)
circuit_terminations = (( circuit_terminations = ((
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC', description='foobar1'), CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC', description='foobar1'),
CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF', description='foobar2'), CircuitTermination(circuit=circuits[0], termination=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF', description='foobar2'),
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'), CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'),
CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'), CircuitTermination(circuit=circuits[1], termination=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'),
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'), CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'),
CircuitTermination(circuit=circuits[2], site=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'), CircuitTermination(circuit=circuits[2], termination=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'),
CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'),
CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'),
CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), CircuitTermination(circuit=circuits[5], termination=provider_networks[2], term_side='A'),
CircuitTermination(circuit=circuits[6], provider_network=provider_networks[0], term_side='A', mark_connected=True), CircuitTermination(circuit=circuits[6], termination=provider_networks[0], term_side='A', mark_connected=True),
)) ))
CircuitTermination.objects.bulk_create(circuit_terminations) for ct in circuit_terminations:
ct.save()
Cable(a_terminations=[circuit_terminations[0]], b_terminations=[circuit_terminations[1]]).save() Cable(a_terminations=[circuit_terminations[0]], b_terminations=[circuit_terminations[1]]).save()

View File

@ -1,5 +1,6 @@
import datetime import datetime
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings from django.test import override_settings
from django.urls import reverse from django.urls import reverse
@ -190,27 +191,31 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_import_objects_with_terminations(self): def test_bulk_import_objects_with_terminations(self):
json_data = """ site = Site.objects.first()
json_data = f"""
[ [
{ {{
"cid": "Circuit 7", "cid": "Circuit 7",
"provider": "Provider 1", "provider": "Provider 1",
"type": "Circuit Type 1", "type": "Circuit Type 1",
"status": "active", "status": "active",
"description": "Testing Import", "description": "Testing Import",
"terminations": [ "terminations": [
{ {{
"term_side": "A", "term_side": "A",
"site": "Site 1" "termination_type": "dcim.site",
}, "termination_id": "{site.pk}"
{ }},
{{
"term_side": "Z", "term_side": "Z",
"site": "Site 1" "termination_type": "dcim.site",
} "termination_id": "{site.pk}"
}}
] ]
} }}
] ]
""" """
initial_count = self._get_queryset().count() initial_count = self._get_queryset().count()
data = { data = {
'data': json_data, 'data': json_data,
@ -336,7 +341,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): class TestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CircuitTermination model = CircuitTermination
@classmethod @classmethod
@ -359,24 +364,27 @@ class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Circuit.objects.bulk_create(circuits) Circuit.objects.bulk_create(circuits)
circuit_terminations = ( circuit_terminations = (
CircuitTermination(circuit=circuits[0], term_side='A', site=sites[0]), CircuitTermination(circuit=circuits[0], term_side='A', termination=sites[0]),
CircuitTermination(circuit=circuits[0], term_side='Z', site=sites[1]), CircuitTermination(circuit=circuits[0], term_side='Z', termination=sites[1]),
CircuitTermination(circuit=circuits[1], term_side='A', site=sites[0]), CircuitTermination(circuit=circuits[1], term_side='A', termination=sites[0]),
CircuitTermination(circuit=circuits[1], term_side='Z', site=sites[1]), CircuitTermination(circuit=circuits[1], term_side='Z', termination=sites[1]),
) )
CircuitTermination.objects.bulk_create(circuit_terminations) for ct in circuit_terminations:
ct.save()
cls.form_data = { cls.form_data = {
'circuit': circuits[2].pk, 'circuit': circuits[2].pk,
'term_side': 'A', 'term_side': 'A',
'site': sites[2].pk, 'termination_type': ContentType.objects.get_for_model(Site).pk,
'termination': sites[2].pk,
'description': 'New description', 'description': 'New description',
} }
site = sites[0].pk
cls.csv_data = ( cls.csv_data = (
"circuit,term_side,site,description", "circuit,term_side,termination_type,termination_id,description",
"Circuit 3,A,Site 1,Foo", f"Circuit 3,A,dcim.site,{site},Foo",
"Circuit 3,Z,Site 1,Bar", f"Circuit 3,Z,dcim.site,{site},Bar",
) )
cls.csv_update_data = ( cls.csv_update_data = (

View File

@ -158,7 +158,7 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
instance, instance,
extra=( extra=(
( (
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance), Circuit.objects.restrict(request.user, 'view').filter(terminations___provider_network=instance),
'provider_network_id', 'provider_network_id',
), ),
), ),
@ -257,8 +257,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
class CircuitListView(generic.ObjectListView): class CircuitListView(generic.ObjectListView):
queryset = Circuit.objects.prefetch_related( queryset = Circuit.objects.prefetch_related(
'tenant__group', 'termination_a__site', 'termination_z__site', 'tenant__group', 'termination_a__termination', 'termination_z__termination',
'termination_a__provider_network', 'termination_z__provider_network',
) )
filterset = filtersets.CircuitFilterSet filterset = filtersets.CircuitFilterSet
filterset_form = forms.CircuitFilterForm filterset_form = forms.CircuitFilterForm
@ -298,8 +297,7 @@ class CircuitBulkImportView(generic.BulkImportView):
class CircuitBulkEditView(generic.BulkEditView): class CircuitBulkEditView(generic.BulkEditView):
queryset = Circuit.objects.prefetch_related( queryset = Circuit.objects.prefetch_related(
'termination_a__site', 'termination_z__site', 'tenant__group', 'termination_a__termination', 'termination_z__termination',
'termination_a__provider_network', 'termination_z__provider_network',
) )
filterset = filtersets.CircuitFilterSet filterset = filtersets.CircuitFilterSet
table = tables.CircuitTable table = tables.CircuitTable
@ -308,8 +306,7 @@ class CircuitBulkEditView(generic.BulkEditView):
class CircuitBulkDeleteView(generic.BulkDeleteView): class CircuitBulkDeleteView(generic.BulkDeleteView):
queryset = Circuit.objects.prefetch_related( queryset = Circuit.objects.prefetch_related(
'termination_a__site', 'termination_z__site', 'tenant__group', 'termination_a__termination', 'termination_z__termination',
'termination_a__provider_network', 'termination_z__provider_network',
) )
filterset = filtersets.CircuitFilterSet filterset = filtersets.CircuitFilterSet
table = tables.CircuitTable table = tables.CircuitTable

View File

@ -205,6 +205,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
required=False, required=False,
many=True many=True
) )
qinq_svlan = VLANSerializer(nested=True, required=False, allow_null=True)
vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True) vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True)
vrf = VRFSerializer(nested=True, required=False, allow_null=True) vrf = VRFSerializer(nested=True, required=False, allow_null=True)
l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
@ -233,10 +234,10 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description',
'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width',
'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected',
'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', 'vlan_translation_policy' 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')

View File

@ -1258,11 +1258,13 @@ class InterfaceModeChoices(ChoiceSet):
MODE_ACCESS = 'access' MODE_ACCESS = 'access'
MODE_TAGGED = 'tagged' MODE_TAGGED = 'tagged'
MODE_TAGGED_ALL = 'tagged-all' MODE_TAGGED_ALL = 'tagged-all'
MODE_Q_IN_Q = 'q-in-q'
CHOICES = ( CHOICES = (
(MODE_ACCESS, _('Access')), (MODE_ACCESS, _('Access')),
(MODE_TAGGED, _('Tagged')), (MODE_TAGGED, _('Tagged')),
(MODE_TAGGED_ALL, _('Tagged (All)')), (MODE_TAGGED_ALL, _('Tagged (All)')),
(MODE_Q_IN_Q, _('Q-in-Q (802.1ad)')),
) )

View File

@ -1796,7 +1796,8 @@ class CommonInterfaceFilterSet(django_filters.FilterSet):
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(untagged_vlan_id=value) | Q(untagged_vlan_id=value) |
Q(tagged_vlans=value) Q(tagged_vlans=value) |
Q(qinq_svlan=value)
) )
def filter_vlan(self, queryset, name, value): def filter_vlan(self, queryset, name, value):
@ -1805,7 +1806,8 @@ class CommonInterfaceFilterSet(django_filters.FilterSet):
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(untagged_vlan_id__vid=value) | Q(untagged_vlan_id__vid=value) |
Q(tagged_vlans__vid=value) Q(tagged_vlans__vid=value) |
Q(qinq_svlan__vid=value)
) )

View File

@ -37,6 +37,8 @@ class InterfaceCommonForm(forms.Form):
del self.fields['vlan_group'] del self.fields['vlan_group']
del self.fields['untagged_vlan'] del self.fields['untagged_vlan']
del self.fields['tagged_vlans'] del self.fields['tagged_vlans']
if interface_mode != InterfaceModeChoices.MODE_Q_IN_Q:
del self.fields['qinq_svlan']
def clean(self): def clean(self):
super().clean() super().clean()

View File

@ -8,6 +8,7 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.choices import VLANQinQRoleChoices
from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
@ -1489,6 +1490,16 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
'available_on_device': '$device', 'available_on_device': '$device',
} }
) )
qinq_svlan = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
label=_('Q-in-Q Service VLAN'),
query_params={
'group_id': '$vlan_group',
'available_on_device': '$device',
'qinq_role': VLANQinQRoleChoices.ROLE_SERVICE,
}
)
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
@ -1513,7 +1524,10 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')), FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')), FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy', name=_('802.1Q Switching')), FieldSet(
'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
name=_('802.1Q Switching')
),
FieldSet( FieldSet(
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
name=_('Wireless') name=_('Wireless')
@ -1526,7 +1540,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', 'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'vlan_translation_policy', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'tags',
] ]
widgets = { widgets = {
'speed': NumberWithOptions( 'speed': NumberWithOptions(

View File

@ -394,6 +394,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')] | None wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')] | None
untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]] vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
@ -472,6 +473,10 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi
devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
children: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]] children: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.field
def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
return self.circuit_terminations.all()
@strawberry_django.type( @strawberry_django.type(
models.Manufacturer, models.Manufacturer,
@ -715,6 +720,10 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None: def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None:
return self.parent return self.parent
@strawberry_django.field
def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
return self.circuit_terminations.all()
@strawberry_django.type( @strawberry_django.type(
models.Site, models.Site,
@ -736,10 +745,13 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje
devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]] locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]] asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]]
circuit_terminations: List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]
clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]] clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]
vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
@strawberry_django.field
def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
return self.circuit_terminations.all()
@strawberry_django.type( @strawberry_django.type(
models.SiteGroup, models.SiteGroup,
@ -756,6 +768,10 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None: def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None:
return self.parent return self.parent
@strawberry_django.field
def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
return self.circuit_terminations.all()
@strawberry_django.type( @strawberry_django.type(
models.VirtualChassis, models.VirtualChassis,

View File

@ -0,0 +1,28 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0195_interface_vlan_translation_policy'),
('ipam', '0075_vlan_qinq'),
]
operations = [
migrations.AddField(
model_name='interface',
name='qinq_svlan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss_svlan', to='ipam.vlan'),
),
migrations.AlterField(
model_name='interface',
name='tagged_vlans',
field=models.ManyToManyField(blank=True, related_name='%(class)ss_as_tagged', to='ipam.vlan'),
),
migrations.AlterField(
model_name='interface',
name='untagged_vlan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss_as_untagged', to='ipam.vlan'),
),
]

View File

@ -1,5 +1,3 @@
# Generated by Django 5.1.2 on 2024-10-29 15:05
import dcim.fields import dcim.fields
import django.db.models.deletion import django.db.models.deletion
import taggit.managers import taggit.managers
@ -42,9 +40,9 @@ def populate_macaddress_objects(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0195_interface_vlan_translation_policy'), ('dcim', '0196_qinq_svlan'),
('extras', '0122_charfield_null_choices'), ('extras', '0122_charfield_null_choices'),
('virtualization', '0043_rename_mac_address_vminterface__mac_address'), ('virtualization', '0044_rename_mac_address_vminterface__mac_address'),
] ]
operations = [ operations = [

View File

@ -1,12 +1,10 @@
# Generated by Django 5.1.2 on 2024-10-29 15:41
from django.db import migrations from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0196_rename_mac_address_interface__mac_address_macaddress'), ('dcim', '0197_rename_mac_address_interface__mac_address_macaddress'),
] ]
operations = [ operations = [

View File

@ -344,7 +344,7 @@ class CableTermination(ChangeLoggedModel):
) )
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None: if self.termination_type.model == 'circuittermination' and self.termination._provider_network is not None:
raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled.")) raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled."))
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@ -690,19 +690,19 @@ class CablePath(models.Model):
).first() ).first()
if circuit_termination is None: if circuit_termination is None:
break break
elif circuit_termination.provider_network: elif circuit_termination._provider_network:
# Circuit terminates to a ProviderNetwork # Circuit terminates to a ProviderNetwork
path.extend([ path.extend([
[object_to_path_node(circuit_termination)], [object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination.provider_network)], [object_to_path_node(circuit_termination._provider_network)],
]) ])
is_complete = True is_complete = True
break break
elif circuit_termination.site and not circuit_termination.cable: elif circuit_termination.termination and not circuit_termination.cable:
# Circuit terminates to a Site # Circuit terminates to a Region/Site/etc.
path.extend([ path.extend([
[object_to_path_node(circuit_termination)], [object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination.site)], [object_to_path_node(circuit_termination.termination)],
]) ])
break break

View File

@ -543,17 +543,48 @@ class BaseInterface(models.Model):
blank=True, blank=True,
verbose_name=_('bridge interface') verbose_name=_('bridge interface')
) )
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
related_name='%(class)ss_as_untagged',
null=True,
blank=True,
verbose_name=_('untagged VLAN')
)
tagged_vlans = models.ManyToManyField(
to='ipam.VLAN',
related_name='%(class)ss_as_tagged',
blank=True,
verbose_name=_('tagged VLANs')
)
qinq_svlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
related_name='%(class)ss_svlan',
null=True,
blank=True,
verbose_name=_('Q-in-Q SVLAN')
)
vlan_translation_policy = models.ForeignKey( vlan_translation_policy = models.ForeignKey(
to='ipam.VLANTranslationPolicy', to='ipam.VLANTranslationPolicy',
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, null=True,
blank=True, blank=True,
verbose_name=_('VLAN Translation Policy'), verbose_name=_('VLAN Translation Policy')
) )
class Meta: class Meta:
abstract = True abstract = True
def clean(self):
super().clean()
# SVLAN can be defined only for Q-in-Q interfaces
if self.qinq_svlan and self.mode != InterfaceModeChoices.MODE_Q_IN_Q:
raise ValidationError({
'qinq_svlan': _("Only Q-in-Q interfaces may specify a service VLAN.")
})
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Remove untagged VLAN assignment for non-802.1Q interfaces # Remove untagged VLAN assignment for non-802.1Q interfaces
@ -699,20 +730,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
blank=True, blank=True,
verbose_name=_('wireless LANs') verbose_name=_('wireless LANs')
) )
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
related_name='interfaces_as_untagged',
null=True,
blank=True,
verbose_name=_('untagged VLAN')
)
tagged_vlans = models.ManyToManyField(
to='ipam.VLAN',
related_name='interfaces_as_tagged',
blank=True,
verbose_name=_('tagged VLANs')
)
vrf = models.ForeignKey( vrf = models.ForeignKey(
to='ipam.VRF', to='ipam.VRF',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,

View File

@ -586,6 +586,10 @@ class BaseInterfaceTable(NetBoxTable):
orderable=False, orderable=False,
verbose_name=_('Tagged VLANs') verbose_name=_('Tagged VLANs')
) )
qinq_svlan = tables.Column(
verbose_name=_('Q-in-Q SVLAN'),
linkify=True
)
def value_ip_addresses(self, value): def value_ip_addresses(self, value):
return ",".join([str(obj.address) for obj in value.all()]) return ",".join([str(obj.address) for obj in value.all()])
@ -673,11 +677,11 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
model = models.Interface model = models.Interface
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf',
'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
'last_updated', 'inventory_items', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@ -714,7 +718,7 @@ class DeviceInterfaceTable(InterfaceTable):
'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'actions',
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',

View File

@ -7,6 +7,7 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.choices import VLANQinQRoleChoices
from ipam.models import ASN, RIR, VLAN, VRF from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer from netbox.api.serializers import GenericObjectSerializer
from tenancy.models import Tenant from tenancy.models import Tenant
@ -1618,6 +1619,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
VLAN(name='VLAN 1', vid=1), VLAN(name='VLAN 1', vid=1),
VLAN(name='VLAN 2', vid=2), VLAN(name='VLAN 2', vid=2),
VLAN(name='VLAN 3', vid=3), VLAN(name='VLAN 3', vid=3),
VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
) )
VLAN.objects.bulk_create(vlans) VLAN.objects.bulk_create(vlans)
@ -1676,18 +1678,22 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'vdcs': [vdcs[1].pk], 'vdcs': [vdcs[1].pk],
'name': 'Interface 7', 'name': 'Interface 7',
'type': InterfaceTypeChoices.TYPE_80211A, 'type': InterfaceTypeChoices.TYPE_80211A,
'mode': InterfaceModeChoices.MODE_Q_IN_Q,
'tx_power': 10, 'tx_power': 10,
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
'rf_channel': WirelessChannelChoices.CHANNEL_5G_32, 'rf_channel': WirelessChannelChoices.CHANNEL_5G_32,
'qinq_svlan': vlans[3].pk,
}, },
{ {
'device': device.pk, 'device': device.pk,
'vdcs': [vdcs[1].pk], 'vdcs': [vdcs[1].pk],
'name': 'Interface 8', 'name': 'Interface 8',
'type': InterfaceTypeChoices.TYPE_80211A, 'type': InterfaceTypeChoices.TYPE_80211A,
'mode': InterfaceModeChoices.MODE_Q_IN_Q,
'tx_power': 10, 'tx_power': 10,
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
'rf_channel': "", 'rf_channel': "",
'qinq_svlan': vlans[3].pk,
}, },
] ]

View File

@ -1167,7 +1167,7 @@ class CablePathTestCase(TestCase):
[IF1] --C1-- [CT1] [IF1] --C1-- [CT1]
""" """
interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface1 = Interface.objects.create(device=self.device, name='Interface 1')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
# Create cable 1 # Create cable 1
cable1 = Cable( cable1 = Cable(
@ -1198,7 +1198,7 @@ class CablePathTestCase(TestCase):
""" """
interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2') interface2 = Interface.objects.create(device=self.device, name='Interface 2')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
# Create cable 1 # Create cable 1
cable1 = Cable( cable1 = Cable(
@ -1214,7 +1214,7 @@ class CablePathTestCase(TestCase):
) )
# Create CT2 # Create CT2
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='Z')
# Check for partial path to site # Check for partial path to site
self.assertPathExists( self.assertPathExists(
@ -1266,7 +1266,7 @@ class CablePathTestCase(TestCase):
interface2 = Interface.objects.create(device=self.device, name='Interface 2') interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3') interface3 = Interface.objects.create(device=self.device, name='Interface 3')
interface4 = Interface.objects.create(device=self.device, name='Interface 4') interface4 = Interface.objects.create(device=self.device, name='Interface 4')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
# Create cable 1 # Create cable 1
cable1 = Cable( cable1 = Cable(
@ -1282,7 +1282,7 @@ class CablePathTestCase(TestCase):
) )
# Create CT2 # Create CT2
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='Z')
# Check for partial path to site # Check for partial path to site
self.assertPathExists( self.assertPathExists(
@ -1335,8 +1335,8 @@ class CablePathTestCase(TestCase):
""" """
interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface1 = Interface.objects.create(device=self.device, name='Interface 1')
site2 = Site.objects.create(name='Site 2', slug='site-2') site2 = Site.objects.create(name='Site 2', slug='site-2')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site2, term_side='Z') circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=site2, term_side='Z')
# Create cable 1 # Create cable 1
cable1 = Cable( cable1 = Cable(
@ -1365,8 +1365,8 @@ class CablePathTestCase(TestCase):
""" """
interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface1 = Interface.objects.create(device=self.device, name='Interface 1')
providernetwork = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.circuit.provider) providernetwork = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.circuit.provider)
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, provider_network=providernetwork, term_side='Z') circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=providernetwork, term_side='Z')
# Create cable 1 # Create cable 1
cable1 = Cable( cable1 = Cable(
@ -1413,8 +1413,8 @@ class CablePathTestCase(TestCase):
frontport2_2 = FrontPort.objects.create( frontport2_2 = FrontPort.objects.create(
device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2 device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
) )
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='Z')
# Create cables # Create cables
cable1 = Cable( cable1 = Cable(
@ -1499,10 +1499,10 @@ class CablePathTestCase(TestCase):
interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2') interface2 = Interface.objects.create(device=self.device, name='Interface 2')
circuit2 = Circuit.objects.create(provider=self.circuit.provider, type=self.circuit.type, cid='Circuit 2') circuit2 = Circuit.objects.create(provider=self.circuit.provider, type=self.circuit.type, cid='Circuit 2')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='Z')
circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='A') circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, termination=self.site, term_side='A')
circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='Z') circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, termination=self.site, term_side='Z')
# Create cables # Create cables
cable1 = Cable( cable1 = Cable(

View File

@ -4,7 +4,8 @@ from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.choices import * from dcim.choices import *
from dcim.filtersets import * from dcim.filtersets import *
from dcim.models import * from dcim.models import *
from ipam.models import ASN, IPAddress, RIR, VLANTranslationPolicy, VRF from ipam.choices import VLANQinQRoleChoices
from ipam.models import ASN, IPAddress, RIR, VLAN, VLANTranslationPolicy, VRF
from netbox.choices import ColorChoices, WeightUnitChoices from netbox.choices import ColorChoices, WeightUnitChoices
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from users.models import User from users.models import User
@ -3527,7 +3528,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = Interface.objects.all() queryset = Interface.objects.all()
filterset = InterfaceFilterSet filterset = InterfaceFilterSet
ignore_fields = ('tagged_vlans', 'untagged_vlan', 'vdcs') ignore_fields = ('tagged_vlans', 'untagged_vlan', 'qinq_svlan', 'vdcs')
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -3683,6 +3684,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
) )
MACAddress.objects.bulk_create(mac_addresses) MACAddress.objects.bulk_create(mac_addresses)
vlans = (
VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
VLAN(name='SVLAN 2', vid=1002, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
VLAN(name='SVLAN 3', vid=1003, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
)
VLAN.objects.bulk_create(vlans)
vlan_translation_policies = ( vlan_translation_policies = (
VLANTranslationPolicy(name='Policy 1'), VLANTranslationPolicy(name='Policy 1'),
VLANTranslationPolicy(name='Policy 2'), VLANTranslationPolicy(name='Policy 2'),
@ -3764,6 +3772,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
duplex='full', duplex='full',
poe_mode=InterfacePoEModeChoices.MODE_PD, poe_mode=InterfacePoEModeChoices.MODE_PD,
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT, poe_type=InterfacePoETypeChoices.TYPE_2_8023AT,
mode=InterfaceModeChoices.MODE_Q_IN_Q,
qinq_svlan=vlans[0],
vlan_translation_policy=vlan_translation_policies[1], vlan_translation_policy=vlan_translation_policies[1],
), ),
Interface( Interface(
@ -3773,7 +3783,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
type=InterfaceTypeChoices.TYPE_OTHER, type=InterfaceTypeChoices.TYPE_OTHER,
enabled=True, enabled=True,
mgmt_only=True, mgmt_only=True,
tx_power=40 tx_power=40,
mode=InterfaceModeChoices.MODE_Q_IN_Q,
qinq_svlan=vlans[1]
), ),
Interface( Interface(
device=devices[4], device=devices[4],
@ -3782,7 +3794,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
type=InterfaceTypeChoices.TYPE_OTHER, type=InterfaceTypeChoices.TYPE_OTHER,
enabled=False, enabled=False,
mgmt_only=False, mgmt_only=False,
tx_power=40 tx_power=40,
mode=InterfaceModeChoices.MODE_Q_IN_Q,
qinq_svlan=vlans[2]
), ),
Interface( Interface(
device=devices[4], device=devices[4],
@ -4042,6 +4056,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
params = {'vdc_identifier': vdc.values_list('identifier', flat=True)} params = {'vdc_identifier': vdc.values_list('identifier', flat=True)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_vlan(self):
vlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first()
params = {'vlan_id': vlan.pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'vlan': vlan.vid}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_vlan_translation_policy(self): def test_vlan_translation_policy(self):
vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2] vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2]
params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]} params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]}
@ -5150,7 +5171,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
provider = Provider.objects.create(name='Provider 1', slug='provider-1') provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuit = Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type) circuit = Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type)
circuit_termination = CircuitTermination.objects.create(circuit=circuit, term_side='A', site=sites[0]) circuit_termination = CircuitTermination.objects.create(circuit=circuit, term_side='A', termination=sites[0])
# Cables # Cables
cables = ( cables = (
@ -5323,9 +5344,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_site(self): def test_site(self):
site = Site.objects.all()[:2] site = Site.objects.all()[:2]
params = {'site_id': [site[0].pk, site[1].pk]} params = {'site_id': [site[0].pk, site[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 12) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 11)
params = {'site': [site[0].slug, site[1].slug]} params = {'site': [site[0].slug, site[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 12) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 11)
def test_tenant(self): def test_tenant(self):
tenant = Tenant.objects.all()[:2] tenant = Tenant.objects.all()[:2]

View File

@ -762,9 +762,9 @@ class CableTestCase(TestCase):
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1') circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2') circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2')
CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A') CircuitTermination.objects.create(circuit=circuit1, termination=site, term_side='A')
CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z') CircuitTermination.objects.create(circuit=circuit1, termination=site, term_side='Z')
CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A') CircuitTermination.objects.create(circuit=circuit2, termination=provider_network, term_side='A')
def test_cable_creation(self): def test_cable_creation(self):
""" """

View File

@ -242,6 +242,10 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
extra=( extra=(
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'), (Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'), (Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
(
Circuit.objects.restrict(request.user, 'view').filter(terminations___region=instance).distinct(),
'region_id'
),
), ),
), ),
} }
@ -324,6 +328,10 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
extra=( extra=(
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'), (Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'), (Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(
Circuit.objects.restrict(request.user, 'view').filter(terminations___site_group=instance).distinct(),
'site_group_id'
),
), ),
), ),
} }
@ -404,8 +412,10 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
scope_id=instance.pk scope_id=instance.pk
), 'site'), ), 'site'),
(ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'), (ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), (
'site_id'), Circuit.objects.restrict(request.user, 'view').filter(terminations___site=instance).distinct(),
'site_id'
),
), ),
), ),
} }
@ -475,7 +485,17 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
locations = instance.get_descendants(include_self=True) locations = instance.get_descendants(include_self=True)
return { return {
'related_models': self.get_related_models(request, locations, [CableTermination]), 'related_models': self.get_related_models(
request,
locations,
[CableTermination],
(
(
Circuit.objects.restrict(request.user, 'view').filter(terminations___location=instance).distinct(),
'location_id'
),
),
),
} }

View File

@ -6,6 +6,7 @@ from ..field_serializers import IPAddressField
__all__ = ( __all__ = (
'NestedIPAddressSerializer', 'NestedIPAddressSerializer',
'NestedVLANSerializer',
) )
@ -16,3 +17,10 @@ class NestedIPAddressSerializer(WritableNestedSerializer):
class Meta: class Meta:
model = models.IPAddress model = models.IPAddress
fields = ['id', 'url', 'display_url', 'display', 'family', 'address'] fields = ['id', 'url', 'display_url', 'display', 'family', 'address']
class NestedVLANSerializer(WritableNestedSerializer):
class Meta:
model = models.VLAN
fields = ['id', 'url', 'display', 'vid', 'name', 'description']

View File

@ -11,6 +11,7 @@ from netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
from .nested import NestedVLANSerializer
from .roles import RoleSerializer from .roles import RoleSerializer
__all__ = ( __all__ = (
@ -64,6 +65,8 @@ class VLANSerializer(NetBoxModelSerializer):
tenant = TenantSerializer(nested=True, required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True)
status = ChoiceField(choices=VLANStatusChoices, required=False) status = ChoiceField(choices=VLANStatusChoices, required=False)
role = RoleSerializer(nested=True, required=False, allow_null=True) role = RoleSerializer(nested=True, required=False, allow_null=True)
qinq_role = ChoiceField(choices=VLANQinQRoleChoices, required=False)
qinq_svlan = NestedVLANSerializer(required=False, allow_null=True, default=None)
l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
# Related object counts # Related object counts
@ -73,8 +76,8 @@ class VLANSerializer(NetBoxModelSerializer):
model = VLAN model = VLAN
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'id', 'url', 'display_url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role',
'description', 'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'description', 'qinq_role', 'qinq_svlan', 'comments', 'l2vpn_termination', 'tags', 'custom_fields',
'prefix_count', 'created', 'last_updated', 'prefix_count',
] ]
brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description') brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description')

View File

@ -157,6 +157,17 @@ class VLANStatusChoices(ChoiceSet):
] ]
class VLANQinQRoleChoices(ChoiceSet):
ROLE_SERVICE = 's-vlan'
ROLE_CUSTOMER = 'c-vlan'
CHOICES = [
(ROLE_SERVICE, _('Service'), 'blue'),
(ROLE_CUSTOMER, _('Customer'), 'orange'),
]
# #
# Services # Services
# #

View File

@ -1041,6 +1041,17 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.all(),
method='get_for_virtualmachine' method='get_for_virtualmachine'
) )
qinq_role = django_filters.MultipleChoiceFilter(
choices=VLANQinQRoleChoices
)
qinq_svlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all(),
label=_('Q-in-Q SVLAN (ID)'),
)
qinq_svlan_vid = MultiValueNumberFilter(
field_name='qinq_svlan__vid',
label=_('Q-in-Q SVLAN number (1-4094)'),
)
l2vpn_id = django_filters.ModelMultipleChoiceFilter( l2vpn_id = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn', field_name='l2vpn_terminations__l2vpn',
queryset=L2VPN.objects.all(), queryset=L2VPN.objects.all(),

View File

@ -527,15 +527,29 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
max_length=200, max_length=200,
required=False required=False
) )
qinq_role = forms.ChoiceField(
label=_('Q-in-Q role'),
choices=add_blank_choice(VLANQinQRoleChoices),
required=False
)
qinq_svlan = DynamicModelChoiceField(
label=_('Q-in-Q SVLAN'),
queryset=VLAN.objects.all(),
required=False,
query_params={
'qinq_role': VLANQinQRoleChoices.ROLE_SERVICE,
}
)
comments = CommentField() comments = CommentField()
model = VLAN model = VLAN
fieldsets = ( fieldsets = (
FieldSet('status', 'role', 'tenant', 'description'), FieldSet('status', 'role', 'tenant', 'description'),
FieldSet('qinq_role', 'qinq_svlan', name=_('Q-in-Q')),
FieldSet('region', 'site_group', 'site', 'group', name=_('Site & Group')), FieldSet('region', 'site_group', 'site', 'group', name=_('Site & Group')),
) )
nullable_fields = ( nullable_fields = (
'site', 'group', 'tenant', 'role', 'description', 'comments', 'site', 'group', 'tenant', 'role', 'description', 'qinq_role', 'qinq_svlan', 'comments',
) )

View File

@ -461,10 +461,26 @@ class VLANImportForm(NetBoxModelImportForm):
to_field_name='name', to_field_name='name',
help_text=_('Functional role') help_text=_('Functional role')
) )
qinq_role = CSVChoiceField(
label=_('Q-in-Q role'),
choices=VLANStatusChoices,
required=False,
help_text=_('Operational status')
)
qinq_svlan = CSVModelChoiceField(
label=_('Q-in-Q SVLAN'),
queryset=VLAN.objects.all(),
required=False,
to_field_name='vid',
help_text=_("Service VLAN (for Q-in-Q/802.1ad customer VLANs)")
)
class Meta: class Meta:
model = VLAN model = VLAN
fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments', 'tags') fields = (
'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'qinq_role', 'qinq_svlan',
'comments', 'tags',
)
class VLANTranslationPolicyImportForm(NetBoxModelImportForm): class VLANTranslationPolicyImportForm(NetBoxModelImportForm):

View File

@ -506,6 +506,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('group_id', 'status', 'role_id', 'vid', 'l2vpn_id', name=_('Attributes')), FieldSet('group_id', 'status', 'role_id', 'vid', 'l2vpn_id', name=_('Attributes')),
FieldSet('qinq_role', 'qinq_svlan_id', name=_('Q-in-Q/802.1ad')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
) )
selector_fields = ('filter_id', 'q', 'site_id') selector_fields = ('filter_id', 'q', 'site_id')
@ -552,6 +553,17 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False, required=False,
label=_('VLAN ID') label=_('VLAN ID')
) )
qinq_role = forms.MultipleChoiceField(
label=_('Q-in-Q role'),
choices=VLANQinQRoleChoices,
required=False
)
qinq_svlan_id = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
null_option='None',
label=_('Q-in-Q SVLAN')
)
l2vpn_id = DynamicModelMultipleChoiceField( l2vpn_id = DynamicModelMultipleChoiceField(
queryset=L2VPN.objects.all(), queryset=L2VPN.objects.all(),
required=False, required=False,

View File

@ -683,13 +683,21 @@ class VLANForm(TenancyForm, NetBoxModelForm):
queryset=Role.objects.all(), queryset=Role.objects.all(),
required=False required=False
) )
qinq_svlan = DynamicModelChoiceField(
label=_('Q-in-Q SVLAN'),
queryset=VLAN.objects.all(),
required=False,
query_params={
'qinq_role': VLANQinQRoleChoices.ROLE_SERVICE,
}
)
comments = CommentField() comments = CommentField()
class Meta: class Meta:
model = VLAN model = VLAN
fields = [ fields = [
'site', 'group', 'vid', 'name', 'status', 'role', 'tenant_group', 'tenant', 'description', 'comments', 'site', 'group', 'vid', 'name', 'status', 'role', 'tenant_group', 'tenant', 'qinq_role', 'qinq_svlan',
'tags', 'description', 'comments', 'tags',
] ]

View File

@ -236,7 +236,7 @@ class ServiceTemplateType(NetBoxObjectType):
@strawberry_django.type( @strawberry_django.type(
models.VLAN, models.VLAN,
fields='__all__', exclude=('qinq_svlan',),
filters=VLANFilter filters=VLANFilter
) )
class VLANType(NetBoxObjectType): class VLANType(NetBoxObjectType):
@ -252,6 +252,10 @@ class VLANType(NetBoxObjectType):
interfaces_as_tagged: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] interfaces_as_tagged: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
vminterfaces_as_tagged: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]] vminterfaces_as_tagged: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
@strawberry_django.field
def qinq_svlan(self) -> Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None:
return self.qinq_svlan
@strawberry_django.type( @strawberry_django.type(
models.VLANGroup, models.VLANGroup,

View File

@ -0,0 +1,30 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0074_vlantranslationpolicy_vlantranslationrule'),
]
operations = [
migrations.AddField(
model_name='vlan',
name='qinq_role',
field=models.CharField(blank=True, max_length=50, null=True),
),
migrations.AddField(
model_name='vlan',
name='qinq_svlan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='qinq_cvlans', to='ipam.vlan'),
),
migrations.AddConstraint(
model_name='vlan',
constraint=models.UniqueConstraint(fields=('qinq_svlan', 'vid'), name='ipam_vlan_unique_qinq_svlan_vid'),
),
migrations.AddConstraint(
model_name='vlan',
constraint=models.UniqueConstraint(fields=('qinq_svlan', 'name'), name='ipam_vlan_unique_qinq_svlan_name'),
),
]

View File

@ -204,6 +204,21 @@ class VLAN(PrimaryModel):
null=True, null=True,
help_text=_("The primary function of this VLAN") help_text=_("The primary function of this VLAN")
) )
qinq_svlan = models.ForeignKey(
to='self',
on_delete=models.PROTECT,
related_name='qinq_cvlans',
blank=True,
null=True
)
qinq_role = models.CharField(
verbose_name=_('Q-in-Q role'),
max_length=50,
choices=VLANQinQRoleChoices,
blank=True,
null=True,
help_text=_("Customer/service VLAN designation (for Q-in-Q/IEEE 802.1ad)")
)
l2vpn_terminations = GenericRelation( l2vpn_terminations = GenericRelation(
to='vpn.L2VPNTermination', to='vpn.L2VPNTermination',
content_type_field='assigned_object_type', content_type_field='assigned_object_type',
@ -214,7 +229,7 @@ class VLAN(PrimaryModel):
objects = VLANQuerySet.as_manager() objects = VLANQuerySet.as_manager()
clone_fields = [ clone_fields = [
'site', 'group', 'tenant', 'status', 'role', 'description', 'site', 'group', 'tenant', 'status', 'role', 'description', 'qinq_role', 'qinq_svlan',
] ]
class Meta: class Meta:
@ -228,6 +243,14 @@ class VLAN(PrimaryModel):
fields=('group', 'name'), fields=('group', 'name'),
name='%(app_label)s_%(class)s_unique_group_name' name='%(app_label)s_%(class)s_unique_group_name'
), ),
models.UniqueConstraint(
fields=('qinq_svlan', 'vid'),
name='%(app_label)s_%(class)s_unique_qinq_svlan_vid'
),
models.UniqueConstraint(
fields=('qinq_svlan', 'name'),
name='%(app_label)s_%(class)s_unique_qinq_svlan_name'
),
) )
verbose_name = _('VLAN') verbose_name = _('VLAN')
verbose_name_plural = _('VLANs') verbose_name_plural = _('VLANs')
@ -255,9 +278,24 @@ class VLAN(PrimaryModel):
).format(ranges=ranges_to_string(self.group.vid_ranges), group=self.group) ).format(ranges=ranges_to_string(self.group.vid_ranges), group=self.group)
}) })
# Only Q-in-Q customer VLANs may be assigned to a service VLAN
if self.qinq_svlan and self.qinq_role != VLANQinQRoleChoices.ROLE_CUSTOMER:
raise ValidationError({
'qinq_svlan': _("Only Q-in-Q customer VLANs maybe assigned to a service VLAN.")
})
# A Q-in-Q customer VLAN must be assigned to a service VLAN
if self.qinq_role == VLANQinQRoleChoices.ROLE_CUSTOMER and not self.qinq_svlan:
raise ValidationError({
'qinq_role': _("A Q-in-Q customer VLAN must be assigned to a service VLAN.")
})
def get_status_color(self): def get_status_color(self):
return VLANStatusChoices.colors.get(self.status) return VLANStatusChoices.colors.get(self.status)
def get_qinq_role_color(self):
return VLANQinQRoleChoices.colors.get(self.qinq_role)
def get_interfaces(self): def get_interfaces(self):
# Return all device interfaces assigned to this VLAN # Return all device interfaces assigned to this VLAN
return Interface.objects.filter( return Interface.objects.filter(

View File

@ -132,6 +132,13 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Role'), verbose_name=_('Role'),
linkify=True linkify=True
) )
qinq_role = columns.ChoiceFieldColumn(
verbose_name=_('Q-in-Q role')
)
qinq_svlan = tables.Column(
verbose_name=_('Q-in-Q SVLAN'),
linkify=True
)
l2vpn = tables.Column( l2vpn = tables.Column(
accessor=tables.A('l2vpn_termination__l2vpn'), accessor=tables.A('l2vpn_termination__l2vpn'),
linkify=True, linkify=True,
@ -154,7 +161,7 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable):
model = VLAN model = VLAN
fields = ( fields = (
'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'tenant_group', 'status', 'role', 'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'tenant_group', 'status', 'role',
'description', 'comments', 'tags', 'l2vpn', 'created', 'last_updated', 'qinq_role', 'qinq_svlan', 'description', 'comments', 'tags', 'l2vpn', 'created', 'last_updated',
) )
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description') default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
row_attrs = { row_attrs = {

View File

@ -980,6 +980,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
VLAN(name='VLAN 1', vid=1, group=vlan_groups[0]), VLAN(name='VLAN 1', vid=1, group=vlan_groups[0]),
VLAN(name='VLAN 2', vid=2, group=vlan_groups[0]), VLAN(name='VLAN 2', vid=2, group=vlan_groups[0]),
VLAN(name='VLAN 3', vid=3, group=vlan_groups[0]), VLAN(name='VLAN 3', vid=3, group=vlan_groups[0]),
VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
) )
VLAN.objects.bulk_create(vlans) VLAN.objects.bulk_create(vlans)
@ -999,6 +1000,12 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
'name': 'VLAN 6', 'name': 'VLAN 6',
'group': vlan_groups[1].pk, 'group': vlan_groups[1].pk,
}, },
{
'vid': 2001,
'name': 'CVLAN 1',
'qinq_role': VLANQinQRoleChoices.ROLE_CUSTOMER,
'qinq_svlan': vlans[3].pk,
},
] ]
def test_delete_vlan_with_prefix(self): def test_delete_vlan_with_prefix(self):

View File

@ -1630,6 +1630,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 4', slug='site-4', region=regions[0], group=site_groups[0]), Site(name='Site 4', slug='site-4', region=regions[0], group=site_groups[0]),
Site(name='Site 5', slug='site-5', region=regions[1], group=site_groups[1]), Site(name='Site 5', slug='site-5', region=regions[1], group=site_groups[1]),
Site(name='Site 6', slug='site-6', region=regions[2], group=site_groups[2]), Site(name='Site 6', slug='site-6', region=regions[2], group=site_groups[2]),
Site(name='Site 7', slug='site-7'),
) )
Site.objects.bulk_create(sites) Site.objects.bulk_create(sites)
@ -1784,9 +1785,21 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
# Create one globally available VLAN # Create one globally available VLAN
VLAN(vid=1000, name='Global VLAN'), VLAN(vid=1000, name='Global VLAN'),
# Create some Q-in-Q service VLANs
VLAN(vid=2001, name='SVLAN 1', site=sites[6], qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
VLAN(vid=2002, name='SVLAN 2', site=sites[6], qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
VLAN(vid=2003, name='SVLAN 3', site=sites[6], qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
) )
VLAN.objects.bulk_create(vlans) VLAN.objects.bulk_create(vlans)
# Create Q-in-Q customer VLANs
VLAN.objects.bulk_create([
VLAN(vid=3001, name='CVLAN 1', site=sites[6], qinq_svlan=vlans[29], qinq_role=VLANQinQRoleChoices.ROLE_CUSTOMER),
VLAN(vid=3002, name='CVLAN 2', site=sites[6], qinq_svlan=vlans[30], qinq_role=VLANQinQRoleChoices.ROLE_CUSTOMER),
VLAN(vid=3003, name='CVLAN 3', site=sites[6], qinq_svlan=vlans[31], qinq_role=VLANQinQRoleChoices.ROLE_CUSTOMER),
])
# Assign VLANs to device interfaces # Assign VLANs to device interfaces
interfaces[0].untagged_vlan = vlans[0] interfaces[0].untagged_vlan = vlans[0]
interfaces[0].tagged_vlans.add(vlans[1]) interfaces[0].tagged_vlans.add(vlans[1])
@ -1897,6 +1910,17 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'vminterface_id': vminterface_id} params = {'vminterface_id': vminterface_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_qinq_role(self):
params = {'qinq_role': [VLANQinQRoleChoices.ROLE_SERVICE, VLANQinQRoleChoices.ROLE_CUSTOMER]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_qinq_svlan(self):
vlans = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE)[:2]
params = {'qinq_svlan_id': [vlans[0].pk, vlans[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'qinq_svlan_vid': [vlans[0].vid, vlans[1].vid]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VLANTranslationPolicyTestCase(TestCase, ChangeLoggedFilterSetTests): class VLANTranslationPolicyTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VLANTranslationPolicy.objects.all() queryset = VLANTranslationPolicy.objects.all()

View File

@ -586,3 +586,24 @@ class TestVLANGroup(TestCase):
vlangroup.vid_ranges = string_to_ranges('2-2') vlangroup.vid_ranges = string_to_ranges('2-2')
vlangroup.full_clean() vlangroup.full_clean()
vlangroup.save() vlangroup.save()
class TestVLAN(TestCase):
@classmethod
def setUpTestData(cls):
VLAN.objects.bulk_create((
VLAN(name='VLAN 1', vid=1, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
))
def test_qinq_role(self):
svlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first()
vlan = VLAN(
name='VLAN X',
vid=999,
qinq_role=VLANQinQRoleChoices.ROLE_SERVICE,
qinq_svlan=svlan
)
with self.assertRaises(ValidationError):
vlan.full_clean()

View File

@ -1,18 +1,19 @@
{% load helpers %} {% load helpers %}
{% load i18n %} {% load i18n %}
{% if termination.site %}
<tr> <tr>
<th scope="row">{% trans "Site" %}</th> <th scope="row">{% trans "Termination point" %}</th>
<td> {% if termination.termination %}
{% if termination.site.region %} <td>
{{ termination.site.region|linkify }} / {{ termination.termination|linkify }}
{% endif %} <div class="fs-5 text-muted">{% trans termination.termination_type.name|bettertitle %}</div>
{{ termination.site|linkify }} </td>
</td> {% else %}
<td>{{ ''|placeholder }}</td>
{% endif %}
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Termination" %}</th> <th scope="row">{% trans "Connection" %}</th>
<td> <td>
{% if termination.mark_connected %} {% if termination.mark_connected %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
@ -57,12 +58,6 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% else %}
<tr>
<th scope="row">{% trans "Provider Network" %}</th>
<td>{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}</td>
</tr>
{% endif %}
<tr> <tr>
<th scope="row">{% trans "Speed" %}</th> <th scope="row">{% trans "Speed" %}</th>
<td> <td>

View File

@ -62,6 +62,22 @@
<th scope="row">{% trans "Description" %}</th> <th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Q-in-Q Role" %}</th>
<td>
{% if object.qinq_role %}
{% badge object.get_qinq_role_display bg_color=object.get_qinq_role_color %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
{% if object.qinq_role == 'c-vlan' %}
<tr>
<th scope="row">{% trans "Q-in-Q SVLAN" %}</th>
<td>{{ object.qinq_svlan|linkify|placeholder }}</td>
</tr>
{% endif %}
<tr> <tr>
<th scope="row">{% trans "L2VPN" %}</th> <th scope="row">{% trans "L2VPN" %}</th>
<td>{{ object.l2vpn_termination.l2vpn|linkify|placeholder }}</td> <td>{{ object.l2vpn_termination.l2vpn|linkify|placeholder }}</td>
@ -92,6 +108,21 @@
</h2> </h2>
{% htmx_table 'ipam:prefix_list' vlan_id=object.pk %} {% htmx_table 'ipam:prefix_list' vlan_id=object.pk %}
</div> </div>
{% if object.qinq_role == 's-vlan' %}
<div class="card">
<h2 class="card-header">
{% trans "Customer VLANs" %}
{% if perms.ipam.add_vlan %}
<div class="card-actions">
<a href="{% url 'ipam:vlan_add' %}?qinq_role=c-vlan&qinq_svlan={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a VLAN" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'ipam:vlan_list' qinq_svlan_id=object.pk %}
</div>
{% endif %}
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -17,6 +17,14 @@
{% render_field form.tags %} {% render_field form.tags %}
</div> </div>
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Q-in-Q (802.1ad)" %}</h2>
</div>
{% render_field form.qinq_role %}
{% render_field form.qinq_svlan %}
</div>
<div class="field-group my-5"> <div class="field-group my-5">
<div class="row"> <div class="row">
<h2 class="col-9 offset-3">{% trans "Tenancy" %}</h2> <h2 class="col-9 offset-3">{% trans "Tenancy" %}</h2>

View File

@ -89,6 +89,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
required=False, required=False,
many=True many=True
) )
qinq_svlan = VLANSerializer(nested=True, required=False, allow_null=True)
vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True) vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True)
vrf = VRFSerializer(nested=True, required=False, allow_null=True) vrf = VRFSerializer(nested=True, required=False, allow_null=True)
l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
@ -105,9 +106,9 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
model = VMInterface model = VMInterface
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu',
'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'l2vpn_termination', 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', 'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated',
'vlan_translation_policy', 'count_ipaddresses', 'count_fhrp_groups',
] ]
brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description') brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')

View File

@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.forms.common import InterfaceCommonForm from dcim.forms.common import InterfaceCommonForm
from dcim.models import Device, DeviceRole, MACAddress, Platform, Rack, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, MACAddress, Platform, Rack, Region, Site, SiteGroup
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.choices import VLANQinQRoleChoices
from ipam.models import IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF from ipam.models import IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
@ -338,6 +339,16 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
'available_on_virtualmachine': '$virtual_machine', 'available_on_virtualmachine': '$virtual_machine',
} }
) )
qinq_svlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
label=_('Q-in-Q Service VLAN'),
query_params={
'group_id': '$vlan_group',
'available_on_virtualmachine': '$virtual_machine',
'qinq_role': VLANQinQRoleChoices.ROLE_SERVICE,
}
)
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
@ -354,17 +365,20 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
FieldSet('vrf', 'mac_address', name=_('Addressing')), FieldSet('vrf', 'mac_address', name=_('Addressing')),
FieldSet('mtu', 'enabled', name=_('Operation')), FieldSet('mtu', 'enabled', name=_('Operation')),
FieldSet('parent', 'bridge', name=_('Related Interfaces')), FieldSet('parent', 'bridge', name=_('Related Interfaces')),
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy', name=_('802.1Q Switching')), FieldSet(
'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
name=_('802.1Q Switching')
),
) )
class Meta: class Meta:
model = VMInterface model = VMInterface
fields = [ fields = [
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'vlan_translation_policy', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'tags',
] ]
labels = { labels = {
'mode': '802.1Q Mode', 'mode': _('802.1Q Mode'),
} }
widgets = { widgets = {
'mode': HTMXSelect(), 'mode': HTMXSelect(),

View File

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

View File

@ -1,5 +1,3 @@
# Generated by Django 5.0.9 on 2024-10-11 19:45
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View File

@ -0,0 +1,28 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0075_vlan_qinq'),
('virtualization', '0042_vminterface_vlan_translation_policy'),
]
operations = [
migrations.AddField(
model_name='vminterface',
name='qinq_svlan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss_svlan', to='ipam.vlan'),
),
migrations.AlterField(
model_name='vminterface',
name='tagged_vlans',
field=models.ManyToManyField(blank=True, related_name='%(class)ss_as_tagged', to='ipam.vlan'),
),
migrations.AlterField(
model_name='vminterface',
name='untagged_vlan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss_as_untagged', to='ipam.vlan'),
),
]

View File

@ -1,12 +1,10 @@
# Generated by Django 5.1.2 on 2024-10-29 15:05
from django.db import migrations from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('virtualization', '0042_vminterface_vlan_translation_policy'), ('virtualization', '0043_qinq_svlan'),
] ]
operations = [ operations = [

View File

@ -1,12 +1,10 @@
# Generated by Django 5.1.2 on 2024-10-29 15:41
from django.db import migrations from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('virtualization', '0043_rename_mac_address_vminterface__mac_address'), ('virtualization', '0044_rename_mac_address_vminterface__mac_address'),
] ]
operations = [ operations = [

View File

@ -322,20 +322,6 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
max_length=100, max_length=100,
blank=True blank=True
) )
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
related_name='vminterfaces_as_untagged',
null=True,
blank=True,
verbose_name=_('untagged VLAN')
)
tagged_vlans = models.ManyToManyField(
to='ipam.VLAN',
related_name='vminterfaces_as_tagged',
blank=True,
verbose_name=_('tagged VLANs')
)
ip_addresses = GenericRelation( ip_addresses = GenericRelation(
to='ipam.IPAddress', to='ipam.IPAddress',
content_type_field='assigned_object_type', content_type_field='assigned_object_type',

View File

@ -151,8 +151,8 @@ class VMInterfaceTable(BaseInterfaceTable):
model = VMInterface model = VMInterface
fields = ( fields = (
'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
'last_updated', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
@ -175,7 +175,8 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
model = VMInterface model = VMInterface
fields = ( fields = (
'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags',
'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
'actions',
) )
default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses') default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses')
row_attrs = { row_attrs = {

View File

@ -4,6 +4,7 @@ from rest_framework import status
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.models import Site from dcim.models import Site
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.choices import VLANQinQRoleChoices
from ipam.models import VLAN, VRF from ipam.models import VLAN, VRF
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
from virtualization.choices import * from virtualization.choices import *
@ -270,6 +271,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
VLAN(name='VLAN 1', vid=1), VLAN(name='VLAN 1', vid=1),
VLAN(name='VLAN 2', vid=2), VLAN(name='VLAN 2', vid=2),
VLAN(name='VLAN 3', vid=3), VLAN(name='VLAN 3', vid=3),
VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
) )
VLAN.objects.bulk_create(vlans) VLAN.objects.bulk_create(vlans)
@ -307,6 +309,12 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
'untagged_vlan': vlans[2].pk, 'untagged_vlan': vlans[2].pk,
'vrf': vrfs[2].pk, 'vrf': vrfs[2].pk,
}, },
{
'virtual_machine': virtualmachine.pk,
'name': 'Interface 7',
'mode': InterfaceModeChoices.MODE_Q_IN_Q,
'qinq_svlan': vlans[3].pk,
},
] ]
def test_bulk_delete_child_interfaces(self): def test_bulk_delete_child_interfaces(self):

View File

@ -1,7 +1,9 @@
from django.test import TestCase from django.test import TestCase
from dcim.choices import InterfaceModeChoices
from dcim.models import Device, DeviceRole, MACAddress, Platform, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, MACAddress, Platform, Region, Site, SiteGroup
from ipam.models import IPAddress, VLANTranslationPolicy, VRF from ipam.choices import VLANQinQRoleChoices
from ipam.models import IPAddress, VLAN, VLANTranslationPolicy, VRF
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
from virtualization.choices import * from virtualization.choices import *
@ -539,7 +541,7 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VMInterface.objects.all() queryset = VMInterface.objects.all()
filterset = VMInterfaceFilterSet filterset = VMInterfaceFilterSet
ignore_fields = ('tagged_vlans', 'untagged_vlan',) ignore_fields = ('tagged_vlans', 'untagged_vlan', 'qinq_svlan')
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -565,6 +567,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
VRF.objects.bulk_create(vrfs) VRF.objects.bulk_create(vrfs)
vlans = (
VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
VLAN(name='SVLAN 2', vid=1002, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
VLAN(name='SVLAN 3', vid=1003, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
)
VLAN.objects.bulk_create(vlans)
vms = ( vms = (
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]), VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]),
VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]), VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]),
@ -611,7 +620,9 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
enabled=False, enabled=False,
mtu=300, mtu=300,
vrf=vrfs[2], vrf=vrfs[2],
description='foobar3' description='foobar3',
mode=InterfaceModeChoices.MODE_Q_IN_Q,
qinq_svlan=vlans[0]
), ),
) )
VMInterface.objects.bulk_create(interfaces) VMInterface.objects.bulk_create(interfaces)
@ -686,6 +697,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']} params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_vlan(self):
vlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first()
params = {'vlan_id': vlan.pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'vlan': vlan.vid}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_vlan_translation_policy(self): def test_vlan_translation_policy(self):
vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2] vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2]
params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]} params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]}