From dfb5a06d9dea49dbb3b215126e654aeda67a7821 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Sep 2020 11:25:52 -0400 Subject: [PATCH] Introduce the RouteTarget model --- netbox/ipam/api/nested_serializers.py | 13 +++ netbox/ipam/api/serializers.py | 22 +++-- netbox/ipam/api/urls.py | 3 + netbox/ipam/api/views.py | 12 ++- netbox/ipam/constants.py | 1 + netbox/ipam/filters.py | 23 ++++- netbox/ipam/forms.py | 63 +++++++++++++- netbox/ipam/migrations/0041_routetarget.py | 34 ++++++++ netbox/ipam/models.py | 44 ++++++++++ netbox/ipam/tables.py | 22 ++++- netbox/ipam/urls.py | 13 ++- netbox/ipam/views.py | 52 +++++++++++- netbox/templates/inc/nav_menu.html | 9 ++ netbox/templates/ipam/routetarget.html | 98 ++++++++++++++++++++++ 14 files changed, 397 insertions(+), 12 deletions(-) create mode 100644 netbox/ipam/migrations/0041_routetarget.py create mode 100644 netbox/templates/ipam/routetarget.html diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index d40c9bb29..004ac070c 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -9,6 +9,7 @@ __all__ = [ 'NestedPrefixSerializer', 'NestedRIRSerializer', 'NestedRoleSerializer', + 'NestedRouteTargetSerializer', 'NestedServiceSerializer', 'NestedVLANGroupSerializer', 'NestedVLANSerializer', @@ -29,6 +30,18 @@ class NestedVRFSerializer(WritableNestedSerializer): fields = ['id', 'url', 'name', 'rd', 'display_name', 'prefix_count'] +# +# Route targets +# + +class NestedRouteTargetSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail') + + class Meta: + model = models.RouteTarget + fields = ['id', 'url', 'name'] + + # # RIRs/aggregates # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 074cba9d6..ffaefad6d 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -3,20 +3,17 @@ from collections import OrderedDict from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from rest_framework.reverse import reverse from rest_framework.validators import UniqueTogetherValidator from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer -from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import ( - ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, - get_serializer_for_model, + ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, get_serializer_for_model, ) from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from .nested_serializers import * @@ -40,6 +37,21 @@ class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): ] +# +# Route targets +# + +class RouteTargetSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail') + tenant = NestedTenantSerializer(required=False, allow_null=True) + + class Meta: + model = RouteTarget + fields = [ + 'id', 'url', 'name', 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + ] + + # # RIRs/aggregates # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index e297d6451..a8cbf7a29 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -8,6 +8,9 @@ router.APIRootView = views.IPAMRootView # VRFs router.register('vrfs', views.VRFViewSet) +# Route targets +router.register('route-targets', views.RouteTargetViewSet) + # RIRs router.register('rirs', views.RIRViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index dd0731bb8..69277a8ea 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -10,7 +10,7 @@ from rest_framework.routers import APIRootView from extras.api.views import CustomFieldModelViewSet from ipam import filters -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from utilities.api import ModelViewSet from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import get_subquery @@ -38,6 +38,16 @@ class VRFViewSet(CustomFieldModelViewSet): filterset_class = filters.VRFFilterSet +# +# Route targets +# + +class RouteTargetViewSet(CustomFieldModelViewSet): + queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags') + serializer_class = serializers.RouteTargetSerializer + filterset_class = filters.RouteTargetFilterSet + + # # RIRs # diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 1ad355aec..e8825ad18 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -16,6 +16,7 @@ BGP_ASN_MAX = 2**32 - 1 # * Type 1 (32-bit IPv4 address : 16-bit integer) # * Type 2 (32-bit AS number : 16-bit integer) # 21 characters are sufficient to convey the longest possible string value (255.255.255.255:65535) +# Also used for RouteTargets VRF_RD_MAX_LENGTH = 21 diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 69453ea6c..6059b4330 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -13,7 +13,7 @@ from utilities.filters import ( ) from virtualization.models import VirtualMachine, VMInterface from .choices import * -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF __all__ = ( @@ -22,6 +22,7 @@ __all__ = ( 'PrefixFilterSet', 'RIRFilterSet', 'RoleFilterSet', + 'RouteTargetFilterSet', 'ServiceFilterSet', 'VLANFilterSet', 'VLANGroupFilterSet', @@ -50,6 +51,26 @@ class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Create fields = ['id', 'name', 'rd', 'enforce_unique'] +class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + tag = TagFilter() + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + + class Meta: + model = RouteTarget + fields = ['id', 'name'] + + class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index fd1dd00c6..ed6071756 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,5 +1,4 @@ from django import forms -from django.core.validators import MaxValueValidator, MinValueValidator from dcim.models import Device, Interface, Rack, Region, Site from extras.forms import ( @@ -16,7 +15,7 @@ from utilities.forms import ( from virtualization.models import Cluster, VirtualMachine, VMInterface from .choices import * from .constants import * -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([ (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1) @@ -98,6 +97,66 @@ class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): tag = TagFilterField(model) +# +# Route targets +# + +class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = RouteTarget + fields = [ + 'name', 'description', 'tenant_group', 'tenant', 'tags', + ] + + +class RouteTargetCSVForm(CustomFieldModelCSVForm): + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + model = RouteTarget + fields = RouteTarget.csv_headers + + +class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + widget=forms.MultipleHiddenInput() + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = [ + 'tenant', 'description', + ] + + +class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): + model = RouteTarget + field_order = ['q', 'name', 'tenant_group', 'tenant'] + q = forms.CharField( + required=False, + label='Search' + ) + tag = TagFilterField(model) + + # # RIRs # diff --git a/netbox/ipam/migrations/0041_routetarget.py b/netbox/ipam/migrations/0041_routetarget.py new file mode 100644 index 000000000..d2e800be2 --- /dev/null +++ b/netbox/ipam/migrations/0041_routetarget.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1 on 2020-09-24 15:19 + +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0010_custom_field_data'), + ('extras', '0052_delete_customfieldchoice_customfieldvalue'), + ('ipam', '0040_service_drop_port'), + ] + + operations = [ + migrations.CreateModel( + name='RouteTarget', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), + ('name', models.CharField(max_length=21, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('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='route_targets', to='tenancy.tenant')), + ], + options={ + 'ordering': ['name'], + }, + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index c11f0e296..f743fe5b0 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -107,6 +107,50 @@ class VRF(ChangeLoggedModel, CustomFieldModel): return self.name +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class RouteTarget(ChangeLoggedModel, CustomFieldModel): + """ + A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. + """ + name = models.CharField( + max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4) + unique=True, + help_text='Route target value (formatted in accordance with RFC 4360)' + ) + description = models.CharField( + max_length=200, + blank=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='route_targets', + blank=True, + null=True + ) + tags = TaggableManager(through=TaggedItem) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['name', 'description', 'tenant'] + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('ipam:routetarget', args=[self.pk]) + + def to_csv(self): + return ( + self.name, + self.description, + self.tenant.name if self.tenant else None, + ) + + class RIR(ChangeLoggedModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 3e89ece64..6a76b5c91 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -5,7 +5,7 @@ from dcim.models import Interface from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn from virtualization.models import VMInterface -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF RIR_UTILIZATION = """
@@ -176,6 +176,26 @@ class VRFTable(BaseTable): default_columns = ('pk', 'name', 'rd', 'tenant', 'description') +# +# Route targets +# + +class RouteTargetTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) + tags = TagColumn( + url_name='ipam:vrf_list' + ) + + class Meta(BaseTable.Meta): + model = RouteTarget + fields = ('pk', 'name', 'tenant', 'description', 'tags') + default_columns = ('pk', 'name', 'tenant', 'description') + + # # RIRs # diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 533335816..9b0dc581b 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -2,7 +2,7 @@ from django.urls import path from extras.views import ObjectChangeLogView from . import views -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF app_name = 'ipam' urlpatterns = [ @@ -18,6 +18,17 @@ urlpatterns = [ path('vrfs//delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), + # Route targets + path('route-targets/', views.RouteTargetListView.as_view(), name='routetarget_list'), + path('route-targets/add/', views.RouteTargetEditView.as_view(), name='routetarget_add'), + path('route-targets/import/', views.RouteTargetBulkImportView.as_view(), name='routetarget_import'), + path('route-targets/edit/', views.RouteTargetBulkEditView.as_view(), name='routetarget_bulk_edit'), + path('route-targets/delete/', views.RouteTargetBulkDeleteView.as_view(), name='routetarget_bulk_delete'), + path('route-targets//', views.RouteTargetView.as_view(), name='routetarget'), + path('route-targets//edit/', views.RouteTargetEditView.as_view(), name='routetarget_edit'), + path('route-targets//delete/', views.RouteTargetDeleteView.as_view(), name='routetarget_delete'), + path('route-targets//changelog/', ObjectChangeLogView.as_view(), name='routetarget_changelog', kwargs={'model': RouteTarget}), + # RIRs path('rirs/', views.RIRListView.as_view(), name='rir_list'), path('rirs/add/', views.RIREditView.as_view(), name='rir_add'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 64e71b69b..240bfedd3 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -16,7 +16,7 @@ from virtualization.models import VirtualMachine, VMInterface from . import filters, forms, tables from .choices import * from .constants import * -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans @@ -74,6 +74,56 @@ class VRFBulkDeleteView(BulkDeleteView): table = tables.VRFTable +# +# Route targets +# + +class RouteTargetListView(ObjectListView): + queryset = RouteTarget.objects.prefetch_related('tenant') + filterset = filters.RouteTargetFilterSet + filterset_form = forms.RouteTargetFilterForm + table = tables.RouteTargetTable + + +class RouteTargetView(ObjectView): + queryset = RouteTarget.objects.all() + + def get(self, request, pk): + routetarget = get_object_or_404(self.queryset, pk=pk) + + return render(request, 'ipam/routetarget.html', { + 'routetarget': routetarget, + }) + + +class RouteTargetEditView(ObjectEditView): + queryset = RouteTarget.objects.all() + model_form = forms.RouteTargetForm + + +class RouteTargetDeleteView(ObjectDeleteView): + queryset = RouteTarget.objects.all() + + +class RouteTargetBulkImportView(BulkImportView): + queryset = RouteTarget.objects.all() + model_form = forms.RouteTargetCSVForm + table = tables.RouteTargetTable + + +class RouteTargetBulkEditView(BulkEditView): + queryset = RouteTarget.objects.prefetch_related('tenant') + filterset = filters.RouteTargetFilterSet + table = tables.RouteTargetTable + form = forms.RouteTargetBulkEditForm + + +class RouteTargetBulkDeleteView(BulkDeleteView): + queryset = RouteTarget.objects.prefetch_related('tenant') + filterset = filters.RouteTargetFilterSet + table = tables.RouteTargetTable + + # # RIRs # diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index bf3d349cc..12c579bdd 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -331,6 +331,15 @@ {% endif %} VRFs + + {% if perms.ipam.add_routetarget %} +
+ + +
+ {% endif %} + Route Targets +
  • diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html new file mode 100644 index 000000000..d891f9241 --- /dev/null +++ b/netbox/templates/ipam/routetarget.html @@ -0,0 +1,98 @@ +{% extends 'base.html' %} +{% load buttons %} +{% load custom_links %} +{% load helpers %} +{% load plugins %} + +{% block header %} +
    +
    + +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    + {% plugin_buttons routetarget %} + {% if perms.ipam.add_routetarget %} + {% clone_button routetarget %} + {% endif %} + {% if perms.ipam.change_routetarget %} + {% edit_button routetarget %} + {% endif %} + {% if perms.ipam.delete_routetarget %} + {% delete_button routetarget %} + {% endif %} +
    +

    {% block title %}Route target {{ routetarget }}{% endblock %}

    + {% include 'inc/created_updated.html' with obj=routetarget %} +
    + {% custom_links routetarget %} +
    + +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Route Target +
    + + + + + + + + + + + + + +
    Name{{ routetarget.name }}
    Tenant + {% if routetarget.tenant %} + {{ routetarget.tenant }} + {% else %} + None + {% endif %} +
    Description{{ vrf.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=routetarget.tags.all url='ipam:routetarget_list' %} + {% plugin_left_page routetarget %} +
    +
    + {% include 'inc/custom_fields_panel.html' with obj=routetarget %} + {% plugin_right_page routetarget %} +
    +
    +
    +
    + {% plugin_full_width_page routetarget %} +
    +
    +{% endblock %}