From 8db1093fdc24e97cb2aba3a70566cda775f10be5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 4 Dec 2023 15:44:52 -0500 Subject: [PATCH] #9816: Add TunnelGroup --- docs/features/vpn-tunnels.md | 2 +- docs/models/vpn/tunnel.md | 12 +- docs/models/vpn/tunnelgroup.md | 13 ++ netbox/dcim/tables/template_code.py | 2 +- netbox/netbox/navigation/menu.py | 1 + netbox/templates/vpn/tunnel.html | 4 + netbox/templates/vpn/tunnelgroup.html | 53 +++++ netbox/vpn/api/nested_serializers.py | 14 ++ netbox/vpn/api/serializers.py | 16 +- netbox/vpn/api/urls.py | 1 + netbox/vpn/api/views.py | 9 + netbox/vpn/filtersets.py | 20 +- netbox/vpn/forms/bulk_edit.py | 21 +- netbox/vpn/forms/bulk_import.py | 21 +- netbox/vpn/forms/filtersets.py | 11 + netbox/vpn/forms/model_forms.py | 26 ++- netbox/vpn/graphql/schema.py | 6 + netbox/vpn/graphql/types.py | 9 + netbox/vpn/migrations/0001_initial.py | 304 ++++++++++++++------------ netbox/vpn/models/tunnels.py | 36 ++- netbox/vpn/tables/tunnels.py | 23 ++ netbox/vpn/tests/test_api.py | 44 ++++ netbox/vpn/tests/test_filtersets.py | 43 ++++ netbox/vpn/tests/test_views.py | 70 +++++- netbox/vpn/urls.py | 8 + netbox/vpn/views.py | 60 +++++ 26 files changed, 669 insertions(+), 160 deletions(-) create mode 100644 docs/models/vpn/tunnelgroup.md create mode 100644 netbox/templates/vpn/tunnelgroup.html diff --git a/docs/features/vpn-tunnels.md b/docs/features/vpn-tunnels.md index ae6df70c8..4ebb91ab7 100644 --- a/docs/features/vpn-tunnels.md +++ b/docs/features/vpn-tunnels.md @@ -1,6 +1,6 @@ # Tunnels -NetBox can model private tunnels formed among virtual termination points across your network. Typical tunnel implementations include GRE, IP-in-IP, and IPSec. A tunnel may be terminated to two or more device or virtual machine interfaces. +NetBox can model private tunnels formed among virtual termination points across your network. Typical tunnel implementations include GRE, IP-in-IP, and IPSec. A tunnel may be terminated to two or more device or virtual machine interfaces. For convenient organization, tunnels may be assigned to user-defined groups. ```mermaid flowchart TD diff --git a/docs/models/vpn/tunnel.md b/docs/models/vpn/tunnel.md index ebe004da1..31625f7d6 100644 --- a/docs/models/vpn/tunnel.md +++ b/docs/models/vpn/tunnel.md @@ -14,15 +14,17 @@ A unique name assigned to the tunnel for identification. The operational status of the tunnel. By default, the following statuses are available: -| Name | -|----------------| -| Planned | -| Active | -| Disabled | +* Planned +* Active +* Disabled !!! tip "Custom tunnel statuses" Additional tunnel statuses may be defined by setting `Tunnel.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. +### Group + +The [administrative group](./tunnelgroup.md) to which this tunnel is assigned (optional). + ### Encapsulation The encapsulation protocol or technique employed to effect the tunnel. NetBox supports GRE, IP-in-IP, and IPSec encapsulations. diff --git a/docs/models/vpn/tunnelgroup.md b/docs/models/vpn/tunnelgroup.md new file mode 100644 index 000000000..7e3a5c3cc --- /dev/null +++ b/docs/models/vpn/tunnelgroup.md @@ -0,0 +1,13 @@ +# Tunnel Group + +[Tunnels](./tunnel.md) can be arranged into administrative groups for organization. For example, you might crete a group to manage all peer-to-peer tunnels inside a mesh network. The assignment of a tunnel to a group is optional. + +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index bf2ce9de4..1862893ff 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -361,7 +361,7 @@ INTERFACE_BUTTONS = """ {% endif %} {% elif record.type == 'virtual' %} {% if perms.vpn.add_tunnel and not record.tunnel_termination %} - + {% elif perms.vpn.delete_tunneltermination and record.tunnel_termination %} diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index e01e65cc8..d4969386e 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -203,6 +203,7 @@ VPN_MENU = Menu( label=_('Tunnels'), items=( get_model_item('vpn', 'tunnel', _('Tunnels')), + get_model_item('vpn', 'tunnelgroup', _('Tunnel Groups')), get_model_item('vpn', 'tunneltermination', _('Tunnel Terminations')), ), ), diff --git a/netbox/templates/vpn/tunnel.html b/netbox/templates/vpn/tunnel.html index 544ffadae..d1607bd95 100644 --- a/netbox/templates/vpn/tunnel.html +++ b/netbox/templates/vpn/tunnel.html @@ -26,6 +26,10 @@ {% trans "Status" %} {% badge object.get_status_display bg_color=object.get_status_color %} + + {% trans "Group" %} + {{ object.group|linkify|placeholder }} + {% trans "Description" %} {{ object.description|placeholder }} diff --git a/netbox/templates/vpn/tunnelgroup.html b/netbox/templates/vpn/tunnelgroup.html new file mode 100644 index 000000000..3afea48c4 --- /dev/null +++ b/netbox/templates/vpn/tunnelgroup.html @@ -0,0 +1,53 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} +{% load i18n %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block extra_controls %} + {% if perms.vpn.add_tunnel %} + + {% trans "Add Tunnel" %} + + {% endif %} +{% endblock extra_controls %} + +{% block content %} +
+
+
+
+ {% trans "Tunnel Group" %} +
+
+ + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/panels/related_objects.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/vpn/api/nested_serializers.py b/netbox/vpn/api/nested_serializers.py index f2627869b..1042b375e 100644 --- a/netbox/vpn/api/nested_serializers.py +++ b/netbox/vpn/api/nested_serializers.py @@ -1,3 +1,4 @@ +from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers from netbox.api.serializers import WritableNestedSerializer @@ -11,11 +12,24 @@ __all__ = ( 'NestedIPSecProposalSerializer', 'NestedL2VPNSerializer', 'NestedL2VPNTerminationSerializer', + 'NestedTunnelGroupSerializer', 'NestedTunnelSerializer', 'NestedTunnelTerminationSerializer', ) +@extend_schema_serializer( + exclude_fields=('tunnel_count',), +) +class NestedTunnelGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='vpn-api:tunnelgroup-detail') + tunnel_count = serializers.IntegerField(read_only=True) + + class Meta: + model = models.TunnelGroup + fields = ['id', 'url', 'display', 'name', 'slug', 'tunnel_count'] + + class NestedTunnelSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField( view_name='vpn-api:tunnel-detail' diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py index 176deba04..dedcbfbf5 100644 --- a/netbox/vpn/api/serializers.py +++ b/netbox/vpn/api/serializers.py @@ -21,11 +21,24 @@ __all__ = ( 'IPSecProposalSerializer', 'L2VPNSerializer', 'L2VPNTerminationSerializer', + 'TunnelGroupSerializer', 'TunnelSerializer', 'TunnelTerminationSerializer', ) +class TunnelGroupSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='vpn-api:tunnelgroup-detail') + tunnel_count = serializers.IntegerField(read_only=True) + + class Meta: + model = TunnelGroup + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'tunnel_count', + ] + + class TunnelSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField( view_name='vpn-api:tunnel-detail' @@ -33,6 +46,7 @@ class TunnelSerializer(NetBoxModelSerializer): status = ChoiceField( choices=TunnelStatusChoices ) + group = NestedTunnelGroupSerializer() encapsulation = ChoiceField( choices=TunnelEncapsulationChoices ) @@ -48,7 +62,7 @@ class TunnelSerializer(NetBoxModelSerializer): class Meta: model = Tunnel fields = ( - 'id', 'url', 'display', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id', + 'id', 'url', 'display', 'name', 'status', 'group', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ) diff --git a/netbox/vpn/api/urls.py b/netbox/vpn/api/urls.py index 8938532dd..5358325f3 100644 --- a/netbox/vpn/api/urls.py +++ b/netbox/vpn/api/urls.py @@ -8,6 +8,7 @@ router.register('ike-proposals', views.IKEProposalViewSet) router.register('ipsec-policies', views.IPSecPolicyViewSet) router.register('ipsec-proposals', views.IPSecProposalViewSet) router.register('ipsec-profiles', views.IPSecProfileViewSet) +router.register('tunnel-groups', views.TunnelGroupViewSet) router.register('tunnels', views.TunnelViewSet) router.register('tunnel-terminations', views.TunnelTerminationViewSet) router.register('l2vpns', views.L2VPNViewSet) diff --git a/netbox/vpn/api/views.py b/netbox/vpn/api/views.py index 9a691a171..58ad2f47d 100644 --- a/netbox/vpn/api/views.py +++ b/netbox/vpn/api/views.py @@ -14,6 +14,7 @@ __all__ = ( 'IPSecProposalViewSet', 'L2VPNViewSet', 'L2VPNTerminationViewSet', + 'TunnelGroupViewSet', 'TunnelTerminationViewSet', 'TunnelViewSet', 'VPNRootView', @@ -32,6 +33,14 @@ class VPNRootView(APIRootView): # Viewsets # +class TunnelGroupViewSet(NetBoxModelViewSet): + queryset = TunnelGroup.objects.annotate( + tunnel_count=count_related(Tunnel, 'group') + ) + serializer_class = serializers.TunnelGroupSerializer + filterset_class = filtersets.TunnelGroupFilterSet + + class TunnelViewSet(NetBoxModelViewSet): queryset = Tunnel.objects.prefetch_related('ipsec_profile', 'tenant').annotate( terminations_count=count_related(TunnelTermination, 'tunnel') diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 2efd0189c..fbdbb2418 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext as _ from dcim.models import Device, Interface from ipam.models import IPAddress, RouteTarget, VLAN -from netbox.filtersets import NetBoxModelFilterSet +from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter from virtualization.models import VirtualMachine, VMInterface @@ -20,14 +20,32 @@ __all__ = ( 'L2VPNFilterSet', 'L2VPNTerminationFilterSet', 'TunnelFilterSet', + 'TunnelGroupFilterSet', 'TunnelTerminationFilterSet', ) +class TunnelGroupFilterSet(OrganizationalModelFilterSet): + + class Meta: + model = TunnelGroup + fields = ['id', 'name', 'slug', 'description'] + + class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet): status = django_filters.MultipleChoiceFilter( choices=TunnelStatusChoices ) + group_id = django_filters.ModelMultipleChoiceFilter( + queryset=TunnelGroup.objects.all(), + label=_('Tunnel group (ID)'), + ) + group = django_filters.ModelMultipleChoiceFilter( + field_name='group__slug', + queryset=TunnelGroup.objects.all(), + to_field_name='slug', + label=_('Tunnel group (slug)'), + ) encapsulation = django_filters.MultipleChoiceFilter( choices=TunnelEncapsulationChoices ) diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py index 4cbfd950d..a976c5659 100644 --- a/netbox/vpn/forms/bulk_edit.py +++ b/netbox/vpn/forms/bulk_edit.py @@ -17,16 +17,33 @@ __all__ = ( 'L2VPNBulkEditForm', 'L2VPNTerminationBulkEditForm', 'TunnelBulkEditForm', + 'TunnelGroupBulkEditForm', 'TunnelTerminationBulkEditForm', ) +class TunnelGroupBulkEditForm(NetBoxModelBulkEditForm): + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + + model = TunnelGroup + nullable_fields = ('description',) + + class TunnelBulkEditForm(NetBoxModelBulkEditForm): status = forms.ChoiceField( label=_('Status'), choices=add_blank_choice(TunnelStatusChoices), required=False ) + group = DynamicModelChoiceField( + queryset=TunnelGroup.objects.all(), + label=_('Tunnel group'), + required=False + ) encapsulation = forms.ChoiceField( label=_('Encapsulation'), choices=add_blank_choice(TunnelEncapsulationChoices), @@ -55,12 +72,12 @@ class TunnelBulkEditForm(NetBoxModelBulkEditForm): model = Tunnel fieldsets = ( - (_('Tunnel'), ('status', 'encapsulation', 'tunnel_id', 'description')), + (_('Tunnel'), ('status', 'group', 'encapsulation', 'tunnel_id', 'description')), (_('Security'), ('ipsec_profile',)), (_('Tenancy'), ('tenant',)), ) nullable_fields = ( - 'ipsec_profile', 'tunnel_id', 'tenant', 'description', 'comments', + 'group', 'ipsec_profile', 'tunnel_id', 'tenant', 'description', 'comments', ) diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py index 37da63da3..c5d53eb1d 100644 --- a/netbox/vpn/forms/bulk_import.py +++ b/netbox/vpn/forms/bulk_import.py @@ -5,7 +5,7 @@ from dcim.models import Device, Interface from ipam.models import IPAddress, VLAN from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField +from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField from virtualization.models import VirtualMachine, VMInterface from vpn.choices import * from vpn.models import * @@ -19,16 +19,31 @@ __all__ = ( 'L2VPNImportForm', 'L2VPNTerminationImportForm', 'TunnelImportForm', + 'TunnelGroupImportForm', 'TunnelTerminationImportForm', ) +class TunnelGroupImportForm(NetBoxModelImportForm): + slug = SlugField() + + class Meta: + model = TunnelGroup + fields = ('name', 'slug', 'description', 'tags') + + class TunnelImportForm(NetBoxModelImportForm): status = CSVChoiceField( label=_('Status'), choices=TunnelStatusChoices, help_text=_('Operational status') ) + group = CSVModelChoiceField( + label=_('Tunnel group'), + queryset=TunnelGroup.objects.all(), + required=False, + to_field_name='name' + ) encapsulation = CSVChoiceField( label=_('Encapsulation'), choices=TunnelEncapsulationChoices, @@ -51,8 +66,8 @@ class TunnelImportForm(NetBoxModelImportForm): class Meta: model = Tunnel fields = ( - 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id', 'description', 'comments', - 'tags', + 'name', 'status', 'group', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id', 'description', + 'comments', 'tags', ) diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py index 91ca8a8dc..a9326c4bc 100644 --- a/netbox/vpn/forms/filtersets.py +++ b/netbox/vpn/forms/filtersets.py @@ -24,10 +24,16 @@ __all__ = ( 'L2VPNFilterForm', 'L2VPNTerminationFilterForm', 'TunnelFilterForm', + 'TunnelGroupFilterForm', 'TunnelTerminationFilterForm', ) +class TunnelGroupFilterForm(NetBoxModelFilterSetForm): + model = TunnelGroup + tag = TagFilterField(model) + + class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Tunnel fieldsets = ( @@ -41,6 +47,11 @@ class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): choices=TunnelStatusChoices, required=False ) + group_id = DynamicModelMultipleChoiceField( + queryset=TunnelGroup.objects.all(), + required=False, + label=_('Tunnel group') + ) encapsulation = forms.MultipleChoiceField( label=_('Encapsulation'), choices=TunnelEncapsulationChoices, diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 5c3db1c99..5b71c24aa 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -23,11 +23,31 @@ __all__ = ( 'L2VPNTerminationForm', 'TunnelCreateForm', 'TunnelForm', + 'TunnelGroupForm', 'TunnelTerminationForm', ) +class TunnelGroupForm(NetBoxModelForm): + slug = SlugField() + + fieldsets = ( + (_('Tunnel Group'), ('name', 'slug', 'description', 'tags')), + ) + + class Meta: + model = TunnelGroup + fields = [ + 'name', 'slug', 'description', 'tags', + ] + + class TunnelForm(TenancyForm, NetBoxModelForm): + group = DynamicModelChoiceField( + queryset=TunnelGroup.objects.all(), + label=_('Tunnel Group'), + required=False + ) ipsec_profile = DynamicModelChoiceField( queryset=IPSecProfile.objects.all(), label=_('IPSec Profile'), @@ -36,7 +56,7 @@ class TunnelForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')), + (_('Tunnel'), ('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'tags')), (_('Security'), ('ipsec_profile',)), (_('Tenancy'), ('tenant_group', 'tenant')), ) @@ -44,8 +64,8 @@ class TunnelForm(TenancyForm, NetBoxModelForm): class Meta: model = Tunnel fields = [ - 'name', 'status', 'encapsulation', 'description', 'tunnel_id', 'ipsec_profile', 'tenant_group', 'tenant', - 'comments', 'tags', + 'name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'ipsec_profile', 'tenant_group', + 'tenant', 'comments', 'tags', ] diff --git a/netbox/vpn/graphql/schema.py b/netbox/vpn/graphql/schema.py index 9c8e1e502..6737957d4 100644 --- a/netbox/vpn/graphql/schema.py +++ b/netbox/vpn/graphql/schema.py @@ -56,6 +56,12 @@ class VPNQuery(graphene.ObjectType): def resolve_tunnel_list(root, info, **kwargs): return gql_query_optimizer(models.Tunnel.objects.all(), info) + tunnel_group = ObjectField(TunnelGroupType) + tunnel_group_list = ObjectListField(TunnelGroupType) + + def resolve_tunnel_group_list(root, info, **kwargs): + return gql_query_optimizer(models.TunnelGroup.objects.all(), info) + tunnel_termination = ObjectField(TunnelTerminationType) tunnel_termination_list = ObjectListField(TunnelTerminationType) diff --git a/netbox/vpn/graphql/types.py b/netbox/vpn/graphql/types.py index 840a44c7b..0bfebb441 100644 --- a/netbox/vpn/graphql/types.py +++ b/netbox/vpn/graphql/types.py @@ -12,11 +12,20 @@ __all__ = ( 'IPSecProposalType', 'L2VPNType', 'L2VPNTerminationType', + 'TunnelGroupType', 'TunnelTerminationType', 'TunnelType', ) +class TunnelGroupType(OrganizationalObjectType): + + class Meta: + model = models.TunnelGroup + fields = '__all__' + filterset_class = filtersets.TunnelGroupFilterSet + + class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): class Meta: diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py index 17e000e53..efa799293 100644 --- a/netbox/vpn/migrations/0001_initial.py +++ b/netbox/vpn/migrations/0001_initial.py @@ -16,140 +16,7 @@ class Migration(migrations.Migration): ] operations = [ - migrations.CreateModel( - name='IKEPolicy', - 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)), - ('description', models.CharField(blank=True, max_length=200)), - ('comments', models.TextField(blank=True)), - ('name', models.CharField(max_length=100, unique=True)), - ('version', models.PositiveSmallIntegerField(default=2)), - ('mode', models.CharField()), - ('preshared_key', models.TextField(blank=True)), - ], - options={ - 'verbose_name': 'IKE policy', - 'verbose_name_plural': 'IKE policies', - 'ordering': ('name',), - }, - ), - migrations.CreateModel( - name='IPSecPolicy', - 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)), - ('description', models.CharField(blank=True, max_length=200)), - ('comments', models.TextField(blank=True)), - ('name', models.CharField(max_length=100, unique=True)), - ('pfs_group', models.PositiveSmallIntegerField(blank=True, null=True)), - ], - options={ - 'verbose_name': 'IPSec policy', - 'verbose_name_plural': 'IPSec policies', - 'ordering': ('name',), - }, - ), - migrations.CreateModel( - name='IPSecProfile', - 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)), - ('description', models.CharField(blank=True, max_length=200)), - ('comments', models.TextField(blank=True)), - ('name', models.CharField(max_length=100, unique=True)), - ('mode', models.CharField()), - ('ike_policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ikepolicy')), - ('ipsec_policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ipsecpolicy')), - ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), - ], - options={ - 'verbose_name': 'IPSec profile', - 'verbose_name_plural': 'IPSec profiles', - 'ordering': ('name',), - }, - ), - migrations.CreateModel( - name='Tunnel', - 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)), - ('description', models.CharField(blank=True, max_length=200)), - ('comments', models.TextField(blank=True)), - ('name', models.CharField(max_length=100, unique=True)), - ('status', models.CharField(default='active', max_length=50)), - ('encapsulation', models.CharField(max_length=50)), - ('tunnel_id', models.PositiveBigIntegerField(blank=True, null=True)), - ('ipsec_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='vpn.ipsecprofile')), - ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), - ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='tenancy.tenant')), - ], - options={ - 'verbose_name': 'tunnel', - 'verbose_name_plural': 'tunnels', - 'ordering': ('name',), - }, - ), - migrations.CreateModel( - name='TunnelTermination', - 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)), - ('role', models.CharField(default='peer', max_length=50)), - ('termination_id', models.PositiveBigIntegerField(blank=True, null=True)), - ('termination_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), - ('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')), - ], - options={ - 'verbose_name': 'tunnel termination', - 'verbose_name_plural': 'tunnel terminations', - 'ordering': ('tunnel', 'role', 'pk'), - }, - ), - migrations.CreateModel( - name='IPSecProposal', - 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)), - ('description', models.CharField(blank=True, max_length=200)), - ('comments', models.TextField(blank=True)), - ('name', models.CharField(max_length=100, unique=True)), - ('encryption_algorithm', models.CharField()), - ('authentication_algorithm', models.CharField()), - ('sa_lifetime_seconds', models.PositiveIntegerField(blank=True, null=True)), - ('sa_lifetime_data', models.PositiveIntegerField(blank=True, null=True)), - ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), - ], - options={ - 'verbose_name': 'IPSec proposal', - 'verbose_name_plural': 'IPSec proposals', - 'ordering': ('name',), - }, - ), - migrations.AddField( - model_name='ipsecpolicy', - name='proposals', - field=models.ManyToManyField(related_name='ipsec_policies', to='vpn.ipsecproposal'), - ), - migrations.AddField( - model_name='ipsecpolicy', - name='tags', - field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), - ), + # IKE migrations.CreateModel( name='IKEProposal', fields=[ @@ -173,6 +40,26 @@ class Migration(migrations.Migration): 'ordering': ('name',), }, ), + migrations.CreateModel( + name='IKEPolicy', + 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)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('version', models.PositiveSmallIntegerField(default=2)), + ('mode', models.CharField()), + ('preshared_key', models.TextField(blank=True)), + ], + options={ + 'verbose_name': 'IKE policy', + 'verbose_name_plural': 'IKE policies', + 'ordering': ('name',), + }, + ), migrations.AddField( model_name='ikepolicy', name='proposals', @@ -183,6 +70,155 @@ class Migration(migrations.Migration): name='tags', field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), ), + + # IPSec + migrations.CreateModel( + name='IPSecProposal', + 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)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('encryption_algorithm', models.CharField()), + ('authentication_algorithm', models.CharField()), + ('sa_lifetime_seconds', models.PositiveIntegerField(blank=True, null=True)), + ('sa_lifetime_data', models.PositiveIntegerField(blank=True, null=True)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'IPSec proposal', + 'verbose_name_plural': 'IPSec proposals', + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='IPSecPolicy', + 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)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('pfs_group', models.PositiveSmallIntegerField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'IPSec policy', + 'verbose_name_plural': 'IPSec policies', + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='ipsecpolicy', + name='proposals', + field=models.ManyToManyField(related_name='ipsec_policies', to='vpn.ipsecproposal'), + ), + migrations.AddField( + model_name='ipsecpolicy', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.CreateModel( + name='IPSecProfile', + 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)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('mode', models.CharField()), + ('ike_policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ikepolicy')), + ('ipsec_policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ipsecpolicy')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'IPSec profile', + 'verbose_name_plural': 'IPSec profiles', + 'ordering': ('name',), + }, + ), + + # Tunnels + migrations.CreateModel( + name='TunnelGroup', + 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)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ], + options={ + 'verbose_name': 'tunnel group', + 'verbose_name_plural': 'tunnel groups', + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='tunnelgroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.CreateModel( + name='Tunnel', + 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)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('status', models.CharField(default='active', max_length=50)), + ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='vpn.tunnelgroup')), + ('encapsulation', models.CharField(max_length=50)), + ('tunnel_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('ipsec_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='vpn.ipsecprofile')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='tenancy.tenant')), + ], + options={ + 'verbose_name': 'tunnel', + 'verbose_name_plural': 'tunnels', + 'ordering': ('name',), + }, + ), + migrations.AddConstraint( + model_name='tunnel', + constraint=models.UniqueConstraint(fields=('group', 'name'), name='vpn_tunnel_group_name'), + ), + migrations.AddConstraint( + model_name='tunnel', + constraint=models.UniqueConstraint(condition=models.Q(('group__isnull', True)), fields=('name',), name='vpn_tunnel_name'), + ), + migrations.CreateModel( + name='TunnelTermination', + 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)), + ('role', models.CharField(default='peer', max_length=50)), + ('termination_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('termination_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('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')), + ], + options={ + 'verbose_name': 'tunnel termination', + 'verbose_name_plural': 'tunnel terminations', + 'ordering': ('tunnel', 'role', 'pk'), + }, + ), migrations.AddConstraint( model_name='tunneltermination', constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='vpn_tunneltermination_termination', violation_error_message='An object may be terminated to only one tunnel at a time.'), diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index f7390d0b4..c1d262d3c 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -1,19 +1,35 @@ 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 netbox.models import ChangeLoggedModel, PrimaryModel +from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel from netbox.models.features import CustomFieldsMixin, CustomLinksMixin, TagsMixin from vpn.choices import * __all__ = ( 'Tunnel', + 'TunnelGroup', 'TunnelTermination', ) +class TunnelGroup(OrganizationalModel): + """ + An administrative grouping of Tunnels. This can be used to correlate peer-to-peer tunnels which form a mesh, + for example. + """ + class Meta: + ordering = ('name',) + verbose_name = _('tunnel group') + verbose_name_plural = _('tunnel groups') + + def get_absolute_url(self): + return reverse('vpn:tunnelgroup', args=[self.pk]) + + class Tunnel(PrimaryModel): name = models.CharField( verbose_name=_('name'), @@ -26,6 +42,13 @@ class Tunnel(PrimaryModel): choices=TunnelStatusChoices, default=TunnelStatusChoices.STATUS_ACTIVE ) + group = models.ForeignKey( + to='vpn.TunnelGroup', + on_delete=models.PROTECT, + related_name='tunnels', + blank=True, + null=True + ) encapsulation = models.CharField( verbose_name=_('encapsulation'), max_length=50, @@ -57,6 +80,17 @@ class Tunnel(PrimaryModel): class Meta: ordering = ('name',) + constraints = ( + models.UniqueConstraint( + fields=('group', 'name'), + name='%(app_label)s_%(class)s_group_name' + ), + models.UniqueConstraint( + fields=('name',), + name='%(app_label)s_%(class)s_name', + condition=Q(group__isnull=True) + ), + ) verbose_name = _('tunnel') verbose_name_plural = _('tunnels') diff --git a/netbox/vpn/tables/tunnels.py b/netbox/vpn/tables/tunnels.py index 9c4ba816d..c10985733 100644 --- a/netbox/vpn/tables/tunnels.py +++ b/netbox/vpn/tables/tunnels.py @@ -8,10 +8,33 @@ from vpn.models import * __all__ = ( 'TunnelTable', + 'TunnelGroupTable', 'TunnelTerminationTable', ) +class TunnelGroupTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + tunnel_count = columns.LinkedCountColumn( + viewname='vpn:tunnel_list', + url_params={'group_id': 'pk'}, + verbose_name=_('Tunnels') + ) + tags = columns.TagColumn( + url_name='vpn:tunnelgroup_list' + ) + + class Meta(NetBoxTable.Meta): + model = TunnelGroup + fields = ( + 'pk', 'id', 'name', 'tunnel_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'tunnel_count', 'description') + + class TunnelTable(TenancyColumnsMixin, NetBoxTable): name = tables.Column( verbose_name=_('Name'), diff --git a/netbox/vpn/tests/test_api.py b/netbox/vpn/tests/test_api.py index 2714bd4fc..eb0520c8b 100644 --- a/netbox/vpn/tests/test_api.py +++ b/netbox/vpn/tests/test_api.py @@ -17,6 +17,38 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) +class TunnelGroupTest(APIViewTestCases.APIViewTestCase): + model = TunnelGroup + brief_fields = ['display', 'id', 'name', 'slug', 'tunnel_count', 'url'] + create_data = ( + { + 'name': 'Tunnel Group 4', + 'slug': 'tunnel-group-4', + }, + { + 'name': 'Tunnel Group 5', + 'slug': 'tunnel-group-5', + }, + { + 'name': 'Tunnel Group 6', + 'slug': 'tunnel-group-6', + }, + ) + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + tunnel_groups = ( + TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'), + TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'), + TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'), + ) + TunnelGroup.objects.bulk_create(tunnel_groups) + + class TunnelTest(APIViewTestCases.APIViewTestCase): model = Tunnel brief_fields = ['display', 'id', 'name', 'url'] @@ -29,20 +61,29 @@ class TunnelTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): + tunnel_groups = ( + TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'), + TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'), + ) + TunnelGroup.objects.bulk_create(tunnel_groups) + tunnels = ( Tunnel( name='Tunnel 1', status=TunnelStatusChoices.STATUS_ACTIVE, + group=tunnel_groups[0], encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP ), Tunnel( name='Tunnel 2', status=TunnelStatusChoices.STATUS_ACTIVE, + group=tunnel_groups[0], encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP ), Tunnel( name='Tunnel 3', status=TunnelStatusChoices.STATUS_ACTIVE, + group=tunnel_groups[0], encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP ), ) @@ -52,16 +93,19 @@ class TunnelTest(APIViewTestCases.APIViewTestCase): { 'name': 'Tunnel 4', 'status': TunnelStatusChoices.STATUS_DISABLED, + 'group': tunnel_groups[1].pk, 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE, }, { 'name': 'Tunnel 5', 'status': TunnelStatusChoices.STATUS_DISABLED, + 'group': tunnel_groups[1].pk, 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE, }, { 'name': 'Tunnel 6', 'status': TunnelStatusChoices.STATUS_DISABLED, + 'group': tunnel_groups[1].pk, 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE, }, ] diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py index a9eeb1203..2ce3b2dde 100644 --- a/netbox/vpn/tests/test_filtersets.py +++ b/netbox/vpn/tests/test_filtersets.py @@ -11,6 +11,32 @@ from vpn.filtersets import * from vpn.models import * +class TunnelGroupTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = TunnelGroup.objects.all() + filterset = TunnelGroupFilterSet + + @classmethod + def setUpTestData(cls): + + TunnelGroup.objects.bulk_create(( + TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1', description='foobar1'), + TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2', description='foobar2'), + TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'), + )) + + def test_name(self): + params = {'name': ['Tunnel Group 1']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_slug(self): + params = {'slug': ['tunnel-group-1']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_description(self): + params = {'description': ['foobar1', 'foobar2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Tunnel.objects.all() filterset = TunnelFilterSet @@ -56,10 +82,18 @@ class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests): ) IPSecProfile.objects.bulk_create(ipsec_profiles) + tunnel_groups = ( + TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'), + TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'), + TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'), + ) + TunnelGroup.objects.bulk_create(tunnel_groups) + tunnels = ( Tunnel( name='Tunnel 1', status=TunnelStatusChoices.STATUS_ACTIVE, + group=tunnel_groups[0], encapsulation=TunnelEncapsulationChoices.ENCAP_GRE, ipsec_profile=ipsec_profiles[0], tunnel_id=100 @@ -67,6 +101,7 @@ class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests): Tunnel( name='Tunnel 2', status=TunnelStatusChoices.STATUS_PLANNED, + group=tunnel_groups[1], encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP, ipsec_profile=ipsec_profiles[0], tunnel_id=200 @@ -74,6 +109,7 @@ class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests): Tunnel( name='Tunnel 3', status=TunnelStatusChoices.STATUS_DISABLED, + group=tunnel_groups[2], encapsulation=TunnelEncapsulationChoices.ENCAP_IPSEC_TUNNEL, ipsec_profile=None, tunnel_id=300 @@ -89,6 +125,13 @@ class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'status': [TunnelStatusChoices.STATUS_ACTIVE, TunnelStatusChoices.STATUS_PLANNED]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_group(self): + tunnel_groups = TunnelGroup.objects.all()[:2] + params = {'group_id': [tunnel_groups[0].pk, tunnel_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [tunnel_groups[0].slug, tunnel_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_encapsulation(self): params = {'encapsulation': [TunnelEncapsulationChoices.ENCAP_GRE, TunnelEncapsulationChoices.ENCAP_IP_IP]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/vpn/tests/test_views.py b/netbox/vpn/tests/test_views.py index 4d9080422..ab797d9fd 100644 --- a/netbox/vpn/tests/test_views.py +++ b/netbox/vpn/tests/test_views.py @@ -6,26 +6,78 @@ from vpn.choices import * from vpn.models import * +class TunnelGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = TunnelGroup + + @classmethod + def setUpTestData(cls): + + tunnel_groups = ( + TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'), + TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'), + TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'), + ) + TunnelGroup.objects.bulk_create(tunnel_groups) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Tunnel Group X', + 'slug': 'tunnel-group-x', + 'description': 'A new Tunnel Group', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,slug", + "Tunnel Group 4,tunnel-group-4", + "Tunnel Group 5,tunnel-group-5", + "Tunnel Group 6,tunnel-group-6", + ) + + cls.csv_update_data = ( + "id,name,description", + f"{tunnel_groups[0].pk},Tunnel Group 7,New description7", + f"{tunnel_groups[1].pk},Tunnel Group 8,New description8", + f"{tunnel_groups[2].pk},Tunnel Group 9,New description9", + ) + + cls.bulk_edit_data = { + 'description': 'Foo', + } + + class TunnelTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Tunnel @classmethod def setUpTestData(cls): + tunnel_groups = ( + TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'), + TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'), + TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'), + TunnelGroup(name='Tunnel Group 4', slug='tunnel-group-4'), + ) + TunnelGroup.objects.bulk_create(tunnel_groups) + tunnels = ( Tunnel( name='Tunnel 1', status=TunnelStatusChoices.STATUS_ACTIVE, + group=tunnel_groups[0], encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP ), Tunnel( name='Tunnel 2', status=TunnelStatusChoices.STATUS_ACTIVE, + group=tunnel_groups[1], encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP ), Tunnel( name='Tunnel 3', status=TunnelStatusChoices.STATUS_ACTIVE, + group=tunnel_groups[2], encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP ), ) @@ -37,26 +89,28 @@ class TunnelTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'name': 'Tunnel X', 'description': 'New tunnel', 'status': TunnelStatusChoices.STATUS_PLANNED, + 'group': tunnel_groups[3].pk, 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE, 'tags': [t.pk for t in tags], } cls.csv_data = ( - "name,status,encapsulation", - "Tunnel 4,planned,gre", - "Tunnel 5,planned,gre", - "Tunnel 6,planned,gre", + "name,status,group,encapsulation", + "Tunnel 4,planned,Tunnel Group 1,gre", + "Tunnel 5,planned,Tunnel Group 2,gre", + "Tunnel 6,planned,Tunnel Group 3,gre", ) cls.csv_update_data = ( - "id,status,encapsulation", - f"{tunnels[0].pk},active,ip-ip", - f"{tunnels[1].pk},active,ip-ip", - f"{tunnels[2].pk},active,ip-ip", + "id,status,group,encapsulation", + f"{tunnels[0].pk},active,Tunnel Group 4,ip-ip", + f"{tunnels[1].pk},active,Tunnel Group 4,ip-ip", + f"{tunnels[2].pk},active,Tunnel Group 4,ip-ip", ) cls.bulk_edit_data = { 'description': 'New description', + 'group': tunnel_groups[3].pk, 'status': TunnelStatusChoices.STATUS_DISABLED, 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE, } diff --git a/netbox/vpn/urls.py b/netbox/vpn/urls.py index 2bf684313..552f0e185 100644 --- a/netbox/vpn/urls.py +++ b/netbox/vpn/urls.py @@ -6,6 +6,14 @@ from . import views app_name = 'vpn' urlpatterns = [ + # Tunnel groups + path('tunnel-groups/', views.TunnelGroupListView.as_view(), name='tunnelgroup_list'), + path('tunnel-groups/add/', views.TunnelGroupEditView.as_view(), name='tunnelgroup_add'), + path('tunnel-groups/import/', views.TunnelGroupBulkImportView.as_view(), name='tunnelgroup_import'), + path('tunnel-groups/edit/', views.TunnelGroupBulkEditView.as_view(), name='tunnelgroup_bulk_edit'), + path('tunnel-groups/delete/', views.TunnelGroupBulkDeleteView.as_view(), name='tunnelgroup_bulk_delete'), + path('tunnel-groups//', include(get_model_urls('vpn', 'tunnelgroup'))), + # Tunnels path('tunnels/', views.TunnelListView.as_view(), name='tunnel_list'), path('tunnels/add/', views.TunnelEditView.as_view(), name='tunnel_add'), diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py index f230e4828..9bf424af9 100644 --- a/netbox/vpn/views.py +++ b/netbox/vpn/views.py @@ -7,6 +7,66 @@ from . import filtersets, forms, tables from .models import * +# +# Tunnel groups +# + +class TunnelGroupListView(generic.ObjectListView): + queryset = TunnelGroup.objects.annotate( + tunnel_count=count_related(Tunnel, 'group') + ) + filterset = filtersets.TunnelGroupFilterSet + filterset_form = forms.TunnelGroupFilterForm + table = tables.TunnelGroupTable + + +@register_model_view(TunnelGroup) +class TunnelGroupView(generic.ObjectView): + queryset = TunnelGroup.objects.all() + + def get_extra_context(self, request, instance): + related_models = ( + (Tunnel.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'), + ) + + return { + 'related_models': related_models, + } + + +@register_model_view(TunnelGroup, 'edit') +class TunnelGroupEditView(generic.ObjectEditView): + queryset = TunnelGroup.objects.all() + form = forms.TunnelGroupForm + + +@register_model_view(TunnelGroup, 'delete') +class TunnelGroupDeleteView(generic.ObjectDeleteView): + queryset = TunnelGroup.objects.all() + + +class TunnelGroupBulkImportView(generic.BulkImportView): + queryset = TunnelGroup.objects.all() + model_form = forms.TunnelGroupImportForm + + +class TunnelGroupBulkEditView(generic.BulkEditView): + queryset = TunnelGroup.objects.annotate( + tunnel_count=count_related(Tunnel, 'group') + ) + filterset = filtersets.TunnelGroupFilterSet + table = tables.TunnelGroupTable + form = forms.TunnelGroupBulkEditForm + + +class TunnelGroupBulkDeleteView(generic.BulkDeleteView): + queryset = TunnelGroup.objects.annotate( + tunnel_count=count_related(Tunnel, 'group') + ) + filterset = filtersets.TunnelGroupFilterSet + table = tables.TunnelGroupTable + + # # Tunnels #