diff --git a/docs/models/circuits/circuittermination.md b/docs/models/circuits/circuittermination.md index c6aa966d0..791863483 100644 --- a/docs/models/circuits/circuittermination.md +++ b/docs/models/circuits/circuittermination.md @@ -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. -### 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). - -### 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). +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). ### Port Speed diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 3667dabd5..869cb8510 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -142,3 +142,7 @@ The configured channel width of a wireless interface, in MHz. This is typically ### Wireless LANs The [wireless LANs](../wireless/wirelesslan.md) for which this interface carries traffic. (Valid for wireless interfaces only.) + +### VLAN Translation Policy + +The [VLAN translation policy](../ipam/vlantranslationpolicy.md) that applies to this interface (optional). diff --git a/docs/models/ipam/vlantranslationpolicy.md b/docs/models/ipam/vlantranslationpolicy.md new file mode 100644 index 000000000..59541931e --- /dev/null +++ b/docs/models/ipam/vlantranslationpolicy.md @@ -0,0 +1,26 @@ +# VLAN Translation Policies + +VLAN translation is a feature that consists of VLAN translation policies and [VLAN translation rules](./vlantranslationrule.md). Many rules can belong to a policy, and each rule defines a mapping of a local to remote VLAN ID (VID). A policy can then be assigned to an [Interface](../dcim/interface.md) or [VMInterface](../virtualization/vminterface.md), and all VLAN translation rules associated with that policy will be visible in the interface details. + +There are uniqueness constraints on `(policy, local_vid)` and on `(policy, remote_vid)` in the `VLANTranslationRule` model. Thus, you cannot have multiple rules linked to the same policy that have the same local VID or the same remote VID. A set of policies and rules might look like this: + +Policy 1: +- Rule: 100 -> 200 +- Rule: 101 -> 201 + +Policy 2: +- Rule: 100 -> 300 +- Rule: 101 -> 301 + +However this is not allowed: + +Policy 3: +- Rule: 100 -> 200 +- Rule: 100 -> 300 + + +## Fields + +### Name + +A unique human-friendly name. diff --git a/docs/models/ipam/vlantranslationrule.md b/docs/models/ipam/vlantranslationrule.md new file mode 100644 index 000000000..bffc030ed --- /dev/null +++ b/docs/models/ipam/vlantranslationrule.md @@ -0,0 +1,19 @@ +# VLAN Translation Rules + +A VLAN translation rule represents a one-to-one mapping of a local VLAN ID (VID) to a remote VID. Many rules can belong to a single policy. + +See [VLAN translation policies](./vlantranslationpolicy.md) for an overview of the VLAN Translation feature. + +## Fields + +### Policy + +The [VLAN Translation Policy](./vlantranslationpolicy.md) to which this rule belongs. + +### Local VID + +VLAN ID (1-4094) in the local network which is to be translated to a remote VID. + +### Remote VID + +VLAN ID (1-4094) in the remote network to which the local VID will be translated. diff --git a/docs/models/virtualization/vminterface.md b/docs/models/virtualization/vminterface.md index d923bdd5d..1e022b091 100644 --- a/docs/models/virtualization/vminterface.md +++ b/docs/models/virtualization/vminterface.md @@ -56,3 +56,7 @@ The tagged VLANs which are configured to be carried by this interface. Valid onl ### VRF The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned. + +### VLAN Translation Policy + +The [VLAN translation policy](../ipam/vlantranslationpolicy.md) that applies to this interface (optional). diff --git a/netbox/circuits/api/serializers_/circuits.py b/netbox/circuits/api/serializers_/circuits.py index 111fa6f87..96a686a65 100644 --- a/netbox/circuits/api/serializers_/circuits.py +++ b/netbox/circuits/api/serializers_/circuits.py @@ -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.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES from circuits.models import Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType from dcim.api.serializers_.cables import CabledObjectSerializer -from dcim.api.serializers_.sites import SiteSerializer -from netbox.api.fields import ChoiceField, RelatedObjectCountField +from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer from netbox.choices import DistanceUnitChoices from tenancy.api.serializers_.tenants import TenantSerializer +from utilities.api import get_serializer_for_model from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer @@ -33,16 +38,33 @@ class CircuitTypeSerializer(NetBoxModelSerializer): 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) class Meta: model = CircuitTermination 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', ] + @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): tenant = TenantSerializer(nested=True, required=False, allow_null=True) @@ -95,18 +117,35 @@ class CircuitSerializer(NetBoxModelSerializer): class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer): 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) class Meta: model = CircuitTermination 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', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_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_): circuit = CircuitSerializer(nested=True) diff --git a/netbox/circuits/constants.py b/netbox/circuits/constants.py new file mode 100644 index 000000000..8119bc286 --- /dev/null +++ b/netbox/circuits/constants.py @@ -0,0 +1,4 @@ +# models values for ContentTypes which may be CircuitTermination termination types +CIRCUIT_TERMINATION_TERMINATION_TYPES = ( + 'region', 'sitegroup', 'site', 'location', 'providernetwork', +) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index ebd1fe28d..4a2a972f3 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -3,11 +3,11 @@ from django.db.models import Q from django.utils.translation import gettext as _ 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 netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet -from utilities.filters import TreeNodeMultipleChoiceFilter +from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -26,37 +26,37 @@ __all__ = ( class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='circuits__terminations__site__region', + field_name='circuits__terminations___region', lookup_expr='in', label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='circuits__terminations__site__region', + field_name='circuits__terminations___region', lookup_expr='in', to_field_name='slug', label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='circuits__terminations__site__group', + field_name='circuits__terminations___site_group', lookup_expr='in', label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='circuits__terminations__site__group', + field_name='circuits__terminations___site_group', lookup_expr='in', to_field_name='slug', label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( - field_name='circuits__terminations__site', + field_name='circuits__terminations___site', queryset=Site.objects.all(), label=_('Site'), ) site = django_filters.ModelMultipleChoiceFilter( - field_name='circuits__terminations__site__slug', + field_name='circuits__terminations___site__slug', queryset=Site.objects.all(), to_field_name='slug', label=_('Site (slug)'), @@ -173,7 +173,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte label=_('Provider account (account)'), ) provider_network_id = django_filters.ModelMultipleChoiceFilter( - field_name='terminations__provider_network', + field_name='terminations___provider_network', queryset=ProviderNetwork.objects.all(), label=_('Provider network (ID)'), ) @@ -193,37 +193,37 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='terminations__site__region', + field_name='terminations___region', lookup_expr='in', label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='terminations__site__region', + field_name='terminations___region', lookup_expr='in', to_field_name='slug', label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='terminations__site__group', + field_name='terminations___site_group', lookup_expr='in', label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='terminations__site__group', + field_name='terminations___site_group', lookup_expr='in', to_field_name='slug', label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( - field_name='terminations__site', + field_name='terminations___site', queryset=Site.objects.all(), label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( - field_name='terminations__site__slug', + field_name='terminations___site__slug', queryset=Site.objects.all(), to_field_name='slug', label=_('Site (slug)'), @@ -263,18 +263,60 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): queryset=Circuit.objects.all(), 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( queryset=Site.objects.all(), + field_name='_site', label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( - field_name='site__slug', + field_name='_site__slug', queryset=Site.objects.all(), to_field_name='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( queryset=ProviderNetwork.objects.all(), + field_name='_provider_network', label=_('ProviderNetwork (ID)'), ) provider_id = django_filters.ModelMultipleChoiceFilter( @@ -292,7 +334,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): class Meta: model = CircuitTermination 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', ) diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 5cb7b5d30..e3f0b5d0c 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -1,17 +1,23 @@ 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 circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices +from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES from circuits.models import * from dcim.models import Site from ipam.models import ASN from netbox.choices import DistanceUnitChoices from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import add_blank_choice -from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField -from utilities.forms.rendering import FieldSet, TabbedGroups -from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions +from utilities.forms import add_blank_choice, get_field_value +from utilities.forms.fields import ( + ColorField, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, +) +from utilities.forms.rendering import FieldSet +from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, HTMXSelect, NumberWithOptions +from utilities.templatetags.builtins.filters import bettertitle __all__ = ( 'CircuitBulkEditForm', @@ -197,15 +203,18 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - required=False + termination_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES), + widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}), + required=False, + label=_('Termination type') ) - provider_network = DynamicModelChoiceField( - label=_('Provider Network'), - queryset=ProviderNetwork.objects.all(), - required=False + termination = DynamicModelChoiceField( + label=_('Termination'), + queryset=Site.objects.none(), # Initial queryset + required=False, + disabled=True, + selector=True ) port_speed = forms.IntegerField( required=False, @@ -225,15 +234,26 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm): fieldsets = ( FieldSet( 'description', - TabbedGroups( - FieldSet('site', name=_('Site')), - FieldSet('provider_network', name=_('Provider Network')), - ), + 'termination_type', 'termination', 'mark_connected', name=_('Circuit Termination') ), 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): diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index d5cdc00a7..eab87b1f5 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -1,13 +1,14 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from circuits.choices import * +from circuits.constants import * from circuits.models import * -from dcim.models import Site from netbox.choices import DistanceUnitChoices from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField __all__ = ( 'CircuitImportForm', @@ -127,17 +128,10 @@ class BaseCircuitTerminationImportForm(forms.ModelForm): label=_('Termination'), choices=CircuitTerminationSideChoices, ) - site = CSVModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - to_field_name='name', - required=False - ) - provider_network = CSVModelChoiceField( - label=_('Provider network'), - queryset=ProviderNetwork.objects.all(), - to_field_name='name', - required=False + termination_type = CSVContentTypeField( + queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES), + required=False, + label=_('Termination type (app & model)') ) @@ -145,9 +139,12 @@ class CircuitTerminationImportRelatedForm(BaseCircuitTerminationImportForm): class Meta: model = CircuitTermination 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' ] + labels = { + 'termination_id': _('Termination ID'), + } class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm): @@ -155,9 +152,12 @@ class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTermination class Meta: model = CircuitTermination 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' ] + labels = { + 'termination_id': _('Termination ID'), + } class CircuitGroupImportForm(NetBoxModelImportForm): diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 2e9b358e8..b585ce079 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext as _ from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, CircuitTerminationSideChoices 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 netbox.choices import DistanceUnitChoices from netbox.forms import NetBoxModelFilterSetForm @@ -207,18 +207,29 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm): fieldsets = ( FieldSet('q', 'filter_id', 'tag'), FieldSet('circuit_id', 'term_side', name=_('Circuit')), - FieldSet('provider_id', 'provider_network_id', name=_('Provider')), - FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('provider_id', name=_('Provider')), + 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( queryset=Site.objects.all(), required=False, - query_params={ - 'region_id': '$region_id', - 'site_group_id': '$site_group_id', - }, label=_('Site') ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Location') + ) circuit_id = DynamicModelMultipleChoiceField( queryset=Circuit.objects.all(), required=False, diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index e00034a10..10cd06563 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -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 circuits.choices import CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices +from circuits.constants import * from circuits.models import * from dcim.models import Site from ipam.models import ASN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm -from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField -from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups -from utilities.forms.widgets import DatePicker, NumberWithOptions +from utilities.forms import get_field_value +from utilities.forms.fields import CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField +from utilities.forms.rendering import FieldSet, InlineFields +from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions +from utilities.templatetags.builtins.filters import bettertitle __all__ = ( 'CircuitForm', @@ -144,26 +149,24 @@ class CircuitTerminationForm(NetBoxModelForm): queryset=Circuit.objects.all(), selector=True ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), + termination_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES), + widget=HTMXSelect(), required=False, - selector=True + label=_('Termination type') ) - provider_network = DynamicModelChoiceField( - label=_('Provider network'), - queryset=ProviderNetwork.objects.all(), + termination = DynamicModelChoiceField( + label=_('Termination'), + queryset=Site.objects.none(), # Initial queryset required=False, + disabled=True, selector=True ) fieldsets = ( FieldSet( 'circuit', 'term_side', 'description', 'tags', - TabbedGroups( - FieldSet('site', name=_('Site')), - FieldSet('provider_network', name=_('Provider Network')), - ), + 'termination_type', 'termination', 'mark_connected', name=_('Circuit Termination') ), FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')), @@ -172,7 +175,7 @@ class CircuitTerminationForm(NetBoxModelForm): class Meta: model = CircuitTermination 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', ] 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): slug = SlugField() diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index 45f0d065d..b52f9d18d 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List +from typing import Annotated, List, Union import strawberry import strawberry_django @@ -59,13 +59,21 @@ class ProviderNetworkType(NetBoxObjectType): @strawberry_django.type( models.CircuitTermination, - fields='__all__', + exclude=('termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'), filters=CircuitTerminationFilter ) class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType): 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( diff --git a/netbox/circuits/migrations/0047_circuittermination__termination.py b/netbox/circuits/migrations/0047_circuittermination__termination.py new file mode 100644 index 000000000..cb2c9ca07 --- /dev/null +++ b/netbox/circuits/migrations/0047_circuittermination__termination.py @@ -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 + ), + ] diff --git a/netbox/circuits/migrations/0048_circuitterminations_cached_relations.py b/netbox/circuits/migrations/0048_circuitterminations_cached_relations.py new file mode 100644 index 000000000..628579228 --- /dev/null +++ b/netbox/circuits/migrations/0048_circuitterminations_cached_relations.py @@ -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', + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 5f749550c..85b22eaa5 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -1,9 +1,13 @@ +from django.apps import apps +from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import ValidationError from django.db import models +from django.db.models import Q from django.urls import reverse from django.utils.translation import gettext_lazy as _ from circuits.choices import * +from circuits.constants import * from dcim.models import CabledObjectModel from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel from netbox.models.mixins import DistanceMixin @@ -230,22 +234,24 @@ class CircuitTermination( term_side = models.CharField( max_length=1, choices=CircuitTerminationSideChoices, - verbose_name=_('termination') + verbose_name=_('termination side') ) - site = models.ForeignKey( - to='dcim.Site', + termination_type = models.ForeignKey( + to='contenttypes.ContentType', on_delete=models.PROTECT, - related_name='circuit_terminations', + limit_choices_to=Q(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES), + related_name='+', blank=True, null=True ) - provider_network = models.ForeignKey( - to='circuits.ProviderNetwork', - on_delete=models.PROTECT, - related_name='circuit_terminations', + termination_id = models.PositiveBigIntegerField( blank=True, null=True ) + termination = GenericForeignKey( + ct_field='termination_type', + fk_field='termination_id' + ) port_speed = models.PositiveIntegerField( verbose_name=_('port speed (Kbps)'), blank=True, @@ -276,6 +282,43 @@ class CircuitTermination( 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: ordering = ['circuit', 'term_side'] constraints = ( @@ -297,10 +340,35 @@ class CircuitTermination( super().clean() # Must define either site *or* provider network - if self.site is None and self.provider_network is None: - raise ValidationError(_("A circuit termination must attach to either a site or a provider network.")) - if self.site and self.provider_network: - raise ValidationError(_("A circuit termination cannot attach to both a site and a provider network.")) + if self.termination is None: + raise ValidationError(_("A circuit termination must attach to termination.")) + + 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): objectchange = super().to_objectchange(action) @@ -314,7 +382,7 @@ class CircuitTermination( def get_peer_termination(self): peer_side = 'Z' if self.term_side == 'A' else 'A' try: - return CircuitTermination.objects.prefetch_related('site').get( + return CircuitTermination.objects.prefetch_related('termination').get( circuit=self.circuit, term_side=peer_side ) diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index e79212a14..ab9c661e6 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -18,10 +18,8 @@ __all__ = ( CIRCUITTERMINATION_LINK = """ -{% if value.site %} - {{ value.site }} -{% elif value.provider_network %} - {{ value.provider_network }} +{% if value.termination %} + {{ value.termination }} {% endif %} """ @@ -63,12 +61,12 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): verbose_name=_('Account') ) status = columns.ChoiceFieldColumn() - termination_a = tables.TemplateColumn( + termination_a = columns.TemplateColumn( template_code=CIRCUITTERMINATION_LINK, orderable=False, verbose_name=_('Side A') ) - termination_z = tables.TemplateColumn( + termination_z = columns.TemplateColumn( template_code=CIRCUITTERMINATION_LINK, orderable=False, verbose_name=_('Side Z') @@ -110,22 +108,54 @@ class CircuitTerminationTable(NetBoxTable): linkify=True, 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( 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( verbose_name=_('Provider Network'), - linkify=True + linkify=True, + accessor='_provider_network' ) class Meta(NetBoxTable.Meta): model = CircuitTermination fields = ( - 'pk', 'id', 'circuit', 'provider', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', - 'xconnect_id', 'pp_info', 'description', 'created', 'last_updated', 'actions', + 'pk', 'id', 'circuit', 'provider', 'term_side', 'termination_type', 'termination', 'site_group', 'region', + '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): diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 1edcd531b..1b2e9f3f8 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -181,10 +181,10 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): Circuit.objects.bulk_create(circuits) circuit_terminations = ( - CircuitTermination(circuit=circuits[0], term_side=SIDE_A, site=sites[0]), - CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, provider_network=provider_networks[0]), - CircuitTermination(circuit=circuits[1], term_side=SIDE_A, site=sites[1]), - CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, provider_network=provider_networks[1]), + CircuitTermination(circuit=circuits[0], term_side=SIDE_A, termination=sites[0]), + CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, termination=provider_networks[0]), + CircuitTermination(circuit=circuits[1], term_side=SIDE_A, termination=sites[1]), + CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, termination=provider_networks[1]), ) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -192,13 +192,15 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): { 'circuit': circuits[2].pk, 'term_side': SIDE_A, - 'site': sites[0].pk, + 'termination_type': 'dcim.site', + 'termination_id': sites[0].pk, 'port_speed': 200000, }, { 'circuit': circuits[2].pk, 'term_side': SIDE_Z, - 'provider_network': provider_networks[0].pk, + 'termination_type': 'circuits.providernetwork', + 'termination_id': provider_networks[0].pk, 'port_speed': 200000, }, ] diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 93958298c..0dbc7172b 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -70,10 +70,12 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): ) Circuit.objects.bulk_create(circuits) - CircuitTermination.objects.bulk_create(( - CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), - CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'), - )) + circuit_terminations = ( + CircuitTermination(circuit=circuits[0], termination=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): params = {'q': 'foobar1'} @@ -233,14 +235,15 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): Circuit.objects.bulk_create(circuits) circuit_terminations = (( - CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), - CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'), - CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'), - CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), - CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), - CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), + CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'), + CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A'), + CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A'), + CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'), + CircuitTermination(circuit=circuits[4], termination=provider_networks[1], 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): params = {'q': 'foobar1'} @@ -384,18 +387,19 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): Circuit.objects.bulk_create(circuits) 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], site=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], site=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], site=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[4], provider_network=provider_networks[1], term_side='A'), - CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), - CircuitTermination(circuit=circuits[6], provider_network=provider_networks[0], term_side='A', mark_connected=True), + 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], termination=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF', description='foobar2'), + CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'), + CircuitTermination(circuit=circuits[1], termination=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'), + CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'), + CircuitTermination(circuit=circuits[2], termination=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'), + CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'), + CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'), + CircuitTermination(circuit=circuits[5], termination=provider_networks[2], term_side='A'), + 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() diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index b06ade30b..a87e327af 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -1,5 +1,6 @@ import datetime +from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse @@ -190,27 +191,31 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) def test_bulk_import_objects_with_terminations(self): - json_data = """ + site = Site.objects.first() + json_data = f""" [ - { + {{ "cid": "Circuit 7", "provider": "Provider 1", "type": "Circuit Type 1", "status": "active", "description": "Testing Import", "terminations": [ - { + {{ "term_side": "A", - "site": "Site 1" - }, - { + "termination_type": "dcim.site", + "termination_id": "{site.pk}" + }}, + {{ "term_side": "Z", - "site": "Site 1" - } + "termination_type": "dcim.site", + "termination_id": "{site.pk}" + }} ] - } + }} ] """ + initial_count = self._get_queryset().count() data = { 'data': json_data, @@ -336,7 +341,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): +class TestCase(ViewTestCases.PrimaryObjectViewTestCase): model = CircuitTermination @classmethod @@ -359,24 +364,27 @@ class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): Circuit.objects.bulk_create(circuits) circuit_terminations = ( - CircuitTermination(circuit=circuits[0], term_side='A', site=sites[0]), - CircuitTermination(circuit=circuits[0], term_side='Z', site=sites[1]), - CircuitTermination(circuit=circuits[1], term_side='A', site=sites[0]), - CircuitTermination(circuit=circuits[1], term_side='Z', site=sites[1]), + CircuitTermination(circuit=circuits[0], term_side='A', termination=sites[0]), + CircuitTermination(circuit=circuits[0], term_side='Z', termination=sites[1]), + CircuitTermination(circuit=circuits[1], term_side='A', termination=sites[0]), + 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 = { 'circuit': circuits[2].pk, 'term_side': 'A', - 'site': sites[2].pk, + 'termination_type': ContentType.objects.get_for_model(Site).pk, + 'termination': sites[2].pk, 'description': 'New description', } + site = sites[0].pk cls.csv_data = ( - "circuit,term_side,site,description", - "Circuit 3,A,Site 1,Foo", - "Circuit 3,Z,Site 1,Bar", + "circuit,term_side,termination_type,termination_id,description", + f"Circuit 3,A,dcim.site,{site},Foo", + f"Circuit 3,Z,dcim.site,{site},Bar", ) cls.csv_update_data = ( diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 8218960c9..4059065bf 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -158,7 +158,7 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView): instance, 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', ), ), @@ -257,8 +257,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView): class CircuitListView(generic.ObjectListView): queryset = Circuit.objects.prefetch_related( - 'tenant__group', 'termination_a__site', 'termination_z__site', - 'termination_a__provider_network', 'termination_z__provider_network', + 'tenant__group', 'termination_a__termination', 'termination_z__termination', ) filterset = filtersets.CircuitFilterSet filterset_form = forms.CircuitFilterForm @@ -298,8 +297,7 @@ class CircuitBulkImportView(generic.BulkImportView): class CircuitBulkEditView(generic.BulkEditView): queryset = Circuit.objects.prefetch_related( - 'termination_a__site', 'termination_z__site', - 'termination_a__provider_network', 'termination_z__provider_network', + 'tenant__group', 'termination_a__termination', 'termination_z__termination', ) filterset = filtersets.CircuitFilterSet table = tables.CircuitTable @@ -308,8 +306,7 @@ class CircuitBulkEditView(generic.BulkEditView): class CircuitBulkDeleteView(generic.BulkDeleteView): queryset = Circuit.objects.prefetch_related( - 'termination_a__site', 'termination_z__site', - 'termination_a__provider_network', 'termination_z__provider_network', + 'tenant__group', 'termination_a__termination', 'termination_z__termination', ) filterset = filtersets.CircuitFilterSet table = tables.CircuitTable diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index e285ce349..3be19bb58 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -8,7 +8,7 @@ from dcim.models import ( ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort, RearPort, VirtualDeviceContext, ) -from ipam.api.serializers_.vlans import VLANSerializer +from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer from ipam.api.serializers_.vrfs import VRFSerializer from ipam.models import VLAN from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField @@ -196,6 +196,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect required=False, many=True ) + vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True) vrf = VRFSerializer(nested=True, required=False, allow_null=True) l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) wireless_link = NestedWirelessLinkSerializer(read_only=True, allow_null=True) @@ -225,7 +226,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', + 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', 'vlan_translation_policy' ] brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 780243553..57e75a0b2 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -8,7 +8,7 @@ from circuits.models import CircuitTermination from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate from ipam.filtersets import PrimaryIPFilterSet -from ipam.models import ASN, IPAddress, VRF +from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF from netbox.choices import ColorChoices from netbox.filtersets import ( BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, @@ -1630,6 +1630,17 @@ class CommonInterfaceFilterSet(django_filters.FilterSet): to_field_name='identifier', label=_('L2VPN'), ) + vlan_translation_policy_id = django_filters.ModelMultipleChoiceFilter( + field_name='vlan_translation_policy', + queryset=VLANTranslationPolicy.objects.all(), + label=_('VLAN Translation Policy (ID)'), + ) + vlan_translation_policy = django_filters.ModelMultipleChoiceFilter( + field_name='vlan_translation_policy__name', + queryset=VLANTranslationPolicy.objects.all(), + to_field_name='name', + label=_('VLAN Translation Policy'), + ) def filter_vlan_id(self, queryset, name, value): value = value.strip() diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 095882d13..1ab9f138b 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -7,7 +7,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.models import ConfigTemplate -from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF +from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from users.models import User @@ -1382,6 +1382,11 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): required=False, label=_('WWN') ) + vlan_translation_policy = DynamicModelChoiceField( + queryset=VLANTranslationPolicy.objects.all(), + required=False, + label=_('VLAN Translation Policy') + ) fieldsets = ( FieldSet( @@ -1391,7 +1396,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')), FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), FieldSet('poe_mode', 'poe_type', name=_('PoE')), - FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')), + FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy', name=_('802.1Q Switching')), FieldSet( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', name=_('Wireless') @@ -1404,7 +1409,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): 'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', - 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', + 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'vlan_translation_policy', ] widgets = { 'speed': NumberWithOptions( diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 507bc0dd0..203b7f74a 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -385,6 +385,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')] | None untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None + vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]] tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] @@ -465,6 +466,10 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: return self._clusters.all() + @strawberry_django.field + def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: + return self.circuit_terminations.all() + @strawberry_django.type( models.Manufacturer, @@ -712,6 +717,10 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: return self._clusters.all() + @strawberry_django.field + def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: + return self.circuit_terminations.all() + @strawberry_django.type( models.Site, @@ -734,12 +743,17 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje locations: List[Annotated["LocationType", strawberry.lazy('dcim.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')]] vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] @strawberry_django.field def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: return self._clusters.all() + @strawberry_django.field + def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: + return self.circuit_terminations.all() + @strawberry_django.type( models.SiteGroup, @@ -760,6 +774,10 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: return self._clusters.all() + @strawberry_django.field + def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: + return self.circuit_terminations.all() + @strawberry_django.type( models.VirtualChassis, diff --git a/netbox/dcim/migrations/0195_interface_vlan_translation_policy.py b/netbox/dcim/migrations/0195_interface_vlan_translation_policy.py new file mode 100644 index 000000000..42ff1205a --- /dev/null +++ b/netbox/dcim/migrations/0195_interface_vlan_translation_policy.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.9 on 2024-10-11 19:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0194_charfield_null_choices'), + ('ipam', '0074_vlantranslationpolicy_vlantranslationrule'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='vlan_translation_policy', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlantranslationpolicy'), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 9f47a63d3..744ec025c 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -344,7 +344,7 @@ class CableTermination(ChangeLoggedModel): ) # 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.")) def save(self, *args, **kwargs): @@ -690,19 +690,19 @@ class CablePath(models.Model): ).first() if circuit_termination is None: break - elif circuit_termination.provider_network: + elif circuit_termination._provider_network: # Circuit terminates to a ProviderNetwork path.extend([ [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 break - elif circuit_termination.site and not circuit_termination.cable: - # Circuit terminates to a Site + elif circuit_termination.termination and not circuit_termination.cable: + # Circuit terminates to a Region/Site/etc. path.extend([ [object_to_path_node(circuit_termination)], - [object_to_path_node(circuit_termination.site)], + [object_to_path_node(circuit_termination.termination)], ]) break diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index a5bc2f604..14f4120b5 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -547,6 +547,13 @@ class BaseInterface(models.Model): blank=True, verbose_name=_('bridge interface') ) + vlan_translation_policy = models.ForeignKey( + to='ipam.VLANTranslationPolicy', + on_delete=models.PROTECT, + null=True, + blank=True, + verbose_name=_('VLAN Translation Policy'), + ) class Meta: abstract = True diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index f7c337bdf..b504d389a 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -1167,7 +1167,7 @@ class CablePathTestCase(TestCase): [IF1] --C1-- [CT1] """ 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 cable1 = Cable( @@ -1198,7 +1198,7 @@ class CablePathTestCase(TestCase): """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') 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 cable1 = Cable( @@ -1214,7 +1214,7 @@ class CablePathTestCase(TestCase): ) # 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 self.assertPathExists( @@ -1266,7 +1266,7 @@ class CablePathTestCase(TestCase): interface2 = Interface.objects.create(device=self.device, name='Interface 2') interface3 = Interface.objects.create(device=self.device, name='Interface 3') 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 cable1 = Cable( @@ -1282,7 +1282,7 @@ class CablePathTestCase(TestCase): ) # 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 self.assertPathExists( @@ -1335,8 +1335,8 @@ class CablePathTestCase(TestCase): """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') site2 = Site.objects.create(name='Site 2', slug='site-2') - circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') - circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site2, term_side='Z') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=site2, term_side='Z') # Create cable 1 cable1 = Cable( @@ -1365,8 +1365,8 @@ class CablePathTestCase(TestCase): """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') 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') - circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, provider_network=providernetwork, term_side='Z') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=providernetwork, term_side='Z') # Create cable 1 cable1 = Cable( @@ -1413,8 +1413,8 @@ class CablePathTestCase(TestCase): frontport2_2 = FrontPort.objects.create( 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') - circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='Z') # Create cables cable1 = Cable( @@ -1499,10 +1499,10 @@ class CablePathTestCase(TestCase): interface1 = Interface.objects.create(device=self.device, name='Interface 1') 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') - circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') - circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') - circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='A') - circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='Z') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A') + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='Z') + circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, termination=self.site, term_side='A') + circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, termination=self.site, term_side='Z') # Create cables cable1 = Cable( diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index d19c51564..c5ca01db2 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4,7 +4,7 @@ from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from dcim.choices import * from dcim.filtersets import * from dcim.models import * -from ipam.models import ASN, IPAddress, RIR, VRF +from ipam.models import ASN, IPAddress, RIR, VLANTranslationPolicy, VRF from netbox.choices import ColorChoices, WeightUnitChoices from tenancy.models import Tenant, TenantGroup from users.models import User @@ -3669,6 +3669,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil ) VirtualDeviceContext.objects.bulk_create(vdcs) + vlan_translation_policies = ( + VLANTranslationPolicy(name='Policy 1'), + VLANTranslationPolicy(name='Policy 2'), + VLANTranslationPolicy(name='Policy 3'), + ) + VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies) + interfaces = ( Interface( device=devices[0], @@ -3686,7 +3693,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil speed=1000000, duplex='half', poe_mode=InterfacePoEModeChoices.MODE_PSE, - poe_type=InterfacePoETypeChoices.TYPE_1_8023AF + poe_type=InterfacePoETypeChoices.TYPE_1_8023AF, + vlan_translation_policy=vlan_translation_policies[0], ), Interface( device=devices[1], @@ -3711,7 +3719,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil speed=1000000, duplex='full', poe_mode=InterfacePoEModeChoices.MODE_PD, - poe_type=InterfacePoETypeChoices.TYPE_1_8023AF + poe_type=InterfacePoETypeChoices.TYPE_1_8023AF, + vlan_translation_policy=vlan_translation_policies[0], ), Interface( device=devices[3], @@ -3729,7 +3738,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil speed=100000, duplex='half', poe_mode=InterfacePoEModeChoices.MODE_PSE, - poe_type=InterfacePoETypeChoices.TYPE_2_8023AT + poe_type=InterfacePoETypeChoices.TYPE_2_8023AT, + vlan_translation_policy=vlan_translation_policies[1], ), Interface( device=devices[4], @@ -3742,7 +3752,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil speed=100000, duplex='full', poe_mode=InterfacePoEModeChoices.MODE_PD, - poe_type=InterfacePoETypeChoices.TYPE_2_8023AT + poe_type=InterfacePoETypeChoices.TYPE_2_8023AT, + vlan_translation_policy=vlan_translation_policies[1], ), Interface( device=devices[4], @@ -4016,6 +4027,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil params = {'vdc_identifier': vdc.values_list('identifier', flat=True)} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_vlan_translation_policy(self): + vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2] + params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'vlan_translation_policy': [vlan_translation_policies[0].name, vlan_translation_policies[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = FrontPort.objects.all() @@ -5117,7 +5135,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): provider = Provider.objects.create(name='Provider 1', slug='provider-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_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 = ( @@ -5290,9 +5308,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): def test_site(self): site = Site.objects.all()[:2] 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]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 12) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 11) def test_tenant(self): tenant = Tenant.objects.all()[:2] diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index e13f5d5ef..8d43d67ea 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -763,9 +763,9 @@ class CableTestCase(TestCase): circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1') 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, site=site, term_side='Z') - CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A') + CircuitTermination.objects.create(circuit=circuit1, termination=site, term_side='A') + CircuitTermination.objects.create(circuit=circuit1, termination=site, term_side='Z') + CircuitTermination.objects.create(circuit=circuit2, termination=provider_network, term_side='A') def test_cable_creation(self): """ diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 98665a7a0..9a821a384 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -11,14 +11,14 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from django.views.generic import View from jinja2.exceptions import TemplateError from circuits.models import Circuit, CircuitTermination from extras.views import ObjectConfigContextView from ipam.models import ASN, IPAddress, VLANGroup -from ipam.tables import InterfaceVLANTable +from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic from tenancy.views import ObjectContactsView @@ -242,6 +242,10 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView): extra=( (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'), + ( + Circuit.objects.restrict(request.user, 'view').filter(terminations___region=instance).distinct(), + 'region_id' + ), ), ), } @@ -324,6 +328,10 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): extra=( (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'), + ( + 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 ), 'site'), (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): locations = instance.get_descendants(include_self=True) 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' + ), + ), + ), } @@ -2580,11 +2600,20 @@ class InterfaceView(generic.ObjectView): orderable=False ) + # Get VLAN translation rules + vlan_translation_table = None + if instance.vlan_translation_policy: + vlan_translation_table = VLANTranslationRuleTable( + data=instance.vlan_translation_policy.rules.all(), + orderable=False + ) + return { 'vdc_table': vdc_table, 'bridge_interfaces_table': bridge_interfaces_tables, 'child_interfaces_table': child_interfaces_tables, 'vlan_table': vlan_table, + 'vlan_translation_table': vlan_translation_table, } diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 9048d5fd9..c9eaa3e0e 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1172,6 +1172,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): 'virtualmachine', 'vlan', 'vlangroup', + 'vlantranslationpolicy', + 'vlantranslationrule', 'vminterface', 'vrf', 'webhook', diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 321842260..0d98b1324 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1180,7 +1180,8 @@ class ScriptView(BaseScriptView): data=form.cleaned_data, request=copy_safe_request(request), job_timeout=script.python_class.job_timeout, - commit=form.cleaned_data.pop('_commit') + commit=form.cleaned_data.pop('_commit'), + name=script.name ) return redirect('extras:script_result', job_pk=job.pk) diff --git a/netbox/ipam/api/serializers_/vlans.py b/netbox/ipam/api/serializers_/vlans.py index 608fcf0b4..ee06357a5 100644 --- a/netbox/ipam/api/serializers_/vlans.py +++ b/netbox/ipam/api/serializers_/vlans.py @@ -5,7 +5,7 @@ from rest_framework import serializers from dcim.api.serializers_.sites import SiteSerializer from ipam.choices import * from ipam.constants import VLANGROUP_SCOPE_TYPES -from ipam.models import VLAN, VLANGroup +from ipam.models import VLAN, VLANGroup, VLANTranslationPolicy, VLANTranslationRule from netbox.api.fields import ChoiceField, ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField from netbox.api.serializers import NetBoxModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer @@ -18,6 +18,8 @@ __all__ = ( 'CreateAvailableVLANSerializer', 'VLANGroupSerializer', 'VLANSerializer', + 'VLANTranslationPolicySerializer', + 'VLANTranslationRuleSerializer', ) @@ -110,3 +112,19 @@ class CreateAvailableVLANSerializer(NetBoxModelSerializer): def validate(self, data): # Bypass model validation since we don't have a VID yet return data + + +class VLANTranslationRuleSerializer(NetBoxModelSerializer): + + class Meta: + model = VLANTranslationRule + fields = ['id', 'policy', 'local_vid', 'remote_vid'] + + +class VLANTranslationPolicySerializer(NetBoxModelSerializer): + rules = VLANTranslationRuleSerializer(many=True, read_only=True) + + class Meta: + model = VLANTranslationPolicy + fields = ['id', 'url', 'name', 'description', 'display', 'rules'] + brief_fields = ('id', 'url', 'name', 'description', 'display') diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index bae9d8048..ea76025ec 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -21,6 +21,8 @@ router.register('fhrp-groups', views.FHRPGroupViewSet) router.register('fhrp-group-assignments', views.FHRPGroupAssignmentViewSet) router.register('vlan-groups', views.VLANGroupViewSet) router.register('vlans', views.VLANViewSet) +router.register('vlan-translation-policies', views.VLANTranslationPolicyViewSet) +router.register('vlan-translation-rules', views.VLANTranslationRuleViewSet) router.register('service-templates', views.ServiceTemplateViewSet) router.register('services', views.ServiceViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index ffd4d5b7d..783d13523 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -143,6 +143,18 @@ class VLANViewSet(NetBoxModelViewSet): filterset_class = filtersets.VLANFilterSet +class VLANTranslationPolicyViewSet(NetBoxModelViewSet): + queryset = VLANTranslationPolicy.objects.all() + serializer_class = serializers.VLANTranslationPolicySerializer + filterset_class = filtersets.VLANTranslationPolicyFilterSet + + +class VLANTranslationRuleViewSet(NetBoxModelViewSet): + queryset = VLANTranslationRule.objects.all() + serializer_class = serializers.VLANTranslationRuleSerializer + filterset_class = filtersets.VLANTranslationRuleFilterSet + + class ServiceTemplateViewSet(NetBoxModelViewSet): queryset = ServiceTemplate.objects.all() serializer_class = serializers.ServiceTemplateSerializer diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 6fba68e8b..017a34ac4 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -37,6 +37,8 @@ __all__ = ( 'ServiceTemplateFilterSet', 'VLANFilterSet', 'VLANGroupFilterSet', + 'VLANTranslationPolicyFilterSet', + 'VLANTranslationRuleFilterSet', 'VRFFilterSet', ) @@ -1104,6 +1106,53 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): ) +class VLANTranslationPolicyFilterSet(NetBoxModelFilterSet): + + class Meta: + model = VLANTranslationPolicy + fields = ('id', 'name', 'description') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + return queryset.filter(qs_filter) + + +class VLANTranslationRuleFilterSet(NetBoxModelFilterSet): + policy_id = django_filters.ModelMultipleChoiceFilter( + queryset=VLANTranslationPolicy.objects.all(), + label=_('VLAN Translation Policy (ID)'), + ) + policy = django_filters.ModelMultipleChoiceFilter( + field_name='policy__name', + queryset=VLANTranslationPolicy.objects.all(), + to_field_name='name', + label=_('VLAN Translation Policy (name)'), + ) + + class Meta: + model = VLANTranslationRule + fields = ('id', 'policy_id', 'policy', 'local_vid', 'remote_vid', 'description') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(policy__name__icontains=value) + ) + try: + int_value = int(value.strip()) + qs_filter |= Q(local_vid=int_value) + qs_filter |= Q(remote_vid=int_value) + except ValueError: + pass + return queryset.filter(qs_filter) + + class ServiceTemplateFilterSet(NetBoxModelFilterSet): port = NumericArrayFilter( field_name='ports', diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 223fad790..49a79623c 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -34,6 +34,8 @@ __all__ = ( 'ServiceTemplateBulkEditForm', 'VLANBulkEditForm', 'VLANGroupBulkEditForm', + 'VLANTranslationPolicyBulkEditForm', + 'VLANTranslationRuleBulkEditForm', 'VRFBulkEditForm', ) @@ -537,6 +539,36 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): ) +class VLANTranslationPolicyBulkEditForm(NetBoxModelBulkEditForm): + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + + model = VLANTranslationPolicy + fieldsets = ( + FieldSet('description'), + ) + nullable_fields = ('description',) + + +class VLANTranslationRuleBulkEditForm(NetBoxModelBulkEditForm): + policy = DynamicModelChoiceField( + label=_('Policy'), + queryset=VLANTranslationPolicy.objects.all(), + selector=True + ) + local_vid = forms.IntegerField(required=False) + remote_vid = forms.IntegerField(required=False) + + model = VLANTranslationRule + fieldsets = ( + FieldSet('policy', 'local_vid', 'remote_vid'), + ) + fields = ('policy', 'local_vid', 'remote_vid') + + class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): protocol = forms.ChoiceField( label=_('Protocol'), diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index a6ef1a9fb..cd34a6d84 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -29,6 +29,8 @@ __all__ = ( 'ServiceTemplateImportForm', 'VLANImportForm', 'VLANGroupImportForm', + 'VLANTranslationPolicyImportForm', + 'VLANTranslationRuleImportForm', 'VRFImportForm', ) @@ -465,6 +467,20 @@ class VLANImportForm(NetBoxModelImportForm): fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments', 'tags') +class VLANTranslationPolicyImportForm(NetBoxModelImportForm): + + class Meta: + model = VLANTranslationPolicy + fields = ('name', 'description', 'tags') + + +class VLANTranslationRuleImportForm(NetBoxModelImportForm): + + class Meta: + model = VLANTranslationRule + fields = ('policy', 'local_vid', 'remote_vid') + + class ServiceTemplateImportForm(NetBoxModelImportForm): protocol = CSVChoiceField( label=_('Protocol'), diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 57c0f479c..b9bee6d97 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -28,6 +28,8 @@ __all__ = ( 'ServiceTemplateFilterForm', 'VLANFilterForm', 'VLANGroupFilterForm', + 'VLANTranslationPolicyFilterForm', + 'VLANTranslationRuleFilterForm', 'VRFFilterForm', ) @@ -461,6 +463,43 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) +class VLANTranslationPolicyFilterForm(NetBoxModelFilterSetForm): + model = VLANTranslationPolicy + fieldsets = ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', name=_('Attributes')), + ) + name = forms.CharField( + required=False, + label=_('Name') + ) + tag = TagFilterField(model) + + +class VLANTranslationRuleFilterForm(NetBoxModelFilterSetForm): + model = VLANTranslationRule + fieldsets = ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('policy_id', 'local_vid', 'remote_vid', name=_('Attributes')), + ) + tag = TagFilterField(model) + policy_id = DynamicModelMultipleChoiceField( + queryset=VLANTranslationPolicy.objects.all(), + required=False, + label=_('VLAN Translation Policy') + ) + local_vid = forms.IntegerField( + min_value=1, + required=False, + label=_('Local VLAN ID') + ) + remote_vid = forms.IntegerField( + min_value=1, + required=False, + label=_('Remote VLAN ID') + ) + + class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VLAN fieldsets = ( diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index e9e90db57..629c1a481 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -41,6 +41,8 @@ __all__ = ( 'ServiceTemplateForm', 'VLANForm', 'VLANGroupForm', + 'VLANTranslationPolicyForm', + 'VLANTranslationRuleForm', 'VRFForm', ) @@ -691,6 +693,37 @@ class VLANForm(TenancyForm, NetBoxModelForm): ] +class VLANTranslationPolicyForm(NetBoxModelForm): + + fieldsets = ( + FieldSet('name', 'description', 'tags', name=_('VLAN Translation Policy')), + ) + + class Meta: + model = VLANTranslationPolicy + fields = [ + 'name', 'description', 'tags', + ] + + +class VLANTranslationRuleForm(NetBoxModelForm): + policy = DynamicModelChoiceField( + label=_('Policy'), + queryset=VLANTranslationPolicy.objects.all(), + selector=True + ) + + fieldsets = ( + FieldSet('policy', 'local_vid', 'remote_vid', 'description', 'tags', name=_('VLAN Translation Rule')), + ) + + class Meta: + model = VLANTranslationRule + fields = [ + 'policy', 'local_vid', 'remote_vid', 'description', 'tags', + ] + + class ServiceTemplateForm(NetBoxModelForm): ports = NumericArrayField( label=_('Ports'), diff --git a/netbox/ipam/graphql/filters.py b/netbox/ipam/graphql/filters.py index 5f6602416..1b0e0133b 100644 --- a/netbox/ipam/graphql/filters.py +++ b/netbox/ipam/graphql/filters.py @@ -19,6 +19,8 @@ __all__ = ( 'ServiceTemplateFilter', 'VLANFilter', 'VLANGroupFilter', + 'VLANTranslationPolicyFilter', + 'VLANTranslationRuleFilter', 'VRFFilter', ) @@ -113,6 +115,18 @@ class VLANGroupFilter(BaseFilterMixin): pass +@strawberry_django.filter(models.VLANTranslationPolicy, lookups=True) +@autotype_decorator(filtersets.VLANTranslationPolicyFilterSet) +class VLANTranslationPolicyFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.VLANTranslationRule, lookups=True) +@autotype_decorator(filtersets.VLANTranslationRuleFilterSet) +class VLANTranslationRuleFilter(BaseFilterMixin): + pass + + @strawberry_django.filter(models.VRF, lookups=True) @autotype_decorator(filtersets.VRFFilterSet) class VRFFilter(BaseFilterMixin): diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 072f8cbcd..5fcf78ea9 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -53,5 +53,11 @@ class IPAMQuery: vlan_group: VLANGroupType = strawberry_django.field() vlan_group_list: List[VLANGroupType] = strawberry_django.field() + vlan_translation_policy: VLANTranslationPolicyType = strawberry_django.field() + vlan_translation_policy_list: List[VLANTranslationPolicyType] = strawberry_django.field() + + vlan_translation_rule: VLANTranslationRuleType = strawberry_django.field() + vlan_translation_rule_list: List[VLANTranslationRuleType] = strawberry_django.field() + vrf: VRFType = strawberry_django.field() vrf_list: List[VRFType] = strawberry_django.field() diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 9fe1fe466..ef50138c2 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -27,6 +27,8 @@ __all__ = ( 'ServiceTemplateType', 'VLANType', 'VLANGroupType', + 'VLANTranslationPolicyType', + 'VLANTranslationRuleType', 'VRFType', ) @@ -274,6 +276,24 @@ class VLANGroupType(OrganizationalObjectType): return self.scope +@strawberry_django.type( + models.VLANTranslationPolicy, + fields='__all__', + filters=VLANTranslationPolicyFilter +) +class VLANTranslationPolicyType(NetBoxObjectType): + rules: List[Annotated["VLANTranslationRuleType", strawberry.lazy('ipam.graphql.types')]] + + +@strawberry_django.type( + models.VLANTranslationRule, + fields='__all__', + filters=VLANTranslationRuleFilter +) +class VLANTranslationRuleType(NetBoxObjectType): + policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] = strawberry_django.field(select_related=["policy"]) + + @strawberry_django.type( models.VRF, fields='__all__', diff --git a/netbox/ipam/migrations/0074_vlantranslationpolicy_vlantranslationrule.py b/netbox/ipam/migrations/0074_vlantranslationpolicy_vlantranslationrule.py new file mode 100644 index 000000000..ca3943649 --- /dev/null +++ b/netbox/ipam/migrations/0074_vlantranslationpolicy_vlantranslationrule.py @@ -0,0 +1,62 @@ +# Generated by Django 5.0.9 on 2024-10-11 19:45 + +import django.core.validators +import django.db.models.deletion +import taggit.managers +import utilities.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0121_customfield_related_object_filter'), + ('ipam', '0073_charfield_null_choices'), + ] + + operations = [ + migrations.CreateModel( + name='VLANTranslationPolicy', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'VLAN translation policy', + 'verbose_name_plural': 'VLAN translation policies', + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='VLANTranslationRule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('local_vid', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)])), + ('remote_vid', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)])), + ('policy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='ipam.vlantranslationpolicy')), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'VLAN translation rule', + 'ordering': ('policy', 'local_vid',), + }, + ), + migrations.AddConstraint( + model_name='vlantranslationrule', + constraint=models.UniqueConstraint(fields=('policy', 'local_vid'), name='ipam_vlantranslationrule_unique_policy_local_vid'), + ), + migrations.AddConstraint( + model_name='vlantranslationrule', + constraint=models.UniqueConstraint(fields=('policy', 'remote_vid'), name='ipam_vlantranslationrule_unique_policy_remote_vid'), + ), + ] diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 23f7c41c7..ff8394839 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -10,13 +10,15 @@ from dcim.models import Interface from ipam.choices import * from ipam.constants import * from ipam.querysets import VLANQuerySet, VLANGroupQuerySet -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, PrimaryModel, NetBoxModel from utilities.data import check_ranges_overlap, ranges_to_string from virtualization.models import VMInterface __all__ = ( 'VLAN', 'VLANGroup', + 'VLANTranslationPolicy', + 'VLANTranslationRule', ) @@ -273,3 +275,73 @@ class VLAN(PrimaryModel): @property def l2vpn_termination(self): return self.l2vpn_terminations.first() + + +class VLANTranslationPolicy(PrimaryModel): + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True, + ) + + class Meta: + verbose_name = _('VLAN translation policy') + verbose_name_plural = _('VLAN translation policies') + ordering = ('name',) + + def __str__(self): + return self.name + + +class VLANTranslationRule(NetBoxModel): + policy = models.ForeignKey( + to=VLANTranslationPolicy, + related_name='rules', + on_delete=models.CASCADE, + ) + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=True + ) + local_vid = models.PositiveSmallIntegerField( + verbose_name=_('Local VLAN ID'), + validators=( + MinValueValidator(VLAN_VID_MIN), + MaxValueValidator(VLAN_VID_MAX) + ), + help_text=_("Numeric VLAN ID (1-4094)") + ) + remote_vid = models.PositiveSmallIntegerField( + verbose_name=_('Remote VLAN ID'), + validators=( + MinValueValidator(VLAN_VID_MIN), + MaxValueValidator(VLAN_VID_MAX) + ), + help_text=_("Numeric VLAN ID (1-4094)") + ) + prerequisite_models = ( + 'ipam.VLANTranslationPolicy', + ) + + class Meta: + verbose_name = _('VLAN translation rule') + ordering = ('policy', 'local_vid',) + constraints = ( + models.UniqueConstraint( + fields=('policy', 'local_vid'), + name='%(app_label)s_%(class)s_unique_policy_local_vid' + ), + models.UniqueConstraint( + fields=('policy', 'remote_vid'), + name='%(app_label)s_%(class)s_unique_policy_remote_vid' + ), + ) + + def __str__(self): + return f'{self.local_vid} -> {self.remote_vid} ({self.policy})' + + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + objectchange.related_object = self.policy + return objectchange diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py index 16a8eba3c..d200abacf 100644 --- a/netbox/ipam/search.py +++ b/netbox/ipam/search.py @@ -160,6 +160,27 @@ class VLANGroupIndex(SearchIndex): display_attrs = ('scope_type', 'description') +@register_search +class VLANTranslationPolicyIndex(SearchIndex): + model = models.VLANTranslationPolicy + fields = ( + ('name', 100), + ('description', 500), + ) + display_attrs = ('description',) + + +@register_search +class VLANTranslationRuleIndex(SearchIndex): + model = models.VLANTranslationRule + fields = ( + ('policy', 100), + ('local_vid', 200), + ('remote_vid', 200), + ) + display_attrs = ('policy', 'local_vid', 'remote_vid') + + @register_search class VRFIndex(SearchIndex): model = models.VRF diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 5387ce24c..1a06bb700 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -16,6 +16,8 @@ __all__ = ( 'VLANMembersTable', 'VLANTable', 'VLANVirtualMachinesTable', + 'VLANTranslationPolicyTable', + 'VLANTranslationRuleTable', ) AVAILABLE_LABEL = mark_safe('Available') @@ -244,3 +246,54 @@ class InterfaceVLANTable(NetBoxTable): def __init__(self, interface, *args, **kwargs): self.interface = interface super().__init__(*args, **kwargs) + + +# +# VLAN Translation +# + +class VLANTranslationPolicyTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + description = tables.Column( + verbose_name=_('Description'), + ) + tags = columns.TagColumn( + url_name='ipam:vlantranslationpolicy_list' + ) + + class Meta(NetBoxTable.Meta): + model = VLANTranslationPolicy + fields = ( + 'pk', 'id', 'name', 'description', 'tags', 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'description') + + +class VLANTranslationRuleTable(NetBoxTable): + policy = tables.Column( + verbose_name=_('Policy'), + linkify=True + ) + local_vid = tables.Column( + verbose_name=_('Local VID'), + linkify=True + ) + remote_vid = tables.Column( + verbose_name=_('Remote VID'), + ) + description = tables.Column( + verbose_name=_('Description'), + ) + tags = columns.TagColumn( + url_name='ipam:vlantranslationrule_list' + ) + + class Meta(NetBoxTable.Meta): + model = VLANTranslationRule + fields = ( + 'pk', 'id', 'name', 'policy', 'local_vid', 'remote_vid', 'description', 'tags', 'created', 'last_updated', + ) + default_columns = ('pk', 'policy', 'local_vid', 'remote_vid', 'description') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 1d2cdf1b7..cd3e47342 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -1020,6 +1020,112 @@ class VLANTest(APIViewTestCases.APIViewTestCase): self.assertTrue(content['detail'].startswith('Unable to delete object.')) +class VLANTranslationPolicyTest(APIViewTestCases.APIViewTestCase): + model = VLANTranslationPolicy + brief_fields = ['description', 'display', 'id', 'name', 'url',] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + vlan_translation_policies = ( + VLANTranslationPolicy( + name='Policy 1', + description='foobar1', + ), + VLANTranslationPolicy( + name='Policy 2', + description='foobar2', + ), + VLANTranslationPolicy( + name='Policy 3', + description='foobar3', + ), + ) + VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies) + + cls.create_data = [ + { + 'name': 'Policy 4', + 'description': 'foobar4', + }, + { + 'name': 'Policy 5', + 'description': 'foobar5', + }, + { + 'name': 'Policy 6', + 'description': 'foobar6', + }, + ] + + +class VLANTranslationRuleTest(APIViewTestCases.APIViewTestCase): + model = VLANTranslationRule + brief_fields = ['id', 'local_vid', 'policy', 'remote_vid',] + + @classmethod + def setUpTestData(cls): + + vlan_translation_policies = ( + VLANTranslationPolicy( + name='Policy 1', + description='foobar1', + ), + VLANTranslationPolicy( + name='Policy 2', + description='foobar2', + ), + ) + VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies) + + vlan_translation_rules = ( + VLANTranslationRule( + policy=vlan_translation_policies[0], + local_vid=100, + remote_vid=200, + description='foo', + ), + VLANTranslationRule( + policy=vlan_translation_policies[0], + local_vid=101, + remote_vid=201, + description='bar', + ), + VLANTranslationRule( + policy=vlan_translation_policies[1], + local_vid=102, + remote_vid=202, + description='baz', + ), + ) + VLANTranslationRule.objects.bulk_create(vlan_translation_rules) + + cls.create_data = [ + { + 'policy': vlan_translation_policies[0].pk, + 'local_vid': 300, + 'remote_vid': 400, + }, + { + 'policy': vlan_translation_policies[0].pk, + 'local_vid': 301, + 'remote_vid': 401, + }, + { + 'policy': vlan_translation_policies[1].pk, + 'local_vid': 302, + 'remote_vid': 402, + }, + ] + + cls.bulk_update_data = { + 'policy': vlan_translation_policies[1].pk, + } + + class ServiceTemplateTest(APIViewTestCases.APIViewTestCase): model = ServiceTemplate brief_fields = ['description', 'display', 'id', 'name', 'ports', 'protocol', 'url'] diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 872a4e98a..fdf2aafa5 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1899,6 +1899,99 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) +class VLANTranslationPolicyTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = VLANTranslationPolicy.objects.all() + filterset = VLANTranslationPolicyFilterSet + + @classmethod + def setUpTestData(cls): + + vlan_translation_policies = ( + VLANTranslationPolicy( + name='Policy 1', + description='foobar1', + ), + VLANTranslationPolicy( + name='Policy 2', + description='foobar2', + ), + VLANTranslationPolicy( + name='Policy 3', + description='foobar3', + ), + ) + VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies) + + def test_name(self): + params = {'name': ['Policy 1', 'Policy 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['foobar1', 'foobar2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class VLANTranslationRuleTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = VLANTranslationRule.objects.all() + filterset = VLANTranslationRuleFilterSet + + @classmethod + def setUpTestData(cls): + + vlan_translation_policies = ( + VLANTranslationPolicy( + name='Policy 1', + description='foobar1', + ), + VLANTranslationPolicy( + name='Policy 2', + description='foobar2', + ), + ) + VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies) + + vlan_translation_rules = ( + VLANTranslationRule( + policy=vlan_translation_policies[0], + local_vid=100, + remote_vid=200, + description='foo', + ), + VLANTranslationRule( + policy=vlan_translation_policies[0], + local_vid=101, + remote_vid=201, + description='bar', + ), + VLANTranslationRule( + policy=vlan_translation_policies[1], + local_vid=100, + remote_vid=200, + description='baz', + ), + ) + VLANTranslationRule.objects.bulk_create(vlan_translation_rules) + + def test_policy_id(self): + policies = VLANTranslationPolicy.objects.all()[:2] + params = {'policy_id': [policies[0].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'policy': [policies[0].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_local_vid(self): + params = {'local_vid': [100]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_remote_vid(self): + params = {'remote_vid': [200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['foo', 'bar']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ServiceTemplate.objects.all() filterset = ServiceTemplateFilterSet diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 27d88767b..c45777f08 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -863,6 +863,121 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class VLANTranslationPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = VLANTranslationPolicy + + @classmethod + def setUpTestData(cls): + + vlan_translation_policies = ( + VLANTranslationPolicy( + name='Policy 1', + description='foobar1', + ), + VLANTranslationPolicy( + name='Policy 2', + description='foobar2', + ), + VLANTranslationPolicy( + name='Policy 3', + description='foobar3', + ), + ) + VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Policy999', + 'description': 'A new VLAN Translation Policy', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,description", + "Policy101,foobar1", + "Policy102,foobar2", + "Policy103,foobar3", + ) + + cls.csv_update_data = ( + "id,name,description", + f"{vlan_translation_policies[0].pk},Policy101,New description 1", + f"{vlan_translation_policies[1].pk},Policy102,New description 2", + f"{vlan_translation_policies[2].pk},Policy103,New description 3", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class VLANTranslationRuleTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = VLANTranslationRule + + @classmethod + def setUpTestData(cls): + + vlan_translation_policies = ( + VLANTranslationPolicy( + name='Policy 1', + description='foobar1', + ), + VLANTranslationPolicy( + name='Policy 2', + description='foobar2', + ), + VLANTranslationPolicy( + name='Policy 3', + description='foobar3', + ), + ) + VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies) + + vlan_translation_rules = ( + VLANTranslationRule( + policy=vlan_translation_policies[0], + local_vid=100, + remote_vid=200, + ), + VLANTranslationRule( + policy=vlan_translation_policies[0], + local_vid=101, + remote_vid=201, + ), + VLANTranslationRule( + policy=vlan_translation_policies[1], + local_vid=102, + remote_vid=202, + ), + ) + VLANTranslationRule.objects.bulk_create(vlan_translation_rules) + + cls.form_data = { + 'policy': vlan_translation_policies[0].pk, + 'local_vid': 300, + 'remote_vid': 400, + } + + cls.csv_data = ( + "policy,local_vid,remote_vid", + f"{vlan_translation_policies[0].pk},103,203", + f"{vlan_translation_policies[0].pk},104,204", + f"{vlan_translation_policies[1].pk},105,205", + ) + + cls.csv_update_data = ( + "id,policy,local_vid,remote_vid", + f"{vlan_translation_rules[0].pk},{vlan_translation_policies[1].pk},105,205", + f"{vlan_translation_rules[1].pk},{vlan_translation_policies[1].pk},106,206", + f"{vlan_translation_rules[2].pk},{vlan_translation_policies[0].pk},107,207", + ) + + cls.bulk_edit_data = { + 'policy': vlan_translation_policies[2].pk, + } + + class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ServiceTemplate diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 61deeff4b..d40f9c5dc 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -116,6 +116,22 @@ urlpatterns = [ path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), path('vlans//', include(get_model_urls('ipam', 'vlan'))), + # VLAN Translation Policies + path('vlan-translation-policies/', views.VLANTranslationPolicyListView.as_view(), name='vlantranslationpolicy_list'), + path('vlan-translation-policies/add/', views.VLANTranslationPolicyEditView.as_view(), name='vlantranslationpolicy_add'), + path('vlan-translation-policies/import/', views.VLANTranslationPolicyBulkImportView.as_view(), name='vlantranslationpolicy_import'), + path('vlan-translation-policies/edit/', views.VLANTranslationPolicyBulkEditView.as_view(), name='vlantranslationpolicy_bulk_edit'), + path('vlan-translation-policies/delete/', views.VLANTranslationPolicyBulkDeleteView.as_view(), name='vlantranslationpolicy_bulk_delete'), + path('vlan-translation-policies//', include(get_model_urls('ipam', 'vlantranslationpolicy'))), + + # VLAN Translation Rules + path('vlan-translation-rules/', views.VLANTranslationRuleListView.as_view(), name='vlantranslationrule_list'), + path('vlan-translation-rules/add/', views.VLANTranslationRuleEditView.as_view(), name='vlantranslationrule_add'), + path('vlan-translation-rules/import/', views.VLANTranslationRuleBulkImportView.as_view(), name='vlantranslationrule_import'), + path('vlan-translation-rules/edit/', views.VLANTranslationRuleBulkEditView.as_view(), name='vlantranslationrule_bulk_edit'), + path('vlan-translation-rules/delete/', views.VLANTranslationRuleBulkDeleteView.as_view(), name='vlantranslationrule_bulk_delete'), + path('vlan-translation-rules//', include(get_model_urls('ipam', 'vlantranslationrule'))), + # Service templates path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'), path('service-templates/add/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_add'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 5381ec187..bbd19c433 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -3,12 +3,13 @@ from django.db.models import Prefetch from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from circuits.models import Provider from dcim.filtersets import InterfaceFilterSet from dcim.forms import InterfaceFilterForm from dcim.models import Interface, Site +from ipam.tables import VLANTranslationRuleTable from netbox.views import generic from tenancy.views import ObjectContactsView from utilities.query import count_related @@ -986,6 +987,110 @@ class VLANGroupVLANsView(generic.ObjectChildrenView): return queryset +# +# VLAN Translation Policies +# + +class VLANTranslationPolicyListView(generic.ObjectListView): + queryset = VLANTranslationPolicy.objects.all() + filterset = filtersets.VLANTranslationPolicyFilterSet + filterset_form = forms.VLANTranslationPolicyFilterForm + table = tables.VLANTranslationPolicyTable + + +@register_model_view(VLANTranslationPolicy) +class VLANTranslationPolicyView(GetRelatedModelsMixin, generic.ObjectView): + queryset = VLANTranslationPolicy.objects.all() + + def get_extra_context(self, request, instance): + vlan_translation_table = VLANTranslationRuleTable( + data=instance.rules.all(), + orderable=False + ) + return { + 'vlan_translation_table': vlan_translation_table, + } + + +@register_model_view(VLANTranslationPolicy, 'edit') +class VLANTranslationPolicyEditView(generic.ObjectEditView): + queryset = VLANTranslationPolicy.objects.all() + form = forms.VLANTranslationPolicyForm + + +@register_model_view(VLANTranslationPolicy, 'delete') +class VLANTranslationPolicyDeleteView(generic.ObjectDeleteView): + queryset = VLANTranslationPolicy.objects.all() + + +class VLANTranslationPolicyBulkImportView(generic.BulkImportView): + queryset = VLANTranslationPolicy.objects.all() + model_form = forms.VLANTranslationPolicyImportForm + + +class VLANTranslationPolicyBulkEditView(generic.BulkEditView): + queryset = VLANTranslationPolicy.objects.all() + filterset = filtersets.VLANTranslationPolicyFilterSet + table = tables.VLANTranslationPolicyTable + form = forms.VLANTranslationPolicyBulkEditForm + + +class VLANTranslationPolicyBulkDeleteView(generic.BulkDeleteView): + queryset = VLANTranslationPolicy.objects.all() + filterset = filtersets.VLANTranslationPolicyFilterSet + table = tables.VLANTranslationPolicyTable + + +# +# VLAN Translation Rules +# + +class VLANTranslationRuleListView(generic.ObjectListView): + queryset = VLANTranslationRule.objects.all() + filterset = filtersets.VLANTranslationRuleFilterSet + filterset_form = forms.VLANTranslationRuleFilterForm + table = tables.VLANTranslationRuleTable + + +@register_model_view(VLANTranslationRule) +class VLANTranslationRuleView(GetRelatedModelsMixin, generic.ObjectView): + queryset = VLANTranslationRule.objects.all() + + def get_extra_context(self, request, instance): + return { + 'related_models': self.get_related_models(request, instance), + } + + +@register_model_view(VLANTranslationRule, 'edit') +class VLANTranslationRuleEditView(generic.ObjectEditView): + queryset = VLANTranslationRule.objects.all() + form = forms.VLANTranslationRuleForm + + +@register_model_view(VLANTranslationRule, 'delete') +class VLANTranslationRuleDeleteView(generic.ObjectDeleteView): + queryset = VLANTranslationRule.objects.all() + + +class VLANTranslationRuleBulkImportView(generic.BulkImportView): + queryset = VLANTranslationRule.objects.all() + model_form = forms.VLANTranslationRuleImportForm + + +class VLANTranslationRuleBulkEditView(generic.BulkEditView): + queryset = VLANTranslationRule.objects.all() + filterset = filtersets.VLANTranslationRuleFilterSet + table = tables.VLANTranslationRuleTable + form = forms.VLANTranslationRuleBulkEditForm + + +class VLANTranslationRuleBulkDeleteView(generic.BulkDeleteView): + queryset = VLANTranslationRule.objects.all() + filterset = filtersets.VLANTranslationRuleFilterSet + table = tables.VLANTranslationRuleTable + + # # FHRP groups # diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 9d8ffaaf8..737e399a5 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -194,6 +194,8 @@ IPAM_MENU = Menu( items=( get_model_item('ipam', 'vlan', _('VLANs')), get_model_item('ipam', 'vlangroup', _('VLAN Groups')), + get_model_item('ipam', 'vlantranslationpolicy', _('VLAN Translation Policies')), + get_model_item('ipam', 'vlantranslationrule', _('VLAN Translation Rules')), ), ), MenuGroup( diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index 451c9c01d..49862e83f 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -4,7 +4,7 @@ from django.contrib import messages from django.db import transaction from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from django.views.generic import View from core.models import Job, ObjectChange diff --git a/netbox/templates/circuits/inc/circuit_termination_fields.html b/netbox/templates/circuits/inc/circuit_termination_fields.html index 97d194f24..94c4599b0 100644 --- a/netbox/templates/circuits/inc/circuit_termination_fields.html +++ b/netbox/templates/circuits/inc/circuit_termination_fields.html @@ -1,18 +1,19 @@ {% load helpers %} {% load i18n %} -{% if termination.site %} - {% trans "Site" %} - - {% if termination.site.region %} - {{ termination.site.region|linkify }} / - {% endif %} - {{ termination.site|linkify }} - + {% trans "Termination point" %} + {% if termination.termination %} + + {{ termination.termination|linkify }} +
{% trans termination.termination_type.name|bettertitle %}
+ + {% else %} + {{ ''|placeholder }} + {% endif %} - {% trans "Termination" %} + {% trans "Connection" %} {% if termination.mark_connected %} @@ -57,12 +58,6 @@ {% endif %} -{% else %} - - {% trans "Provider Network" %} - {{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }} - -{% endif %} {% trans "Speed" %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 016a6c890..b18a6380b 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -133,6 +133,10 @@ {% trans "VRF" %} {{ object.vrf|linkify|placeholder }} + + {% trans "VLAN Translation" %} + {{ object.vlan_translation_policy|linkify|placeholder }} + {% if not object.is_virtual %} @@ -355,6 +359,13 @@ {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %} + {% if object.vlan_translation_policy %} +
+
+ {% include 'inc/panel_table.html' with table=vlan_translation_table heading="VLAN Translation" %} +
+
+ {% endif %} {% if object.is_bridge %}
diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 5b7361a12..cbffbf8de 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -37,101 +37,104 @@ {% endif %}
- {% if module.scripts %} - - - - - - - - - - - - {% for script in module.scripts.all %} - {% with last_job=script.get_latest_jobs|first %} - - +
{% trans "Name" %}{% trans "Description" %}{% trans "Last Run" %}{% trans "Status" %}
- {% if script.is_executable %} - {{ script.python_class.name }} + {% with scripts=module.scripts.all %} + {% if scripts %} + + + + + + + + + + + + {% for script in scripts %} + {% with last_job=script.get_latest_jobs|first %} + + + + {% if last_job %} + + {% else %} - {{ script.python_class.name }} - - - + + {% endif %} - - + + {% if last_job %} - - - {% else %} - - + {% for test_name, data in last_job.data.tests.items %} + + + + + {% endfor %} + {% elif not last_job.data.log %} + {# legacy #} + {% for method, stats in last_job.data.items %} + + + + + {% endfor %} {% endif %} - - - {% if last_job %} - {% for test_name, data in last_job.data.tests.items %} - - - - - {% endfor %} - {% elif not last_job.data.log %} - {# legacy #} - {% for method, stats in last_job.data.items %} - - - - - {% endfor %} - {% endif %} - {% endwith %} - {% endfor %} - -
{% trans "Name" %}{% trans "Description" %}{% trans "Last Run" %}{% trans "Status" %}
+ {% if script.is_executable %} + {{ script.python_class.name }} + {% else %} + {{ script.python_class.name }} + + + + {% endif %} + {{ script.python_class.Meta.description|markdown|placeholder }} + {{ last_job.created|isodatetime }} + + {% badge last_job.get_status_display last_job.get_status_color %} + {% trans "Never" %}{{ ''|placeholder }}{{ script.python_class.Meta.description|markdown|placeholder }} + {% if request.user|can_run:script and script.is_executable %} +
+
+ {% csrf_token %} + +
+
+ {% endif %} +
- {{ last_job.created|isodatetime }} - - {% badge last_job.get_status_display last_job.get_status_color %} - {% trans "Never" %}{{ ''|placeholder }}
+ {{ test_name }} + + {{ data.success }} + {{ data.info }} + {{ data.warning }} + {{ data.failure }} +
+ {{ method }} + + {{ stats.success }} + {{ stats.info }} + {{ stats.warning }} + {{ stats.failure }} +
- {% if request.user|can_run:script and script.is_executable %} -
-
- {% csrf_token %} - -
-
- {% endif %} -
- {{ test_name }} - - {{ data.success }} - {{ data.info }} - {{ data.warning }} - {{ data.failure }} -
- {{ method }} - - {{ stats.success }} - {{ stats.info }} - {{ stats.warning }} - {{ stats.failure }} -
- {% else %} -
-
+ {% else %} +
+
-
- {% endif %} + {% endif %} + {% endwith %} {% empty %} {% include 'inc/panels/tags.html' %} @@ -100,6 +104,13 @@ {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %} +{% if object.vlan_translation_policy %} +
+
+ {% include 'inc/panel_table.html' with table=vlan_translation_table heading="VLAN Translation" %} +
+
+{% endif %}
{% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %} diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 12bf4baab..96b2cb071 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,6 +1,6 @@ from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.views import generic from utilities.query import count_related diff --git a/netbox/translations/en/LC_MESSAGES/django.po b/netbox/translations/en/LC_MESSAGES/django.po index 35a1fae7a..7692853f4 100644 --- a/netbox/translations/en/LC_MESSAGES/django.po +++ b/netbox/translations/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-10-28 19:20+0000\n" +"POT-Creation-Date: 2024-10-29 21:00+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/netbox/virtualization/api/serializers_/virtualmachines.py b/netbox/virtualization/api/serializers_/virtualmachines.py index 1b224c16a..2c00cac96 100644 --- a/netbox/virtualization/api/serializers_/virtualmachines.py +++ b/netbox/virtualization/api/serializers_/virtualmachines.py @@ -8,7 +8,7 @@ from dcim.api.serializers_.sites import SiteSerializer from dcim.choices import InterfaceModeChoices from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from ipam.api.serializers_.ip import IPAddressSerializer -from ipam.api.serializers_.vlans import VLANSerializer +from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer from ipam.api.serializers_.vrfs import VRFSerializer from ipam.models import VLAN from netbox.api.fields import ChoiceField, SerializedPKRelatedField @@ -89,6 +89,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer): required=False, many=True ) + vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True) vrf = VRFSerializer(nested=True, required=False, allow_null=True) l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) count_ipaddresses = serializers.IntegerField(read_only=True) @@ -105,6 +106,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer): 'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', + 'vlan_translation_policy', ] brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description') diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 28563a821..d6835075b 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -7,7 +7,7 @@ from dcim.forms.common import InterfaceCommonForm from dcim.forms.mixins import ScopedForm from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from extras.models import ConfigTemplate -from ipam.models import IPAddress, VLAN, VLANGroup, VRF +from ipam.models import IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ConfirmationForm @@ -339,20 +339,25 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm): required=False, label=_('VRF') ) + vlan_translation_policy = DynamicModelChoiceField( + queryset=VLANTranslationPolicy.objects.all(), + required=False, + label=_('VLAN Translation Policy') + ) fieldsets = ( FieldSet('virtual_machine', 'name', 'description', 'tags', name=_('Interface')), FieldSet('vrf', 'mac_address', name=_('Addressing')), FieldSet('mtu', 'enabled', name=_('Operation')), FieldSet('parent', 'bridge', name=_('Related Interfaces')), - FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')), + FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy', name=_('802.1Q Switching')), ) class Meta: model = VMInterface fields = [ 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', - 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', + 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'vlan_translation_policy', ] labels = { 'mode': '802.1Q Mode', diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 2a22d1680..c14c8e34d 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -107,6 +107,7 @@ class VMInterfaceType(IPAddressesMixin, ComponentType): bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None + vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] bridge_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]] diff --git a/netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py b/netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py new file mode 100644 index 000000000..e0992c9c8 --- /dev/null +++ b/netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.9 on 2024-10-11 19:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0074_vlantranslationpolicy_vlantranslationrule'), + ('virtualization', '0041_charfield_null_choices'), + ] + + operations = [ + migrations.AddField( + model_name='vminterface', + name='vlan_translation_policy', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlantranslationpolicy'), + ), + ] diff --git a/netbox/virtualization/migrations/0042_cluster_scope.py b/netbox/virtualization/migrations/0043_cluster_scope.py similarity index 95% rename from netbox/virtualization/migrations/0042_cluster_scope.py rename to netbox/virtualization/migrations/0043_cluster_scope.py index ed8d8bd88..4df325093 100644 --- a/netbox/virtualization/migrations/0042_cluster_scope.py +++ b/netbox/virtualization/migrations/0043_cluster_scope.py @@ -20,7 +20,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('virtualization', '0041_charfield_null_choices'), + ('virtualization', '0042_vminterface_vlan_translation_policy'), ] operations = [ diff --git a/netbox/virtualization/migrations/0043_clusters_cached_relations.py b/netbox/virtualization/migrations/0044_clusters_cached_relations.py similarity index 98% rename from netbox/virtualization/migrations/0043_clusters_cached_relations.py rename to netbox/virtualization/migrations/0044_clusters_cached_relations.py index bf72fb89a..32c3bd66c 100644 --- a/netbox/virtualization/migrations/0043_clusters_cached_relations.py +++ b/netbox/virtualization/migrations/0044_clusters_cached_relations.py @@ -21,7 +21,7 @@ def populate_denormalized_fields(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('virtualization', '0042_cluster_scope'), + ('virtualization', '0043_cluster_scope'), ] operations = [ diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 41cd7ca50..1c6ef5906 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -1,7 +1,7 @@ from django.test import TestCase from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup -from ipam.models import IPAddress, VRF +from ipam.models import IPAddress, VLANTranslationPolicy, VRF from tenancy.models import Tenant, TenantGroup from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.choices import * @@ -563,6 +563,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) VirtualMachine.objects.bulk_create(vms) + vlan_translation_policies = ( + VLANTranslationPolicy(name='Policy 1'), + VLANTranslationPolicy(name='Policy 2'), + VLANTranslationPolicy(name='Policy 3'), + ) + VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies) + interfaces = ( VMInterface( virtual_machine=vms[0], @@ -571,7 +578,8 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): mtu=100, mac_address='00-00-00-00-00-01', vrf=vrfs[0], - description='foobar1' + description='foobar1', + vlan_translation_policy=vlan_translation_policies[0], ), VMInterface( virtual_machine=vms[1], @@ -580,7 +588,8 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): mtu=200, mac_address='00-00-00-00-00-02', vrf=vrfs[1], - description='foobar2' + description='foobar2', + vlan_translation_policy=vlan_translation_policies[0], ), VMInterface( virtual_machine=vms[2], @@ -660,6 +669,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_vlan_translation_policy(self): + vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2] + params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'vlan_translation_policy': [vlan_translation_policies[0].name, vlan_translation_policies[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class VirtualDiskTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VirtualDisk.objects.all() diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 0828d3a2a..35f2f8f75 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -6,7 +6,7 @@ from django.db.models import Prefetch, Sum from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from django.views.generic.base import RedirectView from jinja2.exceptions import TemplateError @@ -16,7 +16,7 @@ from dcim.models import Device from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView from ipam.models import IPAddress -from ipam.tables import InterfaceVLANTable +from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic from tenancy.views import ObjectContactsView @@ -516,6 +516,14 @@ class VMInterfaceView(generic.ObjectView): orderable=False ) + # Get VLAN translation rules + vlan_translation_table = None + if instance.vlan_translation_policy: + vlan_translation_table = VLANTranslationRuleTable( + data=instance.vlan_translation_policy.rules.all(), + orderable=False + ) + # Get assigned VLANs and annotate whether each is tagged or untagged vlans = [] if instance.untagged_vlan is not None: @@ -533,6 +541,7 @@ class VMInterfaceView(generic.ObjectView): return { 'child_interfaces_table': child_interfaces_tables, 'vlan_table': vlan_table, + 'vlan_translation_table': vlan_translation_table, }