From a4e8070c6d7d0b3013c09389944368ee5a0cfa9f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Nov 2023 11:37:23 -0500 Subject: [PATCH] Add forms for creating tunnel terminations --- netbox/vpn/choices.py | 11 ++ netbox/vpn/forms/model_forms.py | 234 ++++++++++++++++++++++---- netbox/vpn/migrations/0001_initial.py | 2 +- netbox/vpn/models/tunnels.py | 9 +- netbox/vpn/views.py | 16 ++ 5 files changed, 240 insertions(+), 32 deletions(-) diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py index 1ea8bcadb..211dcb372 100644 --- a/netbox/vpn/choices.py +++ b/netbox/vpn/choices.py @@ -35,6 +35,17 @@ class TunnelEncapsulationChoices(ChoiceSet): ] +class TunnelTerminationTypeChoices(ChoiceSet): + # For TunnelCreateForm + TYPE_DEVICE = 'dcim.device' + TYPE_VIRUTALMACHINE = 'virtualization.virtualmachine' + + CHOICES = ( + (TYPE_DEVICE, _('Device')), + (TYPE_VIRUTALMACHINE, _('Virtual Machine')), + ) + + class TunnelTerminationRoleChoices(ChoiceSet): ROLE_PEER = 'peer' ROLE_HUB = 'hub' diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 175c27a47..34976a62a 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -1,24 +1,30 @@ +from django import forms from django.utils.translation import gettext_lazy as _ -from dcim.models import Interface +from dcim.models import Device, Interface from ipam.models import IPAddress from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms.fields import CommentField, DynamicModelChoiceField -from virtualization.models import VMInterface +from utilities.forms.widgets import HTMXSelect +from virtualization.models import VirtualMachine, VMInterface +from vpn.choices import * from vpn.models import * __all__ = ( 'IPSecProfileForm', + 'TunnelCreateForm', 'TunnelForm', 'TunnelTerminationForm', + 'TunnelTerminationCreateForm', ) class TunnelForm(TenancyForm, NetBoxModelForm): ipsec_profile = DynamicModelChoiceField( queryset=IPSecProfile.objects.all(), - label=_('IPSec Profile') + label=_('IPSec Profile'), + required=False ) comments = CommentField() @@ -36,52 +42,220 @@ class TunnelForm(TenancyForm, NetBoxModelForm): ] -class TunnelTerminationForm(NetBoxModelForm): - tunnel = DynamicModelChoiceField( - queryset=Tunnel.objects.all() +class TunnelCreateForm(TunnelForm): + # First termination + termination1_role = forms.ChoiceField( + choices=TunnelTerminationRoleChoices, + label=_('Role') ) - interface = DynamicModelChoiceField( + termination1_type = forms.ChoiceField( + choices=TunnelTerminationTypeChoices, + widget=HTMXSelect(), + label=_('Type') + ) + termination1_parent = DynamicModelChoiceField( + queryset=Device.objects.all(), + selector=True, + label=_('Device') + ) + termination1_interface = DynamicModelChoiceField( + queryset=Interface.objects.all(), label=_('Interface'), + query_params={ + 'device_id': '$termination1_parent', + } + ) + termination1_outside_ip = DynamicModelChoiceField( + queryset=IPAddress.objects.all(), + label=_('Outside IP'), + required=False, + query_params={ + 'device_id': '$termination1_parent', + } + ) + + # Second termination + termination2_role = forms.ChoiceField( + choices=TunnelTerminationRoleChoices, + required=False, + label=_('Role') + ) + termination2_type = forms.ChoiceField( + choices=TunnelTerminationTypeChoices, + required=False, + widget=HTMXSelect(), + label=_('Type') + ) + termination2_parent = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + selector=True, + label=_('Device') + ) + termination2_interface = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - selector=True, - ) - vminterface = DynamicModelChoiceField( - queryset=VMInterface.objects.all(), - required=False, - selector=True, label=_('Interface'), + query_params={ + 'device_id': '$termination2_parent', + } ) + termination2_outside_ip = DynamicModelChoiceField( + queryset=IPAddress.objects.all(), + required=False, + label=_('Outside IP'), + query_params={ + 'device_id': '$termination2_parent', + } + ) + + fieldsets = ( + (_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')), + (_('Security'), ('ipsec_profile', 'preshared_key')), + (_('Tenancy'), ('tenant_group', 'tenant')), + (_('First Termination'), ( + 'termination1_role', 'termination1_type', 'termination1_parent', 'termination1_interface', + 'termination1_outside_ip', + )), + (_('Second Termination'), ( + 'termination2_role', 'termination2_type', 'termination2_parent', 'termination2_interface', + 'termination2_outside_ip', + )), + ) + + def __init__(self, *args, initial=None, **kwargs): + super().__init__(*args, initial=initial, **kwargs) + + if initial and initial.get('termination1_type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE: + self.fields['termination1_parent'].label = _('Virtual Machine') + self.fields['termination1_parent'].queryset = VirtualMachine.objects.all() + self.fields['termination1_interface'].queryset = VMInterface.objects.all() + self.fields['termination1_interface'].widget.add_query_params({ + 'virtual_machine_id': '$termination1_parent', + }) + self.fields['termination1_outside_ip'].widget.add_query_params({ + 'virtual_machine_id': '$termination1_parent', + }) + + if initial and initial.get('termination2_type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE: + self.fields['termination2_parent'].label = _('Virtual Machine') + self.fields['termination2_parent'].queryset = VirtualMachine.objects.all() + self.fields['termination2_interface'].queryset = VMInterface.objects.all() + self.fields['termination2_interface'].widget.add_query_params({ + 'virtual_machine_id': '$termination2_parent', + }) + self.fields['termination2_outside_ip'].widget.add_query_params({ + 'virtual_machine_id': '$termination2_parent', + }) + + def clean(self): + super().clean() + + # Check that all required parameters have been set for the second termination (if any) + termination2_required_parameters = ( + 'termination2_role', 'termination2_type', 'termination2_parent', 'termination2_interface', + ) + termination2_parameters = ( + *termination2_required_parameters, + 'termination2_outside_ip', + ) + if any([self.cleaned_data[param] for param in termination2_parameters]): + for param in termination2_required_parameters: + if not self.cleaned_data[param]: + raise forms.ValidationError({ + param: _("This parameter is required when defining a second termination.") + }) + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + + # Create first termination + TunnelTermination.objects.create( + tunnel=instance, + role=self.cleaned_data['termination1_role'], + interface=self.cleaned_data['termination1_interface'], + outside_ip=self.cleaned_data['termination1_outside_ip'], + ) + + # Create second termination, if defined + if self.cleaned_data['termination2_role']: + TunnelTermination.objects.create( + tunnel=instance, + role=self.cleaned_data['termination2_role'], + interface=self.cleaned_data['termination2_interface'], + outside_ip=self.cleaned_data.get('termination1_outside_ip'), + ) + + return instance + + +class TunnelTerminationForm(NetBoxModelForm): outside_ip = DynamicModelChoiceField( queryset=IPAddress.objects.all(), - selector=True, - label=_('Outside IP'), + required=False, + label=_('Outside IP') ) class Meta: model = TunnelTermination fields = [ - 'tunnel', 'role', 'outside_ip', 'tags', + 'role', 'outside_ip', 'tags', ] - def __init__(self, *args, **kwargs): - # Initialize helper selectors - initial = kwargs.get('initial', {}).copy() - if instance := kwargs.get('instance'): - if type(instance.interface) is Interface: - initial['interface'] = instance.interface - elif type(instance.interface) is VMInterface: - initial['vminterface'] = instance.interface - kwargs['initial'] = initial +class TunnelTerminationCreateForm(NetBoxModelForm): + tunnel = DynamicModelChoiceField( + queryset=Tunnel.objects.all() + ) + type = forms.ChoiceField( + choices=TunnelTerminationTypeChoices, + widget=HTMXSelect(), + label=_('Type') + ) + parent = DynamicModelChoiceField( + queryset=Device.objects.all(), + selector=True, + label=_('Device') + ) + interface = DynamicModelChoiceField( + queryset=Interface.objects.all(), + label=_('Interface'), + query_params={ + 'device_id': '$parent', + } + ) + outside_ip = DynamicModelChoiceField( + queryset=IPAddress.objects.all(), + label=_('Outside IP'), + required=False, + query_params={ + 'device_id': '$parent', + } + ) - super().__init__(*args, **kwargs) + fieldsets = ( + (None, ('tunnel', 'role', 'type', 'parent', 'interface', 'outside_ip', 'tags')), + ) - def clean(self): - super().clean() + class Meta: + model = TunnelTermination + fields = [ + 'tunnel', 'role', 'interface', 'outside_ip', 'tags', + ] - # Handle interface assignment - self.instance.interface = self.cleaned_data['interface'] or self.cleaned_data['interface'] or None + def __init__(self, *args, initial=None, **kwargs): + super().__init__(*args, initial=initial, **kwargs) + + if initial and initial.get('type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE: + self.fields['parent'].label = _('Virtual Machine') + self.fields['parent'].queryset = VirtualMachine.objects.all() + self.fields['interface'].queryset = VMInterface.objects.all() + self.fields['interface'].widget.add_query_params({ + 'virtual_machine_id': '$parent', + }) + self.fields['outside_ip'].widget.add_query_params({ + 'virtual_machine_id': '$parent', + }) class IPSecProfileForm(NetBoxModelForm): diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py index 0a82eb344..0e79e9061 100644 --- a/netbox/vpn/migrations/0001_initial.py +++ b/netbox/vpn/migrations/0001_initial.py @@ -81,7 +81,7 @@ class Migration(migrations.Migration): ('role', models.CharField(default='peer', max_length=50)), ('interface_id', models.PositiveBigIntegerField(blank=True, null=True)), ('interface_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), - ('outside_ip', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='tunnel_termination', to='ipam.ipaddress')), + ('outside_ip', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnel_termination', to='ipam.ipaddress')), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ('tunnel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='vpn.tunnel')), ], diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index b48a30a4f..f6bb77dfd 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -101,7 +101,9 @@ class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLo outside_ip = models.OneToOneField( to='ipam.IPAddress', on_delete=models.PROTECT, - related_name='tunnel_termination' + related_name='tunnel_termination', + blank=True, + null=True ) class Meta: @@ -117,3 +119,8 @@ class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLo def get_role_color(self): return TunnelTerminationRoleChoices.colors.get(self.role) + + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + objectchange.related_object = self.tunnel + return objectchange diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py index 9ea4fd215..aa63d00da 100644 --- a/netbox/vpn/views.py +++ b/netbox/vpn/views.py @@ -28,6 +28,14 @@ class TunnelEditView(generic.ObjectEditView): queryset = Tunnel.objects.all() form = forms.TunnelForm + def dispatch(self, request, *args, **kwargs): + + # If creating a new Tunnel, use the creation form + if 'pk' not in kwargs: + self.form = forms.TunnelCreateForm + + return super().dispatch(request, *args, **kwargs) + @register_model_view(Tunnel, 'delete') class TunnelDeleteView(generic.ObjectDeleteView): @@ -72,6 +80,14 @@ class TunnelTerminationEditView(generic.ObjectEditView): queryset = TunnelTermination.objects.all() form = forms.TunnelTerminationForm + def dispatch(self, request, *args, **kwargs): + + # If creating a new Tunnel, use the creation form + if 'pk' not in kwargs: + self.form = forms.TunnelTerminationCreateForm + + return super().dispatch(request, *args, **kwargs) + @register_model_view(TunnelTermination, 'delete') class TunnelTerminationDeleteView(generic.ObjectDeleteView):