Browse Source

Adds tenant assignment to Prefix and IPAddress objects

Jeremy Stretch 8 years ago
parent
commit
e6c06b39e8

+ 2 - 2
netbox/ipam/admin.py

@@ -40,7 +40,7 @@ class AggregateAdmin(admin.ModelAdmin):
 
 @admin.register(Prefix)
 class PrefixAdmin(admin.ModelAdmin):
-    list_display = ['prefix', 'vrf', 'site', 'status', 'role', 'vlan']
+    list_display = ['prefix', 'vrf', 'tenant', 'site', 'status', 'role', 'vlan']
     list_filter = ['family', 'site', 'status', 'role']
     search_fields = ['prefix']
 
@@ -51,7 +51,7 @@ class PrefixAdmin(admin.ModelAdmin):
 
 @admin.register(IPAddress)
 class IPAddressAdmin(admin.ModelAdmin):
-    list_display = ['address', 'vrf', 'nat_inside']
+    list_display = ['address', 'vrf', 'tenant', 'nat_inside']
     list_filter = ['family']
     fields = ['address', 'vrf', 'device', 'interface', 'nat_inside']
     readonly_fields = ['interface', 'device', 'nat_inside']

+ 15 - 4
netbox/ipam/api/serializers.py

@@ -23,6 +23,15 @@ class VRFNestedSerializer(VRFSerializer):
         fields = ['id', 'name', 'rd']
 
 
+class VRFTenantSerializer(VRFSerializer):
+    """
+    Include tenant serializer. Useful for determining tenant inheritance for Prefixes and IPAddresses.
+    """
+
+    class Meta(VRFSerializer.Meta):
+        fields = ['id', 'name', 'rd', 'tenant']
+
+
 #
 # Roles
 #
@@ -120,13 +129,14 @@ class VLANNestedSerializer(VLANSerializer):
 
 class PrefixSerializer(serializers.ModelSerializer):
     site = SiteNestedSerializer()
-    vrf = VRFNestedSerializer()
+    vrf = VRFTenantSerializer()
+    tenant = TenantNestedSerializer()
     vlan = VLANNestedSerializer()
     role = RoleNestedSerializer()
 
     class Meta:
         model = Prefix
-        fields = ['id', 'family', 'prefix', 'site', 'vrf', 'vlan', 'status', 'role', 'description']
+        fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description']
 
 
 class PrefixNestedSerializer(PrefixSerializer):
@@ -140,12 +150,13 @@ class PrefixNestedSerializer(PrefixSerializer):
 #
 
 class IPAddressSerializer(serializers.ModelSerializer):
-    vrf = VRFNestedSerializer()
+    vrf = VRFTenantSerializer()
+    tenant = TenantNestedSerializer()
     interface = InterfaceNestedSerializer()
 
     class Meta:
         model = IPAddress
-        fields = ['id', 'family', 'address', 'vrf', 'interface', 'description', 'nat_inside', 'nat_outside']
+        fields = ['id', 'family', 'address', 'vrf', 'tenant', 'interface', 'description', 'nat_inside', 'nat_outside']
 
 
 class IPAddressNestedSerializer(IPAddressSerializer):

+ 4 - 4
netbox/ipam/api/views.py

@@ -96,7 +96,7 @@ class PrefixListView(generics.ListAPIView):
     """
     List prefixes (filterable)
     """
-    queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'role')
+    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     serializer_class = serializers.PrefixSerializer
     filter_class = filters.PrefixFilter
 
@@ -105,7 +105,7 @@ class PrefixDetailView(generics.RetrieveAPIView):
     """
     Retrieve a single prefix
     """
-    queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'role')
+    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     serializer_class = serializers.PrefixSerializer
 
 
@@ -117,7 +117,7 @@ class IPAddressListView(generics.ListAPIView):
     """
     List IP addresses (filterable)
     """
-    queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\
+    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\
         .prefetch_related('nat_outside')
     serializer_class = serializers.IPAddressSerializer
     filter_class = filters.IPAddressFilter
@@ -127,7 +127,7 @@ class IPAddressDetailView(generics.RetrieveAPIView):
     """
     Retrieve a single IP address
     """
-    queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\
+    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\
         .prefetch_related('nat_outside')
     serializer_class = serializers.IPAddressSerializer
 

+ 54 - 0
netbox/ipam/filters.py

@@ -2,6 +2,8 @@ import django_filters
 from netaddr import IPNetwork
 from netaddr.core import AddrFormatError
 
+from django.db.models import Q
+
 from dcim.models import Site, Device, Interface
 from tenancy.models import Tenant
 
@@ -67,6 +69,14 @@ class PrefixFilter(django_filters.FilterSet):
         action='_vrf',
         label='VRF',
     )
+    tenant_id = django_filters.MethodFilter(
+        action='_tenant_id',
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.MethodFilter(
+        action='_tenant',
+        label='Tenant',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
         name='site',
         queryset=Site.objects.all(),
@@ -132,6 +142,24 @@ class PrefixFilter(django_filters.FilterSet):
             return queryset.filter(vrf__isnull=True)
         return queryset.filter(vrf__pk=value)
 
+    def _tenant(self, queryset, value):
+        if str(value) == '':
+            return queryset
+        return queryset.filter(
+            Q(tenant__slug=value) |
+            Q(tenant__isnull=True, vrf__tenant__slug=value)
+        )
+
+    def _tenant_id(self, queryset, value):
+        try:
+            value = int(value)
+        except ValueError:
+            return queryset.none()
+        return queryset.filter(
+            Q(tenant__pk=value) |
+            Q(tenant__isnull=True, vrf__tenant__pk=value)
+        )
+
 
 class IPAddressFilter(django_filters.FilterSet):
     q = django_filters.MethodFilter(
@@ -147,6 +175,14 @@ class IPAddressFilter(django_filters.FilterSet):
         action='_vrf',
         label='VRF',
     )
+    tenant_id = django_filters.MethodFilter(
+        action='_tenant_id',
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.MethodFilter(
+        action='_tenant',
+        label='Tenant',
+    )
     device_id = django_filters.ModelMultipleChoiceFilter(
         name='interface__device',
         queryset=Device.objects.all(),
@@ -187,6 +223,24 @@ class IPAddressFilter(django_filters.FilterSet):
             return queryset.filter(vrf__isnull=True)
         return queryset.filter(vrf__pk=value)
 
+    def _tenant(self, queryset, value):
+        if str(value) == '':
+            return queryset
+        return queryset.filter(
+            Q(tenant__slug=value) |
+            Q(tenant__isnull=True, vrf__tenant__slug=value)
+        )
+
+    def _tenant_id(self, queryset, value):
+        try:
+            value = int(value)
+        except ValueError:
+            return queryset.none()
+        return queryset.filter(
+            Q(tenant__pk=value) |
+            Q(tenant__isnull=True, vrf__tenant__pk=value)
+        )
+
 
 class VLANGroupFilter(django_filters.FilterSet):
     site_id = django_filters.ModelMultipleChoiceFilter(

+ 55 - 31
netbox/ipam/forms.py

@@ -16,6 +16,24 @@ FORM_PREFIX_STATUS_CHOICES = (('', '---------'),) + PREFIX_STATUS_CHOICES
 FORM_VLAN_STATUS_CHOICES = (('', '---------'),) + VLAN_STATUS_CHOICES
 
 
+def bulkedit_vrf_choices():
+    choices = [
+        (None, '---------'),
+        (0, 'Global'),
+    ]
+    choices += [(v.pk, v.name) for v in VRF.objects.all()]
+    return choices
+
+
+def bulkedit_tenant_choices():
+    choices = [
+        (None, '---------'),
+        (0, 'None'),
+    ]
+    choices += [(t.pk, t.name) for t in Tenant.objects.all()]
+    return choices
+
+
 #
 # VRFs
 #
@@ -48,7 +66,7 @@ class VRFImportForm(BulkImportForm, BootstrapMixin):
 
 class VRFBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
-    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
+    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
     description = forms.CharField(max_length=100, required=False)
 
 
@@ -145,7 +163,7 @@ class PrefixForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = Prefix
-        fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'description']
+        fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'description']
         help_texts = {
             'prefix': "IPv4 or IPv6 network",
             'vrf': "VRF (if applicable)",
@@ -186,6 +204,8 @@ class PrefixForm(forms.ModelForm, BootstrapMixin):
 class PrefixFromCSVForm(forms.ModelForm):
     vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
                                  error_messages={'invalid_choice': 'VRF not found.'})
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name',
                                   error_messages={'invalid_choice': 'Site not found.'})
     vlan_group_name = forms.CharField(required=False)
@@ -196,7 +216,8 @@ class PrefixFromCSVForm(forms.ModelForm):
 
     class Meta:
         model = Prefix
-        fields = ['prefix', 'vrf', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', 'description']
+        fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role',
+                  'description']
 
     def clean(self):
 
@@ -239,24 +260,21 @@ class PrefixImportForm(BulkImportForm, BootstrapMixin):
     csv = CSVDataField(csv_form=PrefixFromCSVForm)
 
 
-def prefix_vrf_choices():
-    choices = [
-        (None, '---------'),
-        (0, 'Global'),
-    ]
-    choices += [(v.pk, v.name) for v in VRF.objects.all()]
-    return choices
-
-
 class PrefixBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
-    vrf = forms.TypedChoiceField(choices=prefix_vrf_choices, coerce=int, required=False, label='VRF')
+    vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
+    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
     status = forms.ChoiceField(choices=FORM_PREFIX_STATUS_CHOICES, required=False)
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
     description = forms.CharField(max_length=100, required=False)
 
 
+def prefix_vrf_choices():
+    vrf_choices = VRF.objects.annotate(prefix_count=Count('prefixes'))
+    return [(v.pk, u'{} ({})'.format(v.name, v.prefix_count)) for v in vrf_choices]
+
+
 def prefix_site_choices():
     site_choices = Site.objects.annotate(prefix_count=Count('prefixes'))
     return [(s.slug, u'{} ({})'.format(s.name, s.prefix_count)) for s in site_choices]
@@ -276,12 +294,16 @@ def prefix_role_choices():
 
 class PrefixFilterForm(forms.Form, BootstrapMixin):
     parent = forms.CharField(required=False, label='Search Within')
-    vrf = forms.ChoiceField(required=False, choices=prefix_vrf_choices, label='VRF')
-    status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices)
+    vrf = forms.MultipleChoiceField(required=False, choices=prefix_vrf_choices, label='VRF',
+                                    widget=forms.SelectMultiple(attrs={'size': 6}))
+    tenant = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), required=False,
+                                            widget=forms.SelectMultiple(attrs={'size': 6}))
+    status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices,
+                                       widget=forms.SelectMultiple(attrs={'size': 6}))
     site = forms.MultipleChoiceField(required=False, choices=prefix_site_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 8}))
+                                     widget=forms.SelectMultiple(attrs={'size': 6}))
     role = forms.MultipleChoiceField(required=False, choices=prefix_role_choices,
-                                     widget=forms.SelectMultiple(attrs={'size': 8}))
+                                     widget=forms.SelectMultiple(attrs={'size': 6}))
     expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
 
 
@@ -304,7 +326,7 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = IPAddress
-        fields = ['address', 'vrf', 'nat_device', 'nat_inside', 'description']
+        fields = ['address', 'vrf', 'tenant', 'nat_device', 'nat_inside', 'description']
         help_texts = {
             'address': "IPv4 or IPv6 address and mask",
             'vrf': "VRF (if applicable)",
@@ -353,6 +375,8 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin):
 class IPAddressFromCSVForm(forms.ModelForm):
     vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
                                  error_messages={'invalid_choice': 'VRF not found.'})
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
     device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
                                     error_messages={'invalid_choice': 'Device not found.'})
     interface_name = forms.CharField(required=False)
@@ -360,7 +384,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
 
     class Meta:
         model = IPAddress
-        fields = ['address', 'vrf', 'device', 'interface_name', 'is_primary', 'description']
+        fields = ['address', 'vrf', 'tenant', 'device', 'interface_name', 'is_primary', 'description']
 
     def clean(self):
 
@@ -403,18 +427,10 @@ class IPAddressImportForm(BulkImportForm, BootstrapMixin):
     csv = CSVDataField(csv_form=IPAddressFromCSVForm)
 
 
-def ipaddress_vrf_choices():
-    choices = [
-        (None, '---------'),
-        (0, 'Global'),
-    ]
-    choices += [(v.pk, v.name) for v in VRF.objects.all()]
-    return choices
-
-
 class IPAddressBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
-    vrf = forms.TypedChoiceField(choices=ipaddress_vrf_choices, coerce=int, required=False, label='VRF')
+    vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
+    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
     description = forms.CharField(max_length=100, required=False)
 
 
@@ -422,9 +438,17 @@ def ipaddress_family_choices():
     return [('', 'All'), (4, 'IPv4'), (6, 'IPv6')]
 
 
+def ipaddress_vrf_choices():
+    vrf_choices = VRF.objects.annotate(ipaddress_count=Count('ip_addresses'))
+    return [(v.pk, u'{} ({})'.format(v.name, v.ipaddress_count)) for v in vrf_choices]
+
+
 class IPAddressFilterForm(forms.Form, BootstrapMixin):
     family = forms.ChoiceField(required=False, choices=ipaddress_family_choices, label='Address Family')
-    vrf = forms.ChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF')
+    vrf = forms.MultipleChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF',
+                                    widget=forms.SelectMultiple(attrs={'size': 6}))
+    tenant = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), required=False,
+                                            widget=forms.SelectMultiple(attrs={'size': 6}))
 
 
 #
@@ -518,7 +542,7 @@ class VLANBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
     group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
-    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
+    tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
     status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
     description = forms.CharField(max_length=100, required=False)

+ 27 - 0
netbox/ipam/migrations/0007_prefix_ipaddress_add_tenant.py

@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-07-28 15:32
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0001_initial'),
+        ('ipam', '0006_vrf_vlan_add_tenant'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='ipaddress',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='tenancy.Tenant'),
+        ),
+        migrations.AddField(
+            model_name='prefix',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='tenancy.Tenant'),
+        ),
+    ]

+ 2 - 0
netbox/ipam/models.py

@@ -233,6 +233,7 @@ class Prefix(CreatedUpdatedModel):
     site = models.ForeignKey('dcim.Site', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True)
     vrf = models.ForeignKey('VRF', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
                             verbose_name='VRF')
+    tenant = models.ForeignKey(Tenant, related_name='prefixes', blank=True, null=True, on_delete=models.PROTECT)
     vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
                              verbose_name='VLAN')
     status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=1)
@@ -308,6 +309,7 @@ class IPAddress(CreatedUpdatedModel):
     address = IPAddressField()
     vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True,
                             verbose_name='VRF')
+    tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT)
     interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True,
                                   null=True)
     nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True,

+ 14 - 2
netbox/ipam/tables.py

@@ -49,6 +49,16 @@ VLANGROUP_EDIT_LINK = """
 {% endif %}
 """
 
+TENANT_LINK = """
+{% if record.tenant %}
+    <a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}">{{ record.tenant }}</a>
+{% elif record.vrf.tenant %}
+    <a href="{% url 'tenancy:tenant' slug=record.vrf.tenant.slug %}">{{ record.vrf.tenant }}</a>*
+{% else %}
+    &mdash;
+{% endif %}
+"""
+
 
 #
 # VRFs
@@ -126,13 +136,14 @@ class PrefixTable(BaseTable):
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
     prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix')
     vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
+    tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     role = tables.Column(verbose_name='Role')
     description = tables.Column(orderable=False, verbose_name='Description')
 
     class Meta(BaseTable.Meta):
         model = Prefix
-        fields = ('pk', 'prefix', 'status', 'vrf', 'site', 'role', 'description')
+        fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'role', 'description')
 
 
 class PrefixBriefTable(BaseTable):
@@ -154,6 +165,7 @@ class IPAddressTable(BaseTable):
     pk = ToggleColumn()
     address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address')
     vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
+    tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
     device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
                                verbose_name='Device')
     interface = tables.Column(orderable=False, verbose_name='Interface')
@@ -161,7 +173,7 @@ class IPAddressTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = IPAddress
-        fields = ('pk', 'address', 'vrf', 'device', 'interface', 'description')
+        fields = ('pk', 'address', 'vrf', 'tenant', 'device', 'interface', 'description')
 
 
 class IPAddressBriefTable(BaseTable):

+ 2 - 2
netbox/ipam/views.py

@@ -249,7 +249,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class PrefixListView(ObjectListView):
-    queryset = Prefix.objects.select_related('site', 'vrf', 'role')
+    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'role')
     filter = filters.PrefixFilter
     filter_form = forms.PrefixFilterForm
     table = tables.PrefixTable
@@ -380,7 +380,7 @@ def prefix_ipaddresses(request, pk):
 #
 
 class IPAddressListView(ObjectListView):
-    queryset = IPAddress.objects.select_related('vrf', 'interface__device')
+    queryset = IPAddress.objects.select_related('vrf__tenant', 'interface__device')
     filter = filters.IPAddressFilter
     filter_form = forms.IPAddressFilterForm
     table = tables.IPAddressTable

+ 13 - 0
netbox/templates/ipam/ipaddress.html

@@ -65,6 +65,19 @@
                     </td>
                 </tr>
                 <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if ipaddress.tenant %}
+                            <a href="{{ ipaddress.tenant.get_absolute_url }}">{{ ipaddress.tenant }}</a>
+                        {% elif ipaddress.vrf.tenant %}
+                            <a href="{{ ipaddress.vrf.tenant.get_absolute_url }}">{{ ipaddress.vrf.tenant }}</a>
+                            <label class="label label-warning">Inherited</label>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
                     <td>Description</td>
                     <td>
                         {% if ipaddress.description  %}

+ 2 - 1
netbox/templates/ipam/ipaddress_bulk_edit.html

@@ -7,7 +7,8 @@
     {% for ipaddress in selected_objects %}
         <tr>
             <td><a href="{% url 'ipam:ipaddress' pk=ipaddress.pk %}">{{ ipaddress }}</a></td>
-            <td>{{ ipaddress.vrf }}</td>
+            <td>{{ ipaddress.vrf|default:"Global" }}</td>
+            <td>{{ ipaddress.tenant }}</td>
             <td>{{ ipaddress.interface.device }}</td>
             <td>{{ ipaddress.interface }}</td>
             <td>{{ ipaddress.description }}</td>

+ 1 - 0
netbox/templates/ipam/ipaddress_edit.html

@@ -8,6 +8,7 @@
         <div class="panel-body">
             {% render_field form.address %}
             {% render_field form.vrf %}
+            {% render_field form.tenant %}
             {% if obj %}
                 <div class="form-group">
                     <label class="col-md-3 control-label">Device</label>

+ 6 - 1
netbox/templates/ipam/ipaddress_import.html

@@ -39,6 +39,11 @@
 					<td>65000:123</td>
 				</tr>
 				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>ABC01</td>
+				</tr>
+				<tr>
 					<td>Device</td>
 					<td>Device name (optional)</td>
 					<td>switch12</td>
@@ -61,7 +66,7 @@
 			</tbody>
 		</table>
 		<h4>Example</h4>
-		<pre>192.0.2.42/24,65000:123,switch12,ge-0/0/31,True,Management IP</pre>
+		<pre>192.0.2.42/24,65000:123,ABC01,switch12,ge-0/0/31,True,Management IP</pre>
 	</div>
 </div>
 {% endblock %}

+ 13 - 0
netbox/templates/ipam/prefix.html

@@ -27,6 +27,19 @@
                     </td>
                 </tr>
                 <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if prefix.tenant %}
+                            <a href="{{ prefix.tenant.get_absolute_url }}">{{ prefix.tenant }}</a>
+                        {% elif prefix.vrf.tenant %}
+                            <a href="{{ prefix.vrf.tenant.get_absolute_url }}">{{ prefix.vrf.tenant }}</a>
+                            <label class="label label-warning">Inherited</label>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
                     <td>Aggregate</td>
                     <td>
                         {% if aggregate %}

+ 1 - 0
netbox/templates/ipam/prefix_bulk_edit.html

@@ -8,6 +8,7 @@
         <tr>
             <td><a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a></td>
             <td>{{ prefix.vrf|default:"Global" }}</td>
+            <td>{{ prefix.tenant }}</td>
             <td>{{ prefix.site }}</td>
             <td>{{ prefix.status }}</td>
             <td>{{ prefix.role }}</td>

+ 6 - 1
netbox/templates/ipam/prefix_import.html

@@ -39,6 +39,11 @@
 					<td>65000:123</td>
 				</tr>
 				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>ABC01</td>
+				</tr>
+				<tr>
 					<td>Site</td>
 					<td>Name of assigned site (optional)</td>
 					<td>HQ</td>
@@ -71,7 +76,7 @@
 			</tbody>
 		</table>
 		<h4>Example</h4>
-		<pre>192.168.42.0/24,65000:123,HQ,Customers,801,Active,Customer,7th floor WiFi</pre>
+		<pre>192.168.42.0/24,65000:123,ABC01,HQ,Customers,801,Active,Customer,7th floor WiFi</pre>
 	</div>
 </div>
 {% endblock %}

+ 16 - 8
netbox/templates/tenancy/tenant.html

@@ -91,29 +91,37 @@
                 <strong>Stats</strong>
             </div>
             <div class="row panel-body">
-                <div class="col-md-4 text-center">
+                <div class="col-md-3 text-center">
                     <h2><a href="{% url 'dcim:site_list' %}?tenant={{ tenant.slug }}" class="btn {% if tenant.site_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ tenant.site_count }}</a></h2>
                     <p>Sites</p>
                 </div>
-                <div class="col-md-4 text-center">
+                <div class="col-md-3 text-center">
                     <h2><a href="{% url 'dcim:rack_list' %}?tenant={{ tenant.slug }}" class="btn {% if tenant.rack_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ tenant.rack_count }}</a></h2>
                     <p>Racks</p>
                 </div>
-                <div class="col-md-4 text-center">
+                <div class="col-md-3 text-center">
                     <h2><a href="{% url 'dcim:device_list' %}?tenant={{ tenant.slug }}" class="btn {% if tenant.device_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ tenant.device_count }}</a></h2>
                     <p>Devices</p>
                 </div>
-            </div>
-            <div class="row panel-body">
-                <div class="col-md-4 text-center">
+                <div class="col-md-3 text-center">
                     <h2><a href="{% url 'ipam:vrf_list' %}?tenant={{ tenant.slug }}" class="btn {% if tenant.vrf_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ tenant.vrf_count }}</a></h2>
                     <p>VRFs</p>
                 </div>
-                <div class="col-md-4 text-center">
+            </div>
+            <div class="row panel-body">
+                <div class="col-md-3 text-center">
+                    <h2><a href="{% url 'ipam:prefix_list' %}?tenant={{ tenant.slug }}" class="btn {% if tenant.prefix_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ tenant.prefix_count }}</a></h2>
+                    <p>Prefixes</p>
+                </div>
+                <div class="col-md-3 text-center">
+                    <h2><a href="{% url 'ipam:ipaddress_list' %}?tenant={{ tenant.slug }}" class="btn {% if tenant.ipaddress_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ tenant.ipaddress_count }}</a></h2>
+                    <p>IP addresses</p>
+                </div>
+                <div class="col-md-3 text-center">
                     <h2><a href="{% url 'ipam:vlan_list' %}?tenant={{ tenant.slug }}" class="btn {% if tenant.vlan_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ tenant.vlan_count }}</a></h2>
                     <p>VLANs</p>
                 </div>
-                <div class="col-md-4 text-center">
+                <div class="col-md-3 text-center">
                     <h2><a href="{% url 'circuits:circuit_list' %}?tenant={{ tenant.slug }}" class="btn {% if tenant.circuit_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ tenant.circuit_count }}</a></h2>
                     <p>Circuits</p>
                 </div>

+ 2 - 0
netbox/tenancy/views.py

@@ -55,6 +55,8 @@ def tenant(request, slug):
         rack_count=Count('racks', distinct=True),
         device_count=Count('devices', distinct=True),
         vrf_count=Count('vrfs', distinct=True),
+        prefix_count=Count('prefixes', distinct=True),
+        ipaddress_count=Count('ip_addresses', distinct=True),
         vlan_count=Count('vlans', distinct=True),
         circuit_count=Count('circuits', distinct=True),
     ), slug=slug)