Przeglądaj źródła

Merge pull request #5178 from netbox-community/259-route-targets

#259: Route target support
Jeremy Stretch 5 lat temu
rodzic
commit
ee63cbe7e9

+ 1 - 0
docs/core-functionality/ipam.md

@@ -15,3 +15,4 @@
 ---
 ---
 
 
 {!docs/models/ipam/vrf.md!}
 {!docs/models/ipam/vrf.md!}
+{!docs/models/ipam/routetarget.md!}

+ 5 - 0
docs/models/ipam/routetarget.md

@@ -0,0 +1,5 @@
+# Route Targets
+
+A route target is a particular type of [extended BGP community](https://tools.ietf.org/html/rfc4360#section-4) used to control the redistribution of routes among VRF tables in a network. Route targets can be assigned to individual VRFs in NetBox as import or export targets (or both) to model this exchange in an L3VPN. Each route target must be given a unique name, which should be in a format prescribed by [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), similar to a VR route distinguisher.
+
+Each route target can optionally be assigned to a tenant, and may have tags assigned to it.

+ 2 - 0
docs/models/ipam/vrf.md

@@ -10,3 +10,5 @@ By default, NetBox will allow duplicate prefixes to be assigned to a VRF. This b
 
 
 !!! note
 !!! note
     Enforcement of unique IP space can be toggled for global table (non-VRF prefixes) using the `ENFORCE_GLOBAL_UNIQUE` configuration setting.
     Enforcement of unique IP space can be toggled for global table (non-VRF prefixes) using the `ENFORCE_GLOBAL_UNIQUE` configuration setting.
+
+Each VRF may have one or more import and/or export route targets applied to it. Route targets are used to control the exchange of routes (prefixes) among VRFs in L3VPNs.

+ 4 - 0
docs/release-notes/version-2.10.md

@@ -6,6 +6,10 @@
 
 
 ### New Features
 ### New Features
 
 
+#### Route Targets ([#259](https://github.com/netbox-community/netbox/issues/259))
+
+This release introduces support for model L3VPN route targets, which can be used to control the redistribution of routing information among VRFs. Each VRF may be assigned one or more route targets in the import or export direction (or both). Like VRFs, route targets may be assigned to tenants and may have tags applied to them.
+
 #### REST API Bulk Deletion ([#3436](https://github.com/netbox-community/netbox/issues/3436))
 #### REST API Bulk Deletion ([#3436](https://github.com/netbox-community/netbox/issues/3436))
 
 
 The REST API now supports the bulk deletion of objects of the same type in a single request. Send a `DELETE` HTTP request to the list to the model's list endpoint (e.g. `/api/dcim/sites/`) with a list of JSON objects specifying the numeric ID of each object to be deleted. For example, to delete sites with IDs 10, 11, and 12, issue the following request:
 The REST API now supports the bulk deletion of objects of the same type in a single request. Send a `DELETE` HTTP request to the list to the model's list endpoint (e.g. `/api/dcim/sites/`) with a list of JSON objects specifying the numeric ID of each object to be deleted. For example, to delete sites with IDs 10, 11, and 12, issue the following request:

+ 13 - 0
netbox/ipam/api/nested_serializers.py

@@ -9,6 +9,7 @@ __all__ = [
     'NestedPrefixSerializer',
     'NestedPrefixSerializer',
     'NestedRIRSerializer',
     'NestedRIRSerializer',
     'NestedRoleSerializer',
     'NestedRoleSerializer',
+    'NestedRouteTargetSerializer',
     'NestedServiceSerializer',
     'NestedServiceSerializer',
     'NestedVLANGroupSerializer',
     'NestedVLANGroupSerializer',
     'NestedVLANSerializer',
     'NestedVLANSerializer',
@@ -29,6 +30,18 @@ class NestedVRFSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'name', 'rd', 'display_name', 'prefix_count']
         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
 # RIRs/aggregates
 #
 #

+ 21 - 7
netbox/ipam/api/serializers.py

@@ -3,20 +3,17 @@ from collections import OrderedDict
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from drf_yasg.utils import swagger_serializer_method
 from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 from rest_framework import serializers
-from rest_framework.reverse import reverse
 from rest_framework.validators import UniqueTogetherValidator
 from rest_framework.validators import UniqueTogetherValidator
 
 
 from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
 from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
-from dcim.models import Interface
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.serializers import TaggedObjectSerializer
 from extras.api.serializers import TaggedObjectSerializer
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
 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 tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import (
 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 virtualization.api.nested_serializers import NestedVirtualMachineSerializer
 from .nested_serializers import *
 from .nested_serializers import *
@@ -29,14 +26,31 @@ from .nested_serializers import *
 class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
 class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
+    import_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True)
+    export_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True)
     ipaddress_count = serializers.IntegerField(read_only=True)
     ipaddress_count = serializers.IntegerField(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
         fields = [
         fields = [
-            'id', 'url', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name',
-            'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'prefix_count',
+            'id', 'url', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets',
+            'tags', 'display_name', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'prefix_count',
+        ]
+
+
+#
+# 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',
         ]
         ]
 
 
 
 

+ 3 - 0
netbox/ipam/api/urls.py

@@ -8,6 +8,9 @@ router.APIRootView = views.IPAMRootView
 # VRFs
 # VRFs
 router.register('vrfs', views.VRFViewSet)
 router.register('vrfs', views.VRFViewSet)
 
 
+# Route targets
+router.register('route-targets', views.RouteTargetViewSet)
+
 # RIRs
 # RIRs
 router.register('rirs', views.RIRViewSet)
 router.register('rirs', views.RIRViewSet)
 
 

+ 14 - 2
netbox/ipam/api/views.py

@@ -10,7 +10,7 @@ from rest_framework.routers import APIRootView
 
 
 from extras.api.views import CustomFieldModelViewSet
 from extras.api.views import CustomFieldModelViewSet
 from ipam import filters
 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.api import ModelViewSet
 from utilities.constants import ADVISORY_LOCK_KEYS
 from utilities.constants import ADVISORY_LOCK_KEYS
 from utilities.utils import get_subquery
 from utilities.utils import get_subquery
@@ -30,7 +30,9 @@ class IPAMRootView(APIRootView):
 #
 #
 
 
 class VRFViewSet(CustomFieldModelViewSet):
 class VRFViewSet(CustomFieldModelViewSet):
-    queryset = VRF.objects.prefetch_related('tenant').prefetch_related('tags').annotate(
+    queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
+        'import_targets', 'export_targets', 'tags'
+    ).annotate(
         ipaddress_count=get_subquery(IPAddress, 'vrf'),
         ipaddress_count=get_subquery(IPAddress, 'vrf'),
         prefix_count=get_subquery(Prefix, 'vrf')
         prefix_count=get_subquery(Prefix, 'vrf')
     ).order_by(*VRF._meta.ordering)
     ).order_by(*VRF._meta.ordering)
@@ -38,6 +40,16 @@ class VRFViewSet(CustomFieldModelViewSet):
     filterset_class = filters.VRFFilterSet
     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
 # RIRs
 #
 #

+ 1 - 0
netbox/ipam/constants.py

@@ -16,6 +16,7 @@ BGP_ASN_MAX = 2**32 - 1
 #   * Type 1 (32-bit IPv4 address : 16-bit integer)
 #   * Type 1 (32-bit IPv4 address : 16-bit integer)
 #   * Type 2 (32-bit AS number : 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)
 # 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
 VRF_RD_MAX_LENGTH = 21
 
 
 
 

+ 66 - 1
netbox/ipam/filters.py

@@ -13,7 +13,7 @@ from utilities.filters import (
 )
 )
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
 from .choices import *
 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__ = (
 __all__ = (
@@ -22,6 +22,7 @@ __all__ = (
     'PrefixFilterSet',
     'PrefixFilterSet',
     'RIRFilterSet',
     'RIRFilterSet',
     'RoleFilterSet',
     'RoleFilterSet',
+    'RouteTargetFilterSet',
     'ServiceFilterSet',
     'ServiceFilterSet',
     'VLANFilterSet',
     'VLANFilterSet',
     'VLANGroupFilterSet',
     'VLANGroupFilterSet',
@@ -34,6 +35,28 @@ class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Create
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    import_target_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='import_targets',
+        queryset=RouteTarget.objects.all(),
+        label='Import target',
+    )
+    import_target = django_filters.ModelMultipleChoiceFilter(
+        field_name='import_targets__name',
+        queryset=RouteTarget.objects.all(),
+        to_field_name='name',
+        label='Import target (name)',
+    )
+    export_target_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='export_targets',
+        queryset=RouteTarget.objects.all(),
+        label='Export target',
+    )
+    export_target = django_filters.ModelMultipleChoiceFilter(
+        field_name='export_targets__name',
+        queryset=RouteTarget.objects.all(),
+        to_field_name='name',
+        label='Export target (name)',
+    )
     tag = TagFilter()
     tag = TagFilter()
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
@@ -50,6 +73,48 @@ class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Create
         fields = ['id', 'name', 'rd', 'enforce_unique']
         fields = ['id', 'name', 'rd', 'enforce_unique']
 
 
 
 
+class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    importing_vrf_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='importing_vrfs',
+        queryset=VRF.objects.all(),
+        label='Importing VRF',
+    )
+    importing_vrf = django_filters.ModelMultipleChoiceFilter(
+        field_name='importing_vrfs__rd',
+        queryset=VRF.objects.all(),
+        to_field_name='rd',
+        label='Import VRF (RD)',
+    )
+    exporting_vrf_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='exporting_vrfs',
+        queryset=VRF.objects.all(),
+        label='Exporting VRF',
+    )
+    exporting_vrf = django_filters.ModelMultipleChoiceFilter(
+        field_name='exporting_vrfs__rd',
+        queryset=VRF.objects.all(),
+        to_field_name='rd',
+        label='Export VRF (RD)',
+    )
+    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 RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:

+ 92 - 4
netbox/ipam/forms.py

@@ -1,5 +1,4 @@
 from django import forms
 from django import forms
-from django.core.validators import MaxValueValidator, MinValueValidator
 
 
 from dcim.models import Device, Interface, Rack, Region, Site
 from dcim.models import Device, Interface, Rack, Region, Site
 from extras.forms import (
 from extras.forms import (
@@ -16,7 +15,7 @@ from utilities.forms import (
 from virtualization.models import Cluster, VirtualMachine, VMInterface
 from virtualization.models import Cluster, VirtualMachine, VMInterface
 from .choices import *
 from .choices import *
 from .constants 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([
 PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([
     (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1)
     (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1)
@@ -32,6 +31,14 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
 #
 #
 
 
 class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    import_targets = DynamicModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        required=False
+    )
+    export_targets = DynamicModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        required=False
+    )
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         required=False
         required=False
@@ -40,7 +47,8 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     class Meta:
     class Meta:
         model = VRF
         model = VRF
         fields = [
         fields = [
-            'name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant', 'tags',
+            'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant',
+            'tags',
         ]
         ]
         labels = {
         labels = {
             'rd': "RD",
             'rd': "RD",
@@ -90,11 +98,91 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm
 
 
 class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
 class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = VRF
     model = VRF
-    field_order = ['q', 'tenant_group', 'tenant']
+    field_order = ['q', 'import_target', 'export_target', 'tenant_group', 'tenant']
+    q = forms.CharField(
+        required=False,
+        label='Search'
+    )
+    import_target = DynamicModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        to_field_name='name',
+        required=False
+    )
+    export_target = DynamicModelMultipleChoiceField(
+        queryset=RouteTarget.objects.all(),
+        to_field_name='name',
+        required=False
+    )
+    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', 'importing_vrfs', 'exporting_vrfs']
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
+    importing_vrf_id = DynamicModelMultipleChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='Imported by VRF'
+    )
+    exporting_vrf_id = DynamicModelMultipleChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='Exported by VRF'
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 

+ 44 - 0
netbox/ipam/migrations/0041_routetarget.py

@@ -0,0 +1,44 @@
+# 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'],
+            },
+        ),
+        migrations.AddField(
+            model_name='vrf',
+            name='export_targets',
+            field=models.ManyToManyField(blank=True, related_name='exporting_vrfs', to='ipam.RouteTarget'),
+        ),
+        migrations.AddField(
+            model_name='vrf',
+            name='import_targets',
+            field=models.ManyToManyField(blank=True, related_name='importing_vrfs', to='ipam.RouteTarget'),
+        ),
+    ]

+ 54 - 0
netbox/ipam/models.py

@@ -71,6 +71,16 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
+    import_targets = models.ManyToManyField(
+        to='ipam.RouteTarget',
+        related_name='importing_vrfs',
+        blank=True
+    )
+    export_targets = models.ManyToManyField(
+        to='ipam.RouteTarget',
+        related_name='exporting_vrfs',
+        blank=True
+    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
@@ -107,6 +117,50 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
         return self.name
         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):
 class RIR(ChangeLoggedModel):
     """
     """
     A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
     A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address

+ 21 - 1
netbox/ipam/tables.py

@@ -5,7 +5,7 @@ from dcim.models import Interface
 from tenancy.tables import COL_TENANT
 from tenancy.tables import COL_TENANT
 from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn
 from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn
 from virtualization.models import VMInterface
 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 = """
 RIR_UTILIZATION = """
 <div class="progress">
 <div class="progress">
@@ -176,6 +176,26 @@ class VRFTable(BaseTable):
         default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
         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
 # RIRs
 #
 #

+ 30 - 1
netbox/ipam/tests/test_api.py

@@ -6,7 +6,7 @@ from rest_framework import status
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from ipam.choices import *
 from ipam.choices import *
-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.testing import APITestCase, APIViewTestCases, disable_warnings
 from utilities.testing import APITestCase, APIViewTestCases, disable_warnings
 
 
 
 
@@ -52,6 +52,35 @@ class VRFTest(APIViewTestCases.APIViewTestCase):
         VRF.objects.bulk_create(vrfs)
         VRF.objects.bulk_create(vrfs)
 
 
 
 
+class RouteTargetTest(APIViewTestCases.APIViewTestCase):
+    model = RouteTarget
+    brief_fields = ['id', 'name', 'url']
+    create_data = [
+        {
+            'name': '65000:1004',
+        },
+        {
+            'name': '65000:1005',
+        },
+        {
+            'name': '65000:1006',
+        },
+    ]
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        route_targets = (
+            RouteTarget(name='65000:1001'),
+            RouteTarget(name='65000:1002'),
+            RouteTarget(name='65000:1003'),
+        )
+        RouteTarget.objects.bulk_create(route_targets)
+
+
 class RIRTest(APIViewTestCases.APIViewTestCase):
 class RIRTest(APIViewTestCases.APIViewTestCase):
     model = RIR
     model = RIR
     brief_fields = ['aggregate_count', 'id', 'name', 'slug', 'url']
     brief_fields = ['aggregate_count', 'id', 'name', 'slug', 'url']

+ 114 - 1
netbox/ipam/tests/test_filters.py

@@ -3,7 +3,7 @@ from django.test import TestCase
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site
 from ipam.choices import *
 from ipam.choices import *
 from ipam.filters import *
 from ipam.filters import *
-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 virtualization.models import Cluster, ClusterType, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterType, VirtualMachine, VMInterface
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 
 
@@ -15,6 +15,13 @@ class VRFTestCase(TestCase):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
+        route_targets = (
+            RouteTarget(name='65000:1001'),
+            RouteTarget(name='65000:1002'),
+            RouteTarget(name='65000:1003'),
+        )
+        RouteTarget.objects.bulk_create(route_targets)
+
         tenant_groups = (
         tenant_groups = (
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
@@ -39,6 +46,12 @@ class VRFTestCase(TestCase):
             VRF(name='VRF 6', rd='65000:600', tenant=tenants[2], enforce_unique=True),
             VRF(name='VRF 6', rd='65000:600', tenant=tenants[2], enforce_unique=True),
         )
         )
         VRF.objects.bulk_create(vrfs)
         VRF.objects.bulk_create(vrfs)
+        vrfs[0].import_targets.add(route_targets[0])
+        vrfs[0].export_targets.add(route_targets[0])
+        vrfs[1].import_targets.add(route_targets[1])
+        vrfs[1].export_targets.add(route_targets[1])
+        vrfs[2].import_targets.add(route_targets[2])
+        vrfs[2].export_targets.add(route_targets[2])
 
 
     def test_id(self):
     def test_id(self):
         params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
         params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
@@ -58,6 +71,20 @@ class VRFTestCase(TestCase):
         params = {'enforce_unique': 'false'}
         params = {'enforce_unique': 'false'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
+    def test_import_target(self):
+        route_targets = RouteTarget.objects.all()[:2]
+        params = {'import_target_id': [route_targets[0].pk, route_targets[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'import_target': [route_targets[0].name, route_targets[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_export_target(self):
+        route_targets = RouteTarget.objects.all()[:2]
+        params = {'export_target_id': [route_targets[0].pk, route_targets[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'export_target': [route_targets[0].name, route_targets[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_tenant(self):
     def test_tenant(self):
         tenants = Tenant.objects.all()[:2]
         tenants = Tenant.objects.all()[:2]
         params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
         params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
@@ -73,6 +100,92 @@ class VRFTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
 
 
+class RouteTargetTestCase(TestCase):
+    queryset = RouteTarget.objects.all()
+    filterset = RouteTargetFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        tenant_groups = (
+            TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
+            TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
+            TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
+        )
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
+
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
+            Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
+            Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        route_targets = (
+            RouteTarget(name='65000:1001', tenant=tenants[0]),
+            RouteTarget(name='65000:1002', tenant=tenants[0]),
+            RouteTarget(name='65000:1003', tenant=tenants[0]),
+            RouteTarget(name='65000:1004', tenant=tenants[0]),
+            RouteTarget(name='65000:2001', tenant=tenants[1]),
+            RouteTarget(name='65000:2002', tenant=tenants[1]),
+            RouteTarget(name='65000:2003', tenant=tenants[1]),
+            RouteTarget(name='65000:2004', tenant=tenants[1]),
+            RouteTarget(name='65000:3001', tenant=tenants[2]),
+            RouteTarget(name='65000:3002', tenant=tenants[2]),
+            RouteTarget(name='65000:3003', tenant=tenants[2]),
+            RouteTarget(name='65000:3004', tenant=tenants[2]),
+        )
+        RouteTarget.objects.bulk_create(route_targets)
+
+        vrfs = (
+            VRF(name='VRF 1', rd='65000:100'),
+            VRF(name='VRF 2', rd='65000:200'),
+            VRF(name='VRF 3', rd='65000:300'),
+        )
+        VRF.objects.bulk_create(vrfs)
+        vrfs[0].import_targets.add(route_targets[0], route_targets[1])
+        vrfs[0].export_targets.add(route_targets[2], route_targets[3])
+        vrfs[1].import_targets.add(route_targets[4], route_targets[5])
+        vrfs[1].export_targets.add(route_targets[6], route_targets[7])
+
+    def test_id(self):
+        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_name(self):
+        params = {'name': ['65000:1001', '65000:1002', '65000:1003']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_importing_vrf(self):
+        vrfs = VRF.objects.all()[:2]
+        params = {'importing_vrf_id': [vrfs[0].pk, vrfs[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'importing_vrf': [vrfs[0].rd, vrfs[1].rd]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_exporting_vrf(self):
+        vrfs = VRF.objects.all()[:2]
+        params = {'exporting_vrf_id': [vrfs[0].pk, vrfs[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'exporting_vrf': [vrfs[0].rd, vrfs[1].rd]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_tenant(self):
+        tenants = Tenant.objects.all()[:2]
+        params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+        params = {'tenant': [tenants[0].slug, tenants[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+
+    def test_tenant_group(self):
+        tenant_groups = TenantGroup.objects.all()[:2]
+        params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+        params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+
+
 class RIRTestCase(TestCase):
 class RIRTestCase(TestCase):
     queryset = RIR.objects.all()
     queryset = RIR.objects.all()
     filterset = RIRFilterSet
     filterset = RIRFilterSet

+ 41 - 1
netbox/ipam/tests/test_views.py

@@ -4,7 +4,7 @@ from netaddr import IPNetwork
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from ipam.choices import *
 from ipam.choices import *
-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.models import Tenant
 from tenancy.models import Tenant
 from utilities.testing import ViewTestCases
 from utilities.testing import ViewTestCases
 
 
@@ -52,6 +52,46 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
 
 
+class RouteTargetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = RouteTarget
+
+    @classmethod
+    def setUpTestData(cls):
+
+        tenants = (
+            Tenant(name='Tenant A', slug='tenant-a'),
+            Tenant(name='Tenant B', slug='tenant-b'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
+        route_targets = (
+            RouteTarget(name='65000:1001', tenant=tenants[0]),
+            RouteTarget(name='65000:1002', tenant=tenants[1]),
+            RouteTarget(name='65000:1003'),
+        )
+        RouteTarget.objects.bulk_create(route_targets)
+
+        cls.form_data = {
+            'name': '65000:100',
+            'description': 'A new route target',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,tenant,description",
+            "65000:1004,Tenant A,Foo",
+            "65000:1005,Tenant B,Bar",
+            "65000:1006,,No tenant",
+        )
+
+        cls.bulk_edit_data = {
+            'tenant': tenants[1].pk,
+            'description': 'New description',
+        }
+
+
 class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = RIR
     model = RIR
 
 

+ 12 - 1
netbox/ipam/urls.py

@@ -2,7 +2,7 @@ from django.urls import path
 
 
 from extras.views import ObjectChangeLogView
 from extras.views import ObjectChangeLogView
 from . import views
 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'
 app_name = 'ipam'
 urlpatterns = [
 urlpatterns = [
@@ -18,6 +18,17 @@ urlpatterns = [
     path('vrfs/<int:pk>/delete/', views.VRFDeleteView.as_view(), name='vrf_delete'),
     path('vrfs/<int:pk>/delete/', views.VRFDeleteView.as_view(), name='vrf_delete'),
     path('vrfs/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
     path('vrfs/<int:pk>/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/<int:pk>/', views.RouteTargetView.as_view(), name='routetarget'),
+    path('route-targets/<int:pk>/edit/', views.RouteTargetEditView.as_view(), name='routetarget_edit'),
+    path('route-targets/<int:pk>/delete/', views.RouteTargetDeleteView.as_view(), name='routetarget_delete'),
+    path('route-targets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='routetarget_changelog', kwargs={'model': RouteTarget}),
+
     # RIRs
     # RIRs
     path('rirs/', views.RIRListView.as_view(), name='rir_list'),
     path('rirs/', views.RIRListView.as_view(), name='rir_list'),
     path('rirs/add/', views.RIREditView.as_view(), name='rir_add'),
     path('rirs/add/', views.RIREditView.as_view(), name='rir_add'),

+ 73 - 1
netbox/ipam/views.py

@@ -16,7 +16,7 @@ from virtualization.models import VirtualMachine, VMInterface
 from . import filters, forms, tables
 from . import filters, forms, tables
 from .choices import *
 from .choices import *
 from .constants 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
 from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans
 
 
 
 
@@ -39,9 +39,20 @@ class VRFView(ObjectView):
         vrf = get_object_or_404(self.queryset, pk=pk)
         vrf = get_object_or_404(self.queryset, pk=pk)
         prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=vrf).count()
         prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=vrf).count()
 
 
+        import_targets_table = tables.RouteTargetTable(
+            vrf.import_targets.prefetch_related('tenant'),
+            orderable=False
+        )
+        export_targets_table = tables.RouteTargetTable(
+            vrf.export_targets.prefetch_related('tenant'),
+            orderable=False
+        )
+
         return render(request, 'ipam/vrf.html', {
         return render(request, 'ipam/vrf.html', {
             'vrf': vrf,
             'vrf': vrf,
             'prefix_count': prefix_count,
             'prefix_count': prefix_count,
+            'import_targets_table': import_targets_table,
+            'export_targets_table': export_targets_table,
         })
         })
 
 
 
 
@@ -74,6 +85,67 @@ class VRFBulkDeleteView(BulkDeleteView):
     table = tables.VRFTable
     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)
+
+        importing_vrfs_table = tables.VRFTable(
+            routetarget.importing_vrfs.prefetch_related('tenant'),
+            orderable=False
+        )
+        exporting_vrfs_table = tables.VRFTable(
+            routetarget.exporting_vrfs.prefetch_related('tenant'),
+            orderable=False
+        )
+
+        return render(request, 'ipam/routetarget.html', {
+            'routetarget': routetarget,
+            'importing_vrfs_table': importing_vrfs_table,
+            'exporting_vrfs_table': exporting_vrfs_table,
+        })
+
+
+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
 # RIRs
 #
 #

+ 9 - 0
netbox/templates/inc/nav_menu.html

@@ -331,6 +331,15 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'ipam:vrf_list' %}">VRFs</a>
                             <a href="{% url 'ipam:vrf_list' %}">VRFs</a>
                         </li>
                         </li>
+                        <li{% if not perms.ipam.view_routetarget %} class="disabled"{% endif %}>
+                            {% if perms.ipam.add_routetarget %}
+                                <div class="buttons pull-right">
+                                    <a href="{% url 'ipam:routetarget_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
+                                    <a href="{% url 'ipam:routetarget_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
+                                </div>
+                            {% endif %}
+                            <a href="{% url 'ipam:routetarget_list' %}">Route Targets</a>
+                        </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">VLANs</li>
                         <li class="dropdown-header">VLANs</li>
                         <li{% if not perms.ipam.view_vlan %} class="disabled"{% endif %}>
                         <li{% if not perms.ipam.view_vlan %} class="disabled"{% endif %}>

+ 100 - 0
netbox/templates/ipam/routetarget.html

@@ -0,0 +1,100 @@
+{% extends 'base.html' %}
+{% load buttons %}
+{% load custom_links %}
+{% load helpers %}
+{% load plugins %}
+
+{% block header %}
+    <div class="row noprint">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'ipam:routetarget_list' %}">Route Targets</a></li>
+                <li>{{ routetarget }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'ipam:routetarget_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" placeholder="Search roue targets" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
+    </div>
+    <div class="pull-right noprint">
+        {% 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 %}
+    </div>
+    <h1>{% block title %}Route target {{ routetarget }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=routetarget %}
+    <div class="pull-right noprint">
+        {% custom_links routetarget %}
+    </div>
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ routetarget.get_absolute_url }}">Route Target</a>
+        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'ipam:routetarget_changelog' pk=routetarget.pk %}">Change Log</a>
+            </li>
+        {% endif %}
+    </ul>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Route Target</strong>
+            </div>
+            <table class="table table-hover panel-body attr-table">
+                <tr>
+                    <td>Name</td>
+                    <td>{{ routetarget.name }}</td>
+                </tr>
+                <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if routetarget.tenant %}
+                            <a href="{{ routetarget.tenant.get_absolute_url }}">{{ routetarget.tenant }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Description</td>
+                    <td>{{ vrf.description|placeholder }}</td>
+                </tr>
+		    </table>
+        </div>
+        {% include 'extras/inc/tags_panel.html' with tags=routetarget.tags.all url='ipam:routetarget_list' %}
+        {% include 'inc/custom_fields_panel.html' with obj=routetarget %}
+        {% plugin_left_page routetarget %}
+	</div>
+	<div class="col-md-6">
+        {% include 'panel_table.html' with table=importing_vrfs_table heading="Importing VRFs" %}
+        {% include 'panel_table.html' with table=exporting_vrfs_table heading="Exporting VRFs" %}
+        {% plugin_right_page routetarget %}
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-12">
+        {% plugin_full_width_page routetarget %}
+    </div>
+</div>
+{% endblock %}

+ 3 - 1
netbox/templates/ipam/vrf.html

@@ -99,10 +99,12 @@
 		    </table>
 		    </table>
         </div>
         </div>
         {% include 'extras/inc/tags_panel.html' with tags=vrf.tags.all url='ipam:vrf_list' %}
         {% include 'extras/inc/tags_panel.html' with tags=vrf.tags.all url='ipam:vrf_list' %}
+        {% include 'inc/custom_fields_panel.html' with obj=vrf %}
         {% plugin_left_page vrf %}
         {% plugin_left_page vrf %}
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
-        {% include 'inc/custom_fields_panel.html' with obj=vrf %}
+        {% include 'panel_table.html' with table=import_targets_table heading="Import Route Targets" %}
+        {% include 'panel_table.html' with table=export_targets_table heading="Export Route Targets" %}
         {% plugin_right_page vrf %}
         {% plugin_right_page vrf %}
     </div>
     </div>
 </div>
 </div>

+ 7 - 0
netbox/templates/ipam/vrf_edit.html

@@ -11,6 +11,13 @@
             {% render_field form.description %}
             {% render_field form.description %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Route Targets</strong></div>
+        <div class="panel-body">
+            {% render_field form.import_targets %}
+            {% render_field form.export_targets %}
+        </div>
+    </div>
     <div class="panel panel-default">
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Tenancy</strong></div>
         <div class="panel-heading"><strong>Tenancy</strong></div>
         <div class="panel-body">
         <div class="panel-body">