فهرست منبع

Merge pull request #393 from digitalocean/multitenancy

Multitenancy
Jeremy Stretch 9 سال پیش
والد
کامیت
1413f5d89e
76فایلهای تغییر یافته به همراه1327 افزوده شده و 124 حذف شده
  1. 9 0
      docs/data-model/tenancy.md
  2. 4 3
      netbox/circuits/admin.py
  3. 4 2
      netbox/circuits/api/serializers.py
  4. 2 2
      netbox/circuits/api/views.py
  5. 12 0
      netbox/circuits/filters.py
  6. 14 3
      netbox/circuits/forms.py
  7. 22 0
      netbox/circuits/migrations/0004_circuit_add_tenant.py
  8. 3 0
      netbox/circuits/models.py
  9. 2 1
      netbox/circuits/tables.py
  10. 2 2
      netbox/circuits/views.py
  11. 10 6
      netbox/dcim/api/serializers.py
  12. 7 6
      netbox/dcim/api/views.py
  13. 34 0
      netbox/dcim/filters.py
  14. 43 10
      netbox/dcim/forms.py
  15. 32 0
      netbox/dcim/migrations/0012_site_rack_device_add_tenant.py
  16. 7 0
      netbox/dcim/models.py
  17. 13 7
      netbox/dcim/tables.py
  18. 6 0
      netbox/dcim/tests/test_apis.py
  19. 4 3
      netbox/dcim/views.py
  20. 9 4
      netbox/ipam/admin.py
  21. 5 2
      netbox/ipam/api/serializers.py
  22. 4 4
      netbox/ipam/api/views.py
  23. 23 0
      netbox/ipam/filters.py
  24. 28 4
      netbox/ipam/forms.py
  25. 27 0
      netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py
  26. 8 2
      netbox/ipam/models.py
  27. 4 2
      netbox/ipam/tables.py
  28. 4 3
      netbox/ipam/views.py
  29. 2 1
      netbox/netbox/settings.py
  30. 2 0
      netbox/netbox/urls.py
  31. 5 1
      netbox/netbox/views.py
  32. 18 9
      netbox/templates/_base.html
  33. 59 20
      netbox/templates/circuits/circuit.html
  34. 8 2
      netbox/templates/circuits/circuit_edit.html
  35. 6 1
      netbox/templates/circuits/circuit_import.html
  36. 10 0
      netbox/templates/dcim/device.html
  37. 1 0
      netbox/templates/dcim/device_bulk_edit.html
  38. 1 0
      netbox/templates/dcim/device_edit.html
  39. 6 1
      netbox/templates/dcim/device_import.html
  40. 10 0
      netbox/templates/dcim/rack.html
  41. 1 0
      netbox/templates/dcim/rack_bulk_edit.html
  42. 1 0
      netbox/templates/dcim/rack_edit.html
  43. 6 1
      netbox/templates/dcim/rack_import.html
  44. 10 0
      netbox/templates/dcim/site.html
  45. 1 0
      netbox/templates/dcim/site_edit.html
  46. 6 1
      netbox/templates/dcim/site_import.html
  47. 1 0
      netbox/templates/dcim/site_list.html
  48. 27 15
      netbox/templates/home.html
  49. 13 3
      netbox/templates/ipam/vlan.html
  50. 2 1
      netbox/templates/ipam/vlan_bulk_edit.html
  51. 6 1
      netbox/templates/ipam/vlan_import.html
  52. 10 0
      netbox/templates/ipam/vrf.html
  53. 1 0
      netbox/templates/ipam/vrf_bulk_edit.html
  54. 6 1
      netbox/templates/ipam/vrf_import.html
  55. 1 0
      netbox/templates/ipam/vrf_list.html
  56. 124 0
      netbox/templates/tenancy/tenant.html
  57. 13 0
      netbox/templates/tenancy/tenant_bulk_edit.html
  58. 21 0
      netbox/templates/tenancy/tenant_edit.html
  59. 57 0
      netbox/templates/tenancy/tenant_import.html
  60. 42 0
      netbox/templates/tenancy/tenant_list.html
  61. 21 0
      netbox/templates/tenancy/tenantgroup_list.html
  62. 0 0
      netbox/tenancy/__init__.py
  63. 23 0
      netbox/tenancy/admin.py
  64. 0 0
      netbox/tenancy/api/__init__.py
  65. 38 0
      netbox/tenancy/api/serializers.py
  66. 16 0
      netbox/tenancy/api/urls.py
  67. 39 0
      netbox/tenancy/api/views.py
  68. 5 0
      netbox/tenancy/apps.py
  69. 29 0
      netbox/tenancy/filters.py
  70. 61 0
      netbox/tenancy/forms.py
  71. 48 0
      netbox/tenancy/migrations/0001_initial.py
  72. 0 0
      netbox/tenancy/migrations/__init__.py
  73. 50 0
      netbox/tenancy/models.py
  74. 44 0
      netbox/tenancy/tables.py
  75. 24 0
      netbox/tenancy/urls.py
  76. 110 0
      netbox/tenancy/views.py

+ 9 - 0
docs/data-model/tenancy.md

@@ -0,0 +1,9 @@
+NetBox supports the concept of individual tenants within its parent organization. Typically, these are used to represent individual customers or internal departments.
+
+# Tenants
+
+A tenant represents a discrete organization. Certain resources within NetBox can be assigned to a tenant. This makes it very convenient to track which resources are assigned to which customers, for instance.
+
+### Tenant Groups
+
+Tenants are grouped by type. For instance, you might create one group called "Customers" and one called "Acquisitions."

+ 4 - 3
netbox/circuits/admin.py

@@ -21,10 +21,11 @@ class CircuitTypeAdmin(admin.ModelAdmin):
 
 
 @admin.register(Circuit)
 @admin.register(Circuit)
 class CircuitAdmin(admin.ModelAdmin):
 class CircuitAdmin(admin.ModelAdmin):
-    list_display = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id']
-    list_filter = ['provider']
+    list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'commit_rate',
+                    'xconnect_id']
+    list_filter = ['provider', 'type', 'tenant']
     exclude = ['interface']
     exclude = ['interface']
 
 
     def get_queryset(self, request):
     def get_queryset(self, request):
         qs = super(CircuitAdmin, self).get_queryset(request)
         qs = super(CircuitAdmin, self).get_queryset(request)
-        return qs.select_related('provider', 'type', 'site')
+        return qs.select_related('provider', 'type', 'tenant', 'site')

+ 4 - 2
netbox/circuits/api/serializers.py

@@ -2,6 +2,7 @@ from rest_framework import serializers
 
 
 from circuits.models import Provider, CircuitType, Circuit
 from circuits.models import Provider, CircuitType, Circuit
 from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
 from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
+from tenancy.api.serializers import TenantNestedSerializer
 
 
 
 
 #
 #
@@ -45,13 +46,14 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer):
 class CircuitSerializer(serializers.ModelSerializer):
 class CircuitSerializer(serializers.ModelSerializer):
     provider = ProviderNestedSerializer()
     provider = ProviderNestedSerializer()
     type = CircuitTypeNestedSerializer()
     type = CircuitTypeNestedSerializer()
+    tenant = TenantNestedSerializer()
     site = SiteNestedSerializer()
     site = SiteNestedSerializer()
     interface = InterfaceNestedSerializer()
     interface = InterfaceNestedSerializer()
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
-        fields = ['id', 'cid', 'provider', 'type', 'site', 'interface', 'install_date', 'port_speed', 'commit_rate',
-                  'xconnect_id', 'comments']
+        fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed',
+                  'commit_rate', 'xconnect_id', 'comments']
 
 
 
 
 class CircuitNestedSerializer(CircuitSerializer):
 class CircuitNestedSerializer(CircuitSerializer):

+ 2 - 2
netbox/circuits/api/views.py

@@ -42,7 +42,7 @@ class CircuitListView(generics.ListAPIView):
     """
     """
     List circuits (filterable)
     List circuits (filterable)
     """
     """
-    queryset = Circuit.objects.select_related('type', 'provider', 'site', 'interface__device')
+    queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')
     serializer_class = serializers.CircuitSerializer
     serializer_class = serializers.CircuitSerializer
     filter_class = CircuitFilter
     filter_class = CircuitFilter
 
 
@@ -51,5 +51,5 @@ class CircuitDetailView(generics.RetrieveAPIView):
     """
     """
     Retrieve a single circuit
     Retrieve a single circuit
     """
     """
-    queryset = Circuit.objects.select_related('type', 'provider', 'site', 'interface__device')
+    queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')
     serializer_class = serializers.CircuitSerializer
     serializer_class = serializers.CircuitSerializer

+ 12 - 0
netbox/circuits/filters.py

@@ -3,6 +3,7 @@ import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
 from dcim.models import Site
 from dcim.models import Site
+from tenancy.models import Tenant
 from .models import Provider, Circuit, CircuitType
 from .models import Provider, Circuit, CircuitType
 
 
 
 
@@ -62,6 +63,17 @@ class CircuitFilter(django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Circuit type (slug)',
         label='Circuit type (slug)',
     )
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         name='site',
         name='site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),

+ 14 - 3
netbox/circuits/forms.py

@@ -2,6 +2,7 @@ from django import forms
 from django.db.models import Count
 from django.db.models import Count
 
 
 from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
 from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
+from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField,
     APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField,
 )
 )
@@ -99,7 +100,7 @@ class CircuitForm(forms.ModelForm, BootstrapMixin):
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
         fields = [
         fields = [
-            'cid', 'type', 'provider', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
+            'cid', 'type', 'provider', 'tenant', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
             'port_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
             'port_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
         ]
         ]
         help_texts = {
         help_texts = {
@@ -160,13 +161,15 @@ class CircuitFromCSVForm(forms.ModelForm):
                                       error_messages={'invalid_choice': 'Provider not found.'})
                                       error_messages={'invalid_choice': 'Provider not found.'})
     type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
     type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
                                   error_messages={'invalid_choice': 'Invalid circuit type.'})
                                   error_messages={'invalid_choice': 'Invalid circuit type.'})
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
     site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name',
     site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name',
                                   error_messages={'invalid_choice': 'Site not found.'})
                                   error_messages={'invalid_choice': 'Site not found.'})
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
-        fields = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id',
-                  'pp_info']
+        fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'commit_rate',
+                  'xconnect_id', 'pp_info']
 
 
 
 
 class CircuitImportForm(BulkImportForm, BootstrapMixin):
 class CircuitImportForm(BulkImportForm, BootstrapMixin):
@@ -177,6 +180,7 @@ class CircuitBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
     pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
     type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
     type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
     provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
     provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)')
     port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)')
     commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
     commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
     comments = CommentField()
     comments = CommentField()
@@ -192,6 +196,11 @@ def circuit_provider_choices():
     return [(p.slug, u'{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices]
     return [(p.slug, u'{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices]
 
 
 
 
+def circuit_tenant_choices():
+    tenant_choices = Tenant.objects.annotate(circuit_count=Count('circuits'))
+    return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in tenant_choices]
+
+
 def circuit_site_choices():
 def circuit_site_choices():
     site_choices = Site.objects.annotate(circuit_count=Count('circuits'))
     site_choices = Site.objects.annotate(circuit_count=Count('circuits'))
     return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
     return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
@@ -201,5 +210,7 @@ class CircuitFilterForm(forms.Form, BootstrapMixin):
     type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices)
     type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices)
     provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices,
     provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices,
                                          widget=forms.SelectMultiple(attrs={'size': 8}))
                                          widget=forms.SelectMultiple(attrs={'size': 8}))
+    tenant = forms.MultipleChoiceField(required=False, choices=circuit_tenant_choices,
+                                       widget=forms.SelectMultiple(attrs={'size': 8}))
     site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices,
     site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices,
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
                                      widget=forms.SelectMultiple(attrs={'size': 8}))

+ 22 - 0
netbox/circuits/migrations/0004_circuit_add_tenant.py

@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-07-26 21:59
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0001_initial'),
+        ('circuits', '0003_provider_32bit_asn_support'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuit',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='tenancy.Tenant'),
+        ),
+    ]

+ 3 - 0
netbox/circuits/models.py

@@ -3,6 +3,7 @@ from django.db import models
 
 
 from dcim.fields import ASNField
 from dcim.fields import ASNField
 from dcim.models import Site, Interface
 from dcim.models import Site, Interface
+from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
 
 
 
 
@@ -66,6 +67,7 @@ class Circuit(CreatedUpdatedModel):
     cid = models.CharField(max_length=50, verbose_name='Circuit ID')
     cid = models.CharField(max_length=50, verbose_name='Circuit ID')
     provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
     provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
     type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
     type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
+    tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
     site = models.ForeignKey(Site, related_name='circuits', on_delete=models.PROTECT)
     site = models.ForeignKey(Site, related_name='circuits', on_delete=models.PROTECT)
     interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True)
     interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True)
     install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
     install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
@@ -90,6 +92,7 @@ class Circuit(CreatedUpdatedModel):
             self.cid,
             self.cid,
             self.provider.name,
             self.provider.name,
             self.type.name,
             self.type.name,
+            self.tenant.name if self.tenant else '',
             self.site.name,
             self.site.name,
             self.install_date.isoformat() if self.install_date else '',
             self.install_date.isoformat() if self.install_date else '',
             str(self.port_speed),
             str(self.port_speed),

+ 2 - 1
netbox/circuits/tables.py

@@ -53,10 +53,11 @@ class CircuitTable(BaseTable):
     cid = tables.LinkColumn('circuits:circuit', args=[Accessor('pk')], verbose_name='ID')
     cid = tables.LinkColumn('circuits:circuit', args=[Accessor('pk')], verbose_name='ID')
     type = tables.Column(verbose_name='Type')
     type = tables.Column(verbose_name='Type')
     provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
     provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     port_speed_human = tables.Column(verbose_name='Port Speed')
     port_speed_human = tables.Column(verbose_name='Port Speed')
     commit_rate_human = tables.Column(verbose_name='Commit Rate')
     commit_rate_human = tables.Column(verbose_name='Commit Rate')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Circuit
         model = Circuit
-        fields = ('pk', 'cid', 'type', 'provider', 'site', 'port_speed_human', 'commit_rate_human')
+        fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'site', 'port_speed_human', 'commit_rate_human')

+ 2 - 2
netbox/circuits/views.py

@@ -109,7 +109,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 #
 
 
 class CircuitListView(ObjectListView):
 class CircuitListView(ObjectListView):
-    queryset = Circuit.objects.select_related('provider', 'type', 'site')
+    queryset = Circuit.objects.select_related('provider', 'type', 'tenant', 'site')
     filter = filters.CircuitFilter
     filter = filters.CircuitFilter
     filter_form = forms.CircuitFilterForm
     filter_form = forms.CircuitFilterForm
     table = tables.CircuitTable
     table = tables.CircuitTable
@@ -159,7 +159,7 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
     def update_objects(self, pk_list, form):
 
 
         fields_to_update = {}
         fields_to_update = {}
-        for field in ['type', 'provider', 'port_speed', 'commit_rate', 'comments']:
+        for field in ['type', 'provider', 'tenant', 'port_speed', 'commit_rate', 'comments']:
             if form.cleaned_data[field]:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]
                 fields_to_update[field] = form.cleaned_data[field]
 
 

+ 10 - 6
netbox/dcim/api/serializers.py

@@ -6,6 +6,7 @@ from dcim.models import (
     DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet,
     DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet,
     PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
     PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
 )
 )
+from tenancy.api.serializers import TenantNestedSerializer
 
 
 
 
 #
 #
@@ -13,10 +14,11 @@ from dcim.models import (
 #
 #
 
 
 class SiteSerializer(serializers.ModelSerializer):
 class SiteSerializer(serializers.ModelSerializer):
+    tenant = TenantNestedSerializer()
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
-        fields = ['id', 'name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments',
+        fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments',
                   'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
                   'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
 
 
 
 
@@ -52,10 +54,11 @@ class RackGroupNestedSerializer(RackGroupSerializer):
 class RackSerializer(serializers.ModelSerializer):
 class RackSerializer(serializers.ModelSerializer):
     site = SiteNestedSerializer()
     site = SiteNestedSerializer()
     group = RackGroupNestedSerializer()
     group = RackGroupNestedSerializer()
+    tenant = TenantNestedSerializer()
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
-        fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments']
+        fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'u_height', 'comments']
 
 
 
 
 class RackNestedSerializer(RackSerializer):
 class RackNestedSerializer(RackSerializer):
@@ -69,8 +72,8 @@ class RackDetailSerializer(RackSerializer):
     rear_units = serializers.SerializerMethodField()
     rear_units = serializers.SerializerMethodField()
 
 
     class Meta(RackSerializer.Meta):
     class Meta(RackSerializer.Meta):
-        fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments', 'front_units',
-                  'rear_units']
+        fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'u_height', 'comments',
+                  'front_units', 'rear_units']
 
 
     def get_front_units(self, obj):
     def get_front_units(self, obj):
         units = obj.get_rack_units(face=RACK_FACE_FRONT)
         units = obj.get_rack_units(face=RACK_FACE_FRONT)
@@ -218,6 +221,7 @@ class DeviceIPAddressNestedSerializer(serializers.ModelSerializer):
 class DeviceSerializer(serializers.ModelSerializer):
 class DeviceSerializer(serializers.ModelSerializer):
     device_type = DeviceTypeNestedSerializer()
     device_type = DeviceTypeNestedSerializer()
     device_role = DeviceRoleNestedSerializer()
     device_role = DeviceRoleNestedSerializer()
+    tenant = TenantNestedSerializer()
     platform = PlatformNestedSerializer()
     platform = PlatformNestedSerializer()
     rack = RackNestedSerializer()
     rack = RackNestedSerializer()
     primary_ip = DeviceIPAddressNestedSerializer()
     primary_ip = DeviceIPAddressNestedSerializer()
@@ -227,8 +231,8 @@ class DeviceSerializer(serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
-        fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'platform', 'serial', 'rack', 'position',
-                  'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments']
+        fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'rack',
+                  'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments']
 
 
     def get_parent_device(self, obj):
     def get_parent_device(self, obj):
         try:
         try:

+ 7 - 6
netbox/dcim/api/views.py

@@ -27,7 +27,7 @@ class SiteListView(generics.ListAPIView):
     """
     """
     List all sites
     List all sites
     """
     """
-    queryset = Site.objects.all()
+    queryset = Site.objects.select_related('tenant')
     serializer_class = serializers.SiteSerializer
     serializer_class = serializers.SiteSerializer
 
 
 
 
@@ -35,7 +35,7 @@ class SiteDetailView(generics.RetrieveAPIView):
     """
     """
     Retrieve a single site
     Retrieve a single site
     """
     """
-    queryset = Site.objects.all()
+    queryset = Site.objects.select_related('tenant')
     serializer_class = serializers.SiteSerializer
     serializer_class = serializers.SiteSerializer
 
 
 
 
@@ -68,7 +68,7 @@ class RackListView(generics.ListAPIView):
     """
     """
     List racks (filterable)
     List racks (filterable)
     """
     """
-    queryset = Rack.objects.select_related('site')
+    queryset = Rack.objects.select_related('site', 'tenant')
     serializer_class = serializers.RackSerializer
     serializer_class = serializers.RackSerializer
     filter_class = filters.RackFilter
     filter_class = filters.RackFilter
 
 
@@ -77,7 +77,7 @@ class RackDetailView(generics.RetrieveAPIView):
     """
     """
     Retrieve a single rack
     Retrieve a single rack
     """
     """
-    queryset = Rack.objects.select_related('site')
+    queryset = Rack.objects.select_related('site', 'tenant')
     serializer_class = serializers.RackDetailSerializer
     serializer_class = serializers.RackDetailSerializer
 
 
 
 
@@ -193,8 +193,9 @@ class DeviceListView(generics.ListAPIView):
     """
     """
     List devices (filterable)
     List devices (filterable)
     """
     """
-    queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'platform', 'rack__site')\
-        .prefetch_related('primary_ip4__nat_outside', 'primary_ip6__nat_outside')
+    queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform',
+                                             'rack__site').prefetch_related('primary_ip4__nat_outside',
+                                                                            'primary_ip6__nat_outside')
     serializer_class = serializers.DeviceSerializer
     serializer_class = serializers.DeviceSerializer
     filter_class = filters.DeviceFilter
     filter_class = filters.DeviceFilter
     renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer]
     renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer]

+ 34 - 0
netbox/dcim/filters.py

@@ -6,6 +6,7 @@ from .models import (
     ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
     ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
     Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
     Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
 )
 )
+from tenancy.models import Tenant
 
 
 
 
 class SiteFilter(django_filters.FilterSet):
 class SiteFilter(django_filters.FilterSet):
@@ -13,6 +14,17 @@ class SiteFilter(django_filters.FilterSet):
         action='search',
         action='search',
         label='Search',
         label='Search',
     )
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
@@ -74,6 +86,17 @@ class RackFilter(django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Group',
         label='Group',
     )
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
@@ -143,6 +166,17 @@ class DeviceFilter(django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Role (slug)',
         label='Role (slug)',
     )
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
     device_type_id = django_filters.ModelMultipleChoiceFilter(
     device_type_id = django_filters.ModelMultipleChoiceFilter(
         name='device_type',
         name='device_type',
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),

+ 43 - 10
netbox/dcim/forms.py

@@ -4,6 +4,7 @@ from django import forms
 from django.db.models import Count, Q
 from django.db.models import Count, Q
 
 
 from ipam.models import IPAddress
 from ipam.models import IPAddress
+from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
     APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
     FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
     FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
@@ -48,7 +49,7 @@ class SiteForm(forms.ModelForm, BootstrapMixin):
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
-        fields = ['name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments']
+        fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments']
         widgets = {
         widgets = {
             'physical_address': SmallTextarea(attrs={'rows': 3}),
             'physical_address': SmallTextarea(attrs={'rows': 3}),
             'shipping_address': SmallTextarea(attrs={'rows': 3}),
             'shipping_address': SmallTextarea(attrs={'rows': 3}),
@@ -63,16 +64,28 @@ class SiteForm(forms.ModelForm, BootstrapMixin):
 
 
 
 
 class SiteFromCSVForm(forms.ModelForm):
 class SiteFromCSVForm(forms.ModelForm):
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
-        fields = ['name', 'slug', 'facility', 'asn']
+        fields = ['name', 'slug', 'tenant', 'facility', 'asn']
 
 
 
 
 class SiteImportForm(BulkImportForm, BootstrapMixin):
 class SiteImportForm(BulkImportForm, BootstrapMixin):
     csv = CSVDataField(csv_form=SiteFromCSVForm)
     csv = CSVDataField(csv_form=SiteFromCSVForm)
 
 
 
 
+def site_tenant_choices():
+    tenant_choices = Tenant.objects.annotate(site_count=Count('sites'))
+    return [(t.slug, u'{} ({})'.format(t.name, t.site_count)) for t in tenant_choices]
+
+
+class SiteFilterForm(forms.Form, BootstrapMixin):
+    tenant = forms.MultipleChoiceField(required=False, choices=site_tenant_choices,
+                                       widget=forms.SelectMultiple(attrs={'size': 8}))
+
+
 #
 #
 # Rack groups
 # Rack groups
 #
 #
@@ -107,7 +120,7 @@ class RackForm(forms.ModelForm, BootstrapMixin):
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
-        fields = ['site', 'group', 'name', 'facility_id', 'u_height', 'comments']
+        fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'u_height', 'comments']
         help_texts = {
         help_texts = {
             'site': "The site at which the rack exists",
             'site': "The site at which the rack exists",
             'name': "Organizational rack name",
             'name': "Organizational rack name",
@@ -135,10 +148,12 @@ class RackFromCSVForm(forms.ModelForm):
     site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
     site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
                                   error_messages={'invalid_choice': 'Site not found.'})
                                   error_messages={'invalid_choice': 'Site not found.'})
     group_name = forms.CharField(required=False)
     group_name = forms.CharField(required=False)
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
-        fields = ['site', 'group_name', 'name', 'facility_id', 'u_height']
+        fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'u_height']
 
 
     def clean(self):
     def clean(self):
 
 
@@ -161,6 +176,7 @@ class RackBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
     pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
     group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False)
     group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False)
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     u_height = forms.IntegerField(required=False, label='Height (U)')
     u_height = forms.IntegerField(required=False, label='Height (U)')
     comments = CommentField()
     comments = CommentField()
 
 
@@ -175,11 +191,18 @@ def rack_group_choices():
     return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices]
     return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices]
 
 
 
 
+def rack_tenant_choices():
+    tenant_choices = Tenant.objects.annotate(rack_count=Count('racks'))
+    return [(t.slug, u'{} ({})'.format(t.name, t.rack_count)) for t in tenant_choices]
+
+
 class RackFilterForm(forms.Form, BootstrapMixin):
 class RackFilterForm(forms.Form, BootstrapMixin):
     site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
     site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
     group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
     group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
                                          widget=forms.SelectMultiple(attrs={'size': 8}))
                                          widget=forms.SelectMultiple(attrs={'size': 8}))
+    tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices,
+                                       widget=forms.SelectMultiple(attrs={'size': 8}))
 
 
 
 
 #
 #
@@ -203,8 +226,8 @@ class DeviceTypeForm(forms.ModelForm, BootstrapMixin):
 
 
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
-        fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
-                  'is_network_device', 'subdevice_role']
+        fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
+                  'is_pdu', 'is_network_device', 'subdevice_role']
 
 
 
 
 class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
 class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
@@ -324,7 +347,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
-        fields = ['name', 'device_role', 'device_type', 'serial', 'site', 'rack', 'position', 'face', 'status',
+        fields = ['name', 'device_role', 'tenant', 'device_type', 'serial', 'site', 'rack', 'position', 'face', 'status',
                   'platform', 'primary_ip4', 'primary_ip6', 'comments']
                   'platform', 'primary_ip4', 'primary_ip6', 'comments']
         help_texts = {
         help_texts = {
             'device_role': "The function this device serves",
             'device_role': "The function this device serves",
@@ -410,6 +433,8 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
 class BaseDeviceFromCSVForm(forms.ModelForm):
 class BaseDeviceFromCSVForm(forms.ModelForm):
     device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name',
     device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name',
                                          error_messages={'invalid_choice': 'Invalid device role.'})
                                          error_messages={'invalid_choice': 'Invalid device role.'})
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
     manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name',
     manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name',
                                           error_messages={'invalid_choice': 'Invalid manufacturer.'})
                                           error_messages={'invalid_choice': 'Invalid manufacturer.'})
     model_name = forms.CharField()
     model_name = forms.CharField()
@@ -441,8 +466,8 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm):
     face = forms.CharField(required=False)
     face = forms.CharField(required=False)
 
 
     class Meta(BaseDeviceFromCSVForm.Meta):
     class Meta(BaseDeviceFromCSVForm.Meta):
-        fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'site', 'rack_name',
-                  'position', 'face']
+        fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'site',
+                  'rack_name', 'position', 'face']
 
 
     def clean(self):
     def clean(self):
 
 
@@ -477,7 +502,7 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
     device_bay_name = forms.CharField(required=False)
     device_bay_name = forms.CharField(required=False)
 
 
     class Meta(BaseDeviceFromCSVForm.Meta):
     class Meta(BaseDeviceFromCSVForm.Meta):
-        fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'parent',
+        fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'parent',
                   'device_bay_name']
                   'device_bay_name']
 
 
     def clean(self):
     def clean(self):
@@ -512,6 +537,7 @@ class DeviceBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
     pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
     device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
     device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
     device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
     device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False, label='Tenant')
     platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, label='Platform')
     platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, label='Platform')
     platform_delete = forms.BooleanField(required=False, label='Set platform to "none"')
     platform_delete = forms.BooleanField(required=False, label='Set platform to "none"')
     status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status')
     status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status')
@@ -533,6 +559,11 @@ def device_role_choices():
     return [(r.slug, u'{} ({})'.format(r.name, r.device_count)) for r in role_choices]
     return [(r.slug, u'{} ({})'.format(r.name, r.device_count)) for r in role_choices]
 
 
 
 
+def device_tenant_choices():
+    tenant_choices = Tenant.objects.annotate(device_count=Count('devices'))
+    return [(t.slug, u'{} ({})'.format(t.name, t.device_count)) for t in tenant_choices]
+
+
 def device_type_choices():
 def device_type_choices():
     type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances'))
     type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances'))
     return [(t.pk, u'{} ({})'.format(t, t.device_count)) for t in type_choices]
     return [(t.pk, u'{} ({})'.format(t, t.device_count)) for t in type_choices]
@@ -550,6 +581,8 @@ class DeviceFilterForm(forms.Form, BootstrapMixin):
                                               widget=forms.SelectMultiple(attrs={'size': 8}))
                                               widget=forms.SelectMultiple(attrs={'size': 8}))
     role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
     role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
+    tenant = forms.MultipleChoiceField(required=False, choices=device_tenant_choices,
+                                       widget=forms.SelectMultiple(attrs={'size': 8}))
     device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type',
     device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type',
                                                widget=forms.SelectMultiple(attrs={'size': 8}))
                                                widget=forms.SelectMultiple(attrs={'size': 8}))
     platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices)
     platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices)

+ 32 - 0
netbox/dcim/migrations/0012_site_rack_device_add_tenant.py

@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-07-26 21:59
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0001_initial'),
+        ('dcim', '0011_devicetype_part_number'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='device',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='tenancy.Tenant'),
+        ),
+        migrations.AddField(
+            model_name='rack',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='tenancy.Tenant'),
+        ),
+        migrations.AddField(
+            model_name='site',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sites', to='tenancy.Tenant'),
+        ),
+    ]

+ 7 - 0
netbox/dcim/models.py

@@ -8,6 +8,7 @@ from django.db import models
 from django.db.models import Count, Q, ObjectDoesNotExist
 from django.db.models import Count, Q, ObjectDoesNotExist
 
 
 from extras.rpc import RPC_CLIENTS
 from extras.rpc import RPC_CLIENTS
+from tenancy.models import Tenant
 from utilities.fields import NullableCharField
 from utilities.fields import NullableCharField
 from utilities.managers import NaturalOrderByManager
 from utilities.managers import NaturalOrderByManager
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
@@ -152,6 +153,7 @@ class Site(CreatedUpdatedModel):
     """
     """
     name = models.CharField(max_length=50, unique=True)
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
     slug = models.SlugField(unique=True)
+    tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='sites', on_delete=models.PROTECT)
     facility = models.CharField(max_length=50, blank=True)
     facility = models.CharField(max_length=50, blank=True)
     asn = ASNField(blank=True, null=True, verbose_name='ASN')
     asn = ASNField(blank=True, null=True, verbose_name='ASN')
     physical_address = models.CharField(max_length=200, blank=True)
     physical_address = models.CharField(max_length=200, blank=True)
@@ -173,6 +175,7 @@ class Site(CreatedUpdatedModel):
         return ','.join([
         return ','.join([
             self.name,
             self.name,
             self.slug,
             self.slug,
+            self.tenant.name if self.tenant else '',
             self.facility,
             self.facility,
             str(self.asn),
             str(self.asn),
         ])
         ])
@@ -237,6 +240,7 @@ class Rack(CreatedUpdatedModel):
     facility_id = NullableCharField(max_length=30, blank=True, null=True, verbose_name='Facility ID')
     facility_id = NullableCharField(max_length=30, blank=True, null=True, verbose_name='Facility ID')
     site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT)
     site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT)
     group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL)
     group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL)
+    tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT)
     u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)')
     u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)')
     comments = models.TextField(blank=True)
     comments = models.TextField(blank=True)
 
 
@@ -272,6 +276,7 @@ class Rack(CreatedUpdatedModel):
             self.group.name if self.group else '',
             self.group.name if self.group else '',
             self.name,
             self.name,
             self.facility_id or '',
             self.facility_id or '',
+            self.tenant.name if self.tenant else '',
             str(self.u_height),
             str(self.u_height),
         ])
         ])
 
 
@@ -631,6 +636,7 @@ class Device(CreatedUpdatedModel):
     """
     """
     device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT)
     device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT)
     device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT)
     device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT)
+    tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='devices', on_delete=models.PROTECT)
     platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
     platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
     name = NullableCharField(max_length=50, blank=True, null=True, unique=True)
     name = NullableCharField(max_length=50, blank=True, null=True, unique=True)
     serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
     serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
@@ -724,6 +730,7 @@ class Device(CreatedUpdatedModel):
         return ','.join([
         return ','.join([
             self.name or '',
             self.name or '',
             self.device_role.name,
             self.device_role.name,
+            self.tenant.name if self.tenant else '',
             self.device_type.manufacturer.name,
             self.device_type.manufacturer.name,
             self.device_type.model,
             self.device_type.model,
             self.platform.name if self.platform else '',
             self.platform.name if self.platform else '',

+ 13 - 7
netbox/dcim/tables.py

@@ -61,6 +61,7 @@ UTILIZATION_GRAPH = """
 class SiteTable(BaseTable):
 class SiteTable(BaseTable):
     name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
     name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
     facility = tables.Column(verbose_name='Facility')
     facility = tables.Column(verbose_name='Facility')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     asn = tables.Column(verbose_name='ASN')
     asn = tables.Column(verbose_name='ASN')
     rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
     rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
     device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
     device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
@@ -70,7 +71,7 @@ class SiteTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Site
         model = Site
-        fields = ('name', 'facility', 'asn', 'rack_count', 'device_count', 'prefix_count', 'vlan_count',
+        fields = ('name', 'facility', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count', 'vlan_count',
                   'circuit_count')
                   'circuit_count')
 
 
 
 
@@ -101,14 +102,16 @@ class RackTable(BaseTable):
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     facility_id = tables.Column(verbose_name='Facility ID')
     facility_id = tables.Column(verbose_name='Facility ID')
-    u_height = tables.Column(verbose_name='Height (U)')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
+    u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
     devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
     devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
-    u_consumed = tables.Column(accessor=Accessor('u_consumed'), verbose_name='Used (U)')
+    u_consumed = tables.TemplateColumn("{{ record.u_consumed|default:'0' }}U", verbose_name='Used')
     utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
     utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Rack
         model = Rack
-        fields = ('pk', 'name', 'site', 'group', 'facility_id', 'u_height', 'devices', 'u_consumed', 'utilization')
+        fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'u_height', 'devices', 'u_consumed',
+                  'utilization')
 
 
 
 
 class RackImportTable(BaseTable):
 class RackImportTable(BaseTable):
@@ -116,11 +119,12 @@ class RackImportTable(BaseTable):
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     facility_id = tables.Column(verbose_name='Facility ID')
     facility_id = tables.Column(verbose_name='Facility ID')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     u_height = tables.Column(verbose_name='Height (U)')
     u_height = tables.Column(verbose_name='Height (U)')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Rack
         model = Rack
-        fields = ('site', 'group', 'name', 'facility_id', 'u_height')
+        fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height')
 
 
 
 
 #
 #
@@ -259,6 +263,7 @@ class DeviceTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
     status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
     site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
     device_role = tables.Column(verbose_name='Role')
     device_role = tables.Column(verbose_name='Role')
@@ -268,11 +273,12 @@ class DeviceTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Device
         model = Device
-        fields = ('pk', 'name', 'status', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
+        fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
 
 
 
 
 class DeviceImportTable(BaseTable):
 class DeviceImportTable(BaseTable):
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
     site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
     position = tables.Column(verbose_name='Position')
     position = tables.Column(verbose_name='Position')
@@ -281,7 +287,7 @@ class DeviceImportTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Device
         model = Device
-        fields = ('name', 'site', 'rack', 'position', 'device_role', 'device_type')
+        fields = ('name', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
         empty_text = False
         empty_text = False
 
 
 
 

+ 6 - 0
netbox/dcim/tests/test_apis.py

@@ -15,6 +15,7 @@ class SiteTest(APITestCase):
         'id',
         'id',
         'name',
         'name',
         'slug',
         'slug',
+        'tenant',
         'facility',
         'facility',
         'asn',
         'asn',
         'physical_address',
         'physical_address',
@@ -40,6 +41,7 @@ class SiteTest(APITestCase):
         'display_name',
         'display_name',
         'site',
         'site',
         'group',
         'group',
+        'tenant',
         'u_height',
         'u_height',
         'comments'
         'comments'
     ]
     ]
@@ -115,6 +117,7 @@ class RackTest(APITestCase):
         'display_name',
         'display_name',
         'site',
         'site',
         'group',
         'group',
+        'tenant',
         'u_height',
         'u_height',
         'comments'
         'comments'
     ]
     ]
@@ -126,6 +129,7 @@ class RackTest(APITestCase):
         'display_name',
         'display_name',
         'site',
         'site',
         'group',
         'group',
+        'tenant',
         'u_height',
         'u_height',
         'comments',
         'comments',
         'front_units',
         'front_units',
@@ -311,6 +315,7 @@ class DeviceTest(APITestCase):
         'display_name',
         'display_name',
         'device_type',
         'device_type',
         'device_role',
         'device_role',
+        'tenant',
         'platform',
         'platform',
         'serial',
         'serial',
         'rack',
         'rack',
@@ -388,6 +393,7 @@ class DeviceTest(APITestCase):
             'rack_name',
             'rack_name',
             'serial',
             'serial',
             'status',
             'status',
+            'tenant',
         ]
         ]
 
 
         response = self.client.get(endpoint)
         response = self.client.get(endpoint)

+ 4 - 3
netbox/dcim/views.py

@@ -61,8 +61,9 @@ def expand_pattern(string):
 #
 #
 
 
 class SiteListView(ObjectListView):
 class SiteListView(ObjectListView):
-    queryset = Site.objects.all()
+    queryset = Site.objects.select_related('tenant')
     filter = filters.SiteFilter
     filter = filters.SiteFilter
+    filter_form = forms.SiteFilterForm
     table = tables.SiteTable
     table = tables.SiteTable
     template_name = 'dcim/site_list.html'
     template_name = 'dcim/site_list.html'
 
 
@@ -200,7 +201,7 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
     def update_objects(self, pk_list, form):
 
 
         fields_to_update = {}
         fields_to_update = {}
-        for field in ['site', 'group', 'u_height', 'comments']:
+        for field in ['site', 'group', 'tenant', 'u_height', 'comments']:
             if form.cleaned_data[field]:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]
                 fields_to_update[field] = form.cleaned_data[field]
 
 
@@ -632,7 +633,7 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
         if form.cleaned_data['status']:
         if form.cleaned_data['status']:
             status = form.cleaned_data['status']
             status = form.cleaned_data['status']
             fields_to_update['status'] = True if status == 'True' else False
             fields_to_update['status'] = True if status == 'True' else False
-        for field in ['device_type', 'device_role', 'serial']:
+        for field in ['tenant', 'device_type', 'device_role', 'serial']:
             if form.cleaned_data[field]:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]
                 fields_to_update[field] = form.cleaned_data[field]
 
 

+ 9 - 4
netbox/ipam/admin.py

@@ -7,7 +7,12 @@ from .models import (
 
 
 @admin.register(VRF)
 @admin.register(VRF)
 class VRFAdmin(admin.ModelAdmin):
 class VRFAdmin(admin.ModelAdmin):
-    list_display = ['name', 'rd']
+    list_display = ['name', 'rd', 'tenant', 'enforce_unique']
+    list_filter = ['tenant']
+
+    def get_queryset(self, request):
+        qs = super(VRFAdmin, self).get_queryset(request)
+        return qs.select_related('tenant')
 
 
 
 
 @admin.register(Role)
 @admin.register(Role)
@@ -67,10 +72,10 @@ class VLANGroupAdmin(admin.ModelAdmin):
 
 
 @admin.register(VLAN)
 @admin.register(VLAN)
 class VLANAdmin(admin.ModelAdmin):
 class VLANAdmin(admin.ModelAdmin):
-    list_display = ['site', 'vid', 'name', 'status', 'role']
-    list_filter = ['site', 'status', 'role']
+    list_display = ['site', 'vid', 'name', 'tenant', 'status', 'role']
+    list_filter = ['site', 'tenant', 'status', 'role']
     search_fields = ['vid', 'name']
     search_fields = ['vid', 'name']
 
 
     def get_queryset(self, request):
     def get_queryset(self, request):
         qs = super(VLANAdmin, self).get_queryset(request)
         qs = super(VLANAdmin, self).get_queryset(request)
-        return qs.select_related('site', 'role')
+        return qs.select_related('site', 'tenant', 'role')

+ 5 - 2
netbox/ipam/api/serializers.py

@@ -2,6 +2,7 @@ from rest_framework import serializers
 
 
 from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
 from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
 from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
 from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
+from tenancy.api.serializers import TenantNestedSerializer
 
 
 
 
 #
 #
@@ -9,10 +10,11 @@ from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLAN
 #
 #
 
 
 class VRFSerializer(serializers.ModelSerializer):
 class VRFSerializer(serializers.ModelSerializer):
+    tenant = TenantNestedSerializer()
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
-        fields = ['id', 'name', 'rd', 'enforce_unique', 'description']
+        fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description']
 
 
 
 
 class VRFNestedSerializer(VRFSerializer):
 class VRFNestedSerializer(VRFSerializer):
@@ -98,11 +100,12 @@ class VLANGroupNestedSerializer(VLANGroupSerializer):
 class VLANSerializer(serializers.ModelSerializer):
 class VLANSerializer(serializers.ModelSerializer):
     site = SiteNestedSerializer()
     site = SiteNestedSerializer()
     group = VLANGroupNestedSerializer()
     group = VLANGroupNestedSerializer()
+    tenant = TenantNestedSerializer()
     role = RoleNestedSerializer()
     role = RoleNestedSerializer()
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
-        fields = ['id', 'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'display_name']
+        fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name']
 
 
 
 
 class VLANNestedSerializer(VLANSerializer):
 class VLANNestedSerializer(VLANSerializer):

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

@@ -14,7 +14,7 @@ class VRFListView(generics.ListAPIView):
     """
     """
     List all VRFs
     List all VRFs
     """
     """
-    queryset = VRF.objects.all()
+    queryset = VRF.objects.select_related('tenant')
     serializer_class = serializers.VRFSerializer
     serializer_class = serializers.VRFSerializer
     filter_class = filters.VRFFilter
     filter_class = filters.VRFFilter
 
 
@@ -23,7 +23,7 @@ class VRFDetailView(generics.RetrieveAPIView):
     """
     """
     Retrieve a single VRF
     Retrieve a single VRF
     """
     """
-    queryset = VRF.objects.all()
+    queryset = VRF.objects.select_related('tenant')
     serializer_class = serializers.VRFSerializer
     serializer_class = serializers.VRFSerializer
 
 
 
 
@@ -161,7 +161,7 @@ class VLANListView(generics.ListAPIView):
     """
     """
     List VLANs (filterable)
     List VLANs (filterable)
     """
     """
-    queryset = VLAN.objects.select_related('site', 'role')
+    queryset = VLAN.objects.select_related('site', 'tenant', 'role')
     serializer_class = serializers.VLANSerializer
     serializer_class = serializers.VLANSerializer
     filter_class = filters.VLANFilter
     filter_class = filters.VLANFilter
 
 
@@ -170,5 +170,5 @@ class VLANDetailView(generics.RetrieveAPIView):
     """
     """
     Retrieve a single VLAN
     Retrieve a single VLAN
     """
     """
-    queryset = VLAN.objects.select_related('site', 'role')
+    queryset = VLAN.objects.select_related('site', 'tenant', 'role')
     serializer_class = serializers.VLANSerializer
     serializer_class = serializers.VLANSerializer

+ 23 - 0
netbox/ipam/filters.py

@@ -3,6 +3,7 @@ from netaddr import IPNetwork
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
 
 
 from dcim.models import Site, Device, Interface
 from dcim.models import Site, Device, Interface
+from tenancy.models import Tenant
 
 
 from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
 from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
 
 
@@ -13,6 +14,17 @@ class VRFFilter(django_filters.FilterSet):
         lookup_type='icontains',
         lookup_type='icontains',
         label='Name',
         label='Name',
     )
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
@@ -226,6 +238,17 @@ class VLANFilter(django_filters.FilterSet):
         name='vid',
         name='vid',
         label='VLAN number (1-4095)',
         label='VLAN number (1-4095)',
     )
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        name='tenant',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
     role_id = django_filters.ModelMultipleChoiceFilter(
     role_id = django_filters.ModelMultipleChoiceFilter(
         name='role',
         name='role',
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),

+ 28 - 4
netbox/ipam/forms.py

@@ -4,6 +4,7 @@ from django import forms
 from django.db.models import Count
 from django.db.models import Count
 
 
 from dcim.models import Site, Device, Interface
 from dcim.models import Site, Device, Interface
+from tenancy.models import Tenant
 from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField
 from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField
 
 
 from .models import (
 from .models import (
@@ -23,7 +24,7 @@ class VRFForm(forms.ModelForm, BootstrapMixin):
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
-        fields = ['name', 'rd', 'enforce_unique', 'description']
+        fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
         labels = {
         labels = {
             'rd': "RD",
             'rd': "RD",
         }
         }
@@ -33,10 +34,12 @@ class VRFForm(forms.ModelForm, BootstrapMixin):
 
 
 
 
 class VRFFromCSVForm(forms.ModelForm):
 class VRFFromCSVForm(forms.ModelForm):
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
-        fields = ['name', 'rd', 'enforce_unique', 'description']
+        fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
 
 
 
 
 class VRFImportForm(BulkImportForm, BootstrapMixin):
 class VRFImportForm(BulkImportForm, BootstrapMixin):
@@ -45,9 +48,20 @@ class VRFImportForm(BulkImportForm, BootstrapMixin):
 
 
 class VRFBulkEditForm(forms.Form, BootstrapMixin):
 class VRFBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
     pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     description = forms.CharField(max_length=100, required=False)
     description = forms.CharField(max_length=100, required=False)
 
 
 
 
+def vrf_tenant_choices():
+    tenant_choices = Tenant.objects.annotate(vrf_count=Count('vrfs'))
+    return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
+
+
+class VRFFilterForm(forms.Form, BootstrapMixin):
+    tenant = forms.MultipleChoiceField(required=False, choices=vrf_tenant_choices,
+                                       widget=forms.SelectMultiple(attrs={'size': 8}))
+
+
 #
 #
 # RIRs
 # RIRs
 #
 #
@@ -444,7 +458,7 @@ class VLANForm(forms.ModelForm, BootstrapMixin):
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
-        fields = ['site', 'group', 'vid', 'name', 'description', 'status', 'role']
+        fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
         help_texts = {
         help_texts = {
             'site': "The site at which this VLAN exists",
             'site': "The site at which this VLAN exists",
             'group': "VLAN group (optional)",
             'group': "VLAN group (optional)",
@@ -475,13 +489,15 @@ class VLANFromCSVForm(forms.ModelForm):
                                   error_messages={'invalid_choice': 'Device not found.'})
                                   error_messages={'invalid_choice': 'Device not found.'})
     group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name',
     group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name',
                                    error_messages={'invalid_choice': 'VLAN group not found.'})
                                    error_messages={'invalid_choice': 'VLAN group not found.'})
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
     status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES])
     status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES])
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
                                   error_messages={'invalid_choice': 'Invalid role.'})
                                   error_messages={'invalid_choice': 'Invalid role.'})
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
-        fields = ['site', 'group', 'vid', 'name', 'status_name', 'role', 'description']
+        fields = ['site', 'group', 'vid', 'name', 'tenant', 'status_name', 'role', 'description']
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         m = super(VLANFromCSVForm, self).save(commit=False)
         m = super(VLANFromCSVForm, self).save(commit=False)
@@ -500,6 +516,7 @@ class VLANBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
     pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
     group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
     group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
     status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
     description = forms.CharField(max_length=100, required=False)
     description = forms.CharField(max_length=100, required=False)
@@ -515,6 +532,11 @@ def vlan_group_choices():
     return [(g.pk, u'{} ({})'.format(g, g.vlan_count)) for g in group_choices]
     return [(g.pk, u'{} ({})'.format(g, g.vlan_count)) for g in group_choices]
 
 
 
 
+def vlan_tenant_choices():
+    tenant_choices = Tenant.objects.annotate(vrf_count=Count('vlans'))
+    return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
+
+
 def vlan_status_choices():
 def vlan_status_choices():
     status_counts = {}
     status_counts = {}
     for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
     for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
@@ -532,6 +554,8 @@ class VLANFilterForm(forms.Form, BootstrapMixin):
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
     group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group',
     group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group',
                                          widget=forms.SelectMultiple(attrs={'size': 8}))
                                          widget=forms.SelectMultiple(attrs={'size': 8}))
+    tenant = forms.MultipleChoiceField(required=False, choices=vlan_tenant_choices,
+                                       widget=forms.SelectMultiple(attrs={'size': 8}))
     status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
     status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
     role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
     role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
                                      widget=forms.SelectMultiple(attrs={'size': 8}))

+ 27 - 0
netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py

@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-07-27 14:39
+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', '0005_auto_20160725_1842'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='vlan',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='tenancy.Tenant'),
+        ),
+        migrations.AddField(
+            model_name='vrf',
+            name='tenant',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vrfs', to='tenancy.Tenant'),
+        ),
+    ]

+ 8 - 2
netbox/ipam/models.py

@@ -7,6 +7,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 
 
 from dcim.models import Interface
 from dcim.models import Interface
+from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
 
 
 from .fields import IPNetworkField, IPAddressField
 from .fields import IPNetworkField, IPAddressField
@@ -46,6 +47,7 @@ class VRF(CreatedUpdatedModel):
     """
     """
     name = models.CharField(max_length=50)
     name = models.CharField(max_length=50)
     rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher')
     rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher')
+    tenant = models.ForeignKey(Tenant, related_name='vrfs', blank=True, null=True, on_delete=models.PROTECT)
     enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space',
     enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space',
                                          help_text="Prevent duplicate prefixes/IP addresses within this VRF")
                                          help_text="Prevent duplicate prefixes/IP addresses within this VRF")
     description = models.CharField(max_length=100, blank=True)
     description = models.CharField(max_length=100, blank=True)
@@ -65,6 +67,8 @@ class VRF(CreatedUpdatedModel):
         return ','.join([
         return ','.join([
             self.name,
             self.name,
             self.rd,
             self.rd,
+            self.tenant.name if self.tenant else '',
+            'True' if self.enforce_unique else '',
             self.description,
             self.description,
         ])
         ])
 
 
@@ -291,7 +295,7 @@ class Prefix(CreatedUpdatedModel):
 
 
 class IPAddress(CreatedUpdatedModel):
 class IPAddress(CreatedUpdatedModel):
     """
     """
-    An IPAddress represents an individual IPV4 or IPv6 address and its mask. The mask length should match what is
+    An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
     configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like
     configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like
     Prefixes, IPAddresses can optionally be assigned to a VRF. An IPAddress can optionally be assigned to an Interface.
     Prefixes, IPAddresses can optionally be assigned to a VRF. An IPAddress can optionally be assigned to an Interface.
     Interfaces can have zero or more IPAddresses assigned to them.
     Interfaces can have zero or more IPAddresses assigned to them.
@@ -407,9 +411,10 @@ class VLAN(CreatedUpdatedModel):
         MaxValueValidator(4094)
         MaxValueValidator(4094)
     ])
     ])
     name = models.CharField(max_length=64)
     name = models.CharField(max_length=64)
-    description = models.CharField(max_length=100, blank=True)
+    tenant = models.ForeignKey(Tenant, related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
     status = models.PositiveSmallIntegerField('Status', choices=VLAN_STATUS_CHOICES, default=1)
     status = models.PositiveSmallIntegerField('Status', choices=VLAN_STATUS_CHOICES, default=1)
     role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
     role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
+    description = models.CharField(max_length=100, blank=True)
 
 
     class Meta:
     class Meta:
         ordering = ['site', 'group', 'vid']
         ordering = ['site', 'group', 'vid']
@@ -438,6 +443,7 @@ class VLAN(CreatedUpdatedModel):
             self.group.name if self.group else '',
             self.group.name if self.group else '',
             str(self.vid),
             str(self.vid),
             self.name,
             self.name,
+            self.tenant.name if self.tenant else '',
             self.get_status_display(),
             self.get_status_display(),
             self.role.name if self.role else '',
             self.role.name if self.role else '',
             self.description,
             self.description,

+ 4 - 2
netbox/ipam/tables.py

@@ -58,11 +58,12 @@ class VRFTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
     name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
     rd = tables.Column(verbose_name='RD')
     rd = tables.Column(verbose_name='RD')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     description = tables.Column(orderable=False, verbose_name='Description')
     description = tables.Column(orderable=False, verbose_name='Description')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VRF
         model = VRF
-        fields = ('pk', 'name', 'rd', 'description')
+        fields = ('pk', 'name', 'rd', 'tenant', 'description')
 
 
 
 
 #
 #
@@ -203,9 +204,10 @@ class VLANTable(BaseTable):
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     name = tables.Column(verbose_name='Name')
     name = tables.Column(verbose_name='Name')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
     role = tables.Column(verbose_name='Role')
     role = tables.Column(verbose_name='Role')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VLAN
         model = VLAN
-        fields = ('pk', 'vid', 'site', 'group', 'name', 'status', 'role')
+        fields = ('pk', 'vid', 'site', 'group', 'name', 'tenant', 'status', 'role')

+ 4 - 3
netbox/ipam/views.py

@@ -36,8 +36,9 @@ def add_available_prefixes(parent, prefix_list):
 #
 #
 
 
 class VRFListView(ObjectListView):
 class VRFListView(ObjectListView):
-    queryset = VRF.objects.all()
+    queryset = VRF.objects.select_related('tenant')
     filter = filters.VRFFilter
     filter = filters.VRFFilter
+    filter_form = forms.VRFFilterForm
     table = tables.VRFTable
     table = tables.VRFTable
     edit_permissions = ['ipam.change_vrf', 'ipam.delete_vrf']
     edit_permissions = ['ipam.change_vrf', 'ipam.delete_vrf']
     template_name = 'ipam/vrf_list.html'
     template_name = 'ipam/vrf_list.html'
@@ -85,7 +86,7 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
     def update_objects(self, pk_list, form):
 
 
         fields_to_update = {}
         fields_to_update = {}
-        for field in ['description']:
+        for field in ['tenant', 'description']:
             if form.cleaned_data[field]:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]
                 fields_to_update[field] = form.cleaned_data[field]
 
 
@@ -558,7 +559,7 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
     def update_objects(self, pk_list, form):
 
 
         fields_to_update = {}
         fields_to_update = {}
-        for field in ['site', 'group', 'status', 'role', 'description']:
+        for field in ['site', 'group', 'tenant', 'status', 'role', 'description']:
             if form.cleaned_data[field]:
             if form.cleaned_data[field]:
                 fields_to_update[field] = form.cleaned_data[field]
                 fields_to_update[field] = form.cleaned_data[field]
 
 

+ 2 - 1
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ except ImportError:
                                "the documentation.")
                                "the documentation.")
 
 
 
 
-VERSION = '1.3.3-dev'
+VERSION = '1.4.0-dev'
 
 
 # Import local configuration
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -108,6 +108,7 @@ INSTALLED_APPS = (
     'ipam',
     'ipam',
     'extras',
     'extras',
     'secrets',
     'secrets',
+    'tenancy',
     'users',
     'users',
     'utilities',
     'utilities',
 )
 )

+ 2 - 0
netbox/netbox/urls.py

@@ -22,6 +22,7 @@ urlpatterns = [
     url(r'^dcim/', include('dcim.urls', namespace='dcim')),
     url(r'^dcim/', include('dcim.urls', namespace='dcim')),
     url(r'^ipam/', include('ipam.urls', namespace='ipam')),
     url(r'^ipam/', include('ipam.urls', namespace='ipam')),
     url(r'^secrets/', include('secrets.urls', namespace='secrets')),
     url(r'^secrets/', include('secrets.urls', namespace='secrets')),
+    url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')),
     url(r'^profile/', include('users.urls', namespace='users')),
     url(r'^profile/', include('users.urls', namespace='users')),
 
 
     # API
     # API
@@ -29,6 +30,7 @@ urlpatterns = [
     url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')),
     url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')),
     url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')),
     url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')),
     url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')),
     url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')),
+    url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
     url(r'^api/docs/', include('rest_framework_swagger.urls')),
     url(r'^api/docs/', include('rest_framework_swagger.urls')),
     url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
     url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
 
 

+ 5 - 1
netbox/netbox/views.py

@@ -7,14 +7,18 @@ from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceCon
 from extras.models import UserAction
 from extras.models import UserAction
 from ipam.models import Aggregate, Prefix, IPAddress, VLAN
 from ipam.models import Aggregate, Prefix, IPAddress, VLAN
 from secrets.models import Secret
 from secrets.models import Secret
+from tenancy.models import Tenant
 
 
 
 
 def home(request):
 def home(request):
 
 
     stats = {
     stats = {
 
 
-        # DCIM
+        # Organization
         'site_count': Site.objects.count(),
         'site_count': Site.objects.count(),
+        'tenant_count': Tenant.objects.count(),
+
+        # DCIM
         'rack_count': Rack.objects.count(),
         'rack_count': Rack.objects.count(),
         'device_count': Device.objects.count(),
         'device_count': Device.objects.count(),
         'interface_connections_count': InterfaceConnection.objects.count(),
         'interface_connections_count': InterfaceConnection.objects.count(),

+ 18 - 9
netbox/templates/_base.html

@@ -24,17 +24,26 @@
             <div id="navbar" class="navbar-collapse collapse">
             <div id="navbar" class="navbar-collapse collapse">
                 {% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
                 {% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
                 <ul class="nav navbar-nav">
                 <ul class="nav navbar-nav">
-                    <li class="dropdown{% if request.path|startswith:'/dcim/sites/' %} active{% endif %}">
-                        {% if perms.dcim.add_site %}
-                            <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Sites <span class="caret"></span></a>
-                            <ul class="dropdown-menu">
-                                <li><a href="{% url 'dcim:site_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Sites</a></li>
+                    <li class="dropdown{% if request.path|startswith:'/dcim/sites/' or 'tenancy' in request.path %} active{% endif %}">
+                        <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
+                        <ul class="dropdown-menu">
+                            <li><a href="{% url 'dcim:site_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Sites</a></li>
+                            {% if perms.dcim.add_site %}
                                 <li><a href="{% url 'dcim:site_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Site</a></li>
                                 <li><a href="{% url 'dcim:site_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Site</a></li>
                                 <li><a href="{% url 'dcim:site_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Sites</a></li>
                                 <li><a href="{% url 'dcim:site_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Sites</a></li>
-                            </ul>
-                        {% else %}
-                            <a href="{% url 'dcim:site_list' %}">Sites</a>
-                        {% endif %}
+                            {% endif %}
+                            <li class="divider"></li>
+                            <li><a href="{% url 'tenancy:tenant_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Tenants</a></li>
+                            {% if perms.tenancy.add_tenant %}
+                                <li><a href="{% url 'tenancy:tenant_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Tenant</a></li>
+                                <li><a href="{% url 'tenancy:tenant_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Tenants</a></li>
+                            {% endif %}
+                            <li class="divider"></li>
+                            <li><a href="{% url 'tenancy:tenantgroup_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Tenant Groups</a></li>
+                            {% if perms.tenancy.add_tenantgroup %}
+                                <li><a href="{% url 'tenancy:tenantgroup_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Tenant Group</a></li>
+                            {% endif %}
+                        </ul>
                     </li>
                     </li>
                     <li class="dropdown{% if request.path|startswith:'/dcim/rack' %} active{% endif %}">
                     <li class="dropdown{% if request.path|startswith:'/dcim/rack' %} active{% endif %}">
                         <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Racks <span class="caret"></span></a>
                         <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Racks <span class="caret"></span></a>

+ 59 - 20
netbox/templates/circuits/circuit.html

@@ -58,25 +58,19 @@
                     <td>{{ circuit.cid }}</td>
                     <td>{{ circuit.cid }}</td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>
-                    <td>Site</td>
-                    <td>
-                        <a href="{% url 'dcim:site' slug=circuit.site.slug %}">{{ circuit.site }}</a>
-                    </td>
+                    <td>Type</td>
+                    <td><a href="{{ circuit.type.get_absolute_url }}">{{ circuit.type }}</a></td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>
-                    <td>Termination</td>
+                    <td>Tenant</td>
                     <td>
                     <td>
-                        {% if circuit.interface %}
-                            <span><a href="{% url 'dcim:device' pk=circuit.interface.device.pk %}">{{ circuit.interface.device }}</a> {{ circuit.interface }}</span>
+                        {% if circuit.tenant %}
+                            <a href="{{ circuit.tenant.get_absolute_url }}">{{ circuit.tenant }}</a>
                         {% else %}
                         {% else %}
-                            <span class="text-muted">Not defined</span>
+                            <span class="text-muted">N/A</span>
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
-                <tr>
-                    <td>Install Date</td>
-                    <td>{{ circuit.install_date }}</td>
-                </tr>
                 <tr>
                 <tr>
                     <td>Port Speed</td>
                     <td>Port Speed</td>
                     <td>{{ circuit.port_speed_human }}</td>
                     <td>{{ circuit.port_speed_human }}</td>
@@ -85,14 +79,6 @@
                     <td>Commit Rate</td>
                     <td>Commit Rate</td>
                     <td>{% if circuit.commit_rate %}{{ circuit.commit_rate_human }}{% else %}<span class="text-muted">N/A</span>{% endif %}</td>
                     <td>{% if circuit.commit_rate %}{{ circuit.commit_rate_human }}{% else %}<span class="text-muted">N/A</span>{% endif %}</td>
                 </tr>
                 </tr>
-                <tr>
-                    <td>Cross-Connect</td>
-                    <td>{{ circuit.xconnect_id }}</td>
-                </tr>
-                <tr>
-                    <td>Patch Panel/Port</td>
-                    <td>{{ circuit.pp_info }}</td>
-                </tr>
                 <tr>
                 <tr>
                     <td>Created</td>
                     <td>Created</td>
                     <td>{{ circuit.created }}</td>
                     <td>{{ circuit.created }}</td>
@@ -105,6 +91,59 @@
         </div>
         </div>
 	</div>
 	</div>
 	<div class="col-md-6">
 	<div class="col-md-6">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Termination</strong>
+            </div>
+            <table class="table table-hover panel-body">
+                <tr>
+                    <td>Site</td>
+                    <td>
+                        <a href="{% url 'dcim:site' slug=circuit.site.slug %}">{{ circuit.site }}</a>
+                    </td>
+                </tr>
+                <tr>
+                    <td>Termination</td>
+                    <td>
+                        {% if circuit.interface %}
+                            <span><a href="{% url 'dcim:device' pk=circuit.interface.device.pk %}">{{ circuit.interface.device }}</a> {{ circuit.interface }}</span>
+                        {% else %}
+                            <span class="text-muted">Not defined</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Install Date</td>
+                    <td>
+                        {% if circuit.install_date %}
+                            {{ circuit.install_date }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Cross-Connect</td>
+                    <td>
+                        {% if circuit.xconnect_id %}
+                            {{ circuit.xconnect_id }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Patch Panel/Port</td>
+                    <td>
+                        {% if circuit.pp_info %}
+                            {{ circuit.pp_info }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+            </table>
+        </div>
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Comments</strong>
                 <strong>Comments</strong>

+ 8 - 2
netbox/templates/circuits/circuit_edit.html

@@ -9,13 +9,19 @@
             {% render_field form.provider %}
             {% render_field form.provider %}
             {% render_field form.cid %}
             {% render_field form.cid %}
             {% render_field form.type %}
             {% render_field form.type %}
+            {% render_field form.tenant %}
             {% render_field form.install_date %}
             {% render_field form.install_date %}
-            {% render_field form.port_speed %}
-            {% render_field form.commit_rate %}
             {% render_field form.xconnect_id %}
             {% render_field form.xconnect_id %}
             {% render_field form.pp_info %}
             {% render_field form.pp_info %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Bandwidth</strong></div>
+        <div class="panel-body">
+            {% render_field form.port_speed %}
+            {% render_field form.commit_rate %}
+        </div>
+    </div>
     <div class="panel panel-default">
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Termination</strong></div>
         <div class="panel-heading"><strong>Termination</strong></div>
         <div class="panel-body">
         <div class="panel-body">

+ 6 - 1
netbox/templates/circuits/circuit_import.html

@@ -43,6 +43,11 @@
 					<td>Circuit type</td>
 					<td>Circuit type</td>
 					<td>Transit</td>
 					<td>Transit</td>
 				</tr>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Strickland Propane</td>
+				</tr>
 				<tr>
 				<tr>
 					<td>Site</td>
 					<td>Site</td>
 					<td>Site name</td>
 					<td>Site name</td>
@@ -76,7 +81,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<h4>Example</h4>
-		<pre>IC-603122,TeliaSonera,Transit,ASH-4,2016-02-23,10000,2000,937649,PP8371 ports 13/14</pre>
+		<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,ASH-4,2016-02-23,10000,2000,937649,PP8371 ports 13/14</pre>
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 10 - 0
netbox/templates/dcim/device.html

@@ -14,6 +14,16 @@
                 <strong>Device</strong>
                 <strong>Device</strong>
             </div>
             </div>
             <table class="table table-hover panel-body">
             <table class="table table-hover panel-body">
+                <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if device.tenant %}
+                            <a href="{{ device.tenant.get_absolute_url }}">{{ device.tenant }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td>Site</td>
                     <td>Site</td>
                     <td>
                     <td>

+ 1 - 0
netbox/templates/dcim/device_bulk_edit.html

@@ -9,6 +9,7 @@
             <td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>
             <td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>
             <td>{{ device.device_type }}</td>
             <td>{{ device.device_type }}</td>
             <td>{{ device.device_role }}</td>
             <td>{{ device.device_role }}</td>
+            <td>{{ device.tenant }}</td>
             <td>{{ device.serial }}</td>
             <td>{{ device.serial }}</td>
         </tr>
         </tr>
     {% endfor %}
     {% endfor %}

+ 1 - 0
netbox/templates/dcim/device_edit.html

@@ -7,6 +7,7 @@
         <div class="panel-body">
         <div class="panel-body">
             {% render_field form.name %}
             {% render_field form.name %}
             {% render_field form.device_role %}
             {% render_field form.device_role %}
+            {% render_field form.tenant %}
         </div>
         </div>
     </div>
     </div>
     <div class="panel panel-default">
     <div class="panel panel-default">

+ 6 - 1
netbox/templates/dcim/device_import.html

@@ -36,6 +36,11 @@
 					<td>Functional role of device</td>
 					<td>Functional role of device</td>
 					<td>ToR Switch</td>
 					<td>ToR Switch</td>
 				</tr>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Pied Piper</td>
+				</tr>
 				<tr>
 				<tr>
 					<td>Device manufacturer</td>
 					<td>Device manufacturer</td>
 					<td>Hardware manufacturer</td>
 					<td>Hardware manufacturer</td>
@@ -79,7 +84,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<h4>Example</h4>
-		<pre>rack101_sw1,ToR Switch,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,Rear</pre>
+		<pre>rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,Rear</pre>
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 10 - 0
netbox/templates/dcim/rack.html

@@ -86,6 +86,16 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if rack.tenant %}
+                            <a href="{{ rack.tenant.get_absolute_url }}">{{ rack.tenant }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td>Height</td>
                     <td>Height</td>
                     <td>{{ rack.u_height }}U</td>
                     <td>{{ rack.u_height }}U</td>

+ 1 - 0
netbox/templates/dcim/rack_bulk_edit.html

@@ -9,6 +9,7 @@
             <td><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack }}</a></td>
             <td><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack }}</a></td>
             <td>{{ rack.facility_id }}</td>
             <td>{{ rack.facility_id }}</td>
             <td>{{ rack.site }}</td>
             <td>{{ rack.site }}</td>
+            <td>{{ rack.tenant }}</td>
             <td>{{ rack.u_height }}</td>
             <td>{{ rack.u_height }}</td>
         </tr>
         </tr>
     {% endfor %}
     {% endfor %}

+ 1 - 0
netbox/templates/dcim/rack_edit.html

@@ -9,6 +9,7 @@
             {% render_field form.group %}
             {% render_field form.group %}
             {% render_field form.name %}
             {% render_field form.name %}
             {% render_field form.facility_id %}
             {% render_field form.facility_id %}
+            {% render_field form.tenant %}
             {% render_field form.u_height %}
             {% render_field form.u_height %}
         </div>
         </div>
     </div>
     </div>

+ 6 - 1
netbox/templates/dcim/rack_import.html

@@ -48,6 +48,11 @@
 					<td>Rack ID assigned by the facility (optional)</td>
 					<td>Rack ID assigned by the facility (optional)</td>
 					<td>J12.100</td>
 					<td>J12.100</td>
 				</tr>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Pied Piper</td>
+				</tr>
 				<tr>
 				<tr>
 					<td>Height</td>
 					<td>Height</td>
 					<td>Height in rack units</td>
 					<td>Height in rack units</td>
@@ -56,7 +61,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<h4>Example</h4>
-		<pre>DC-4,Cage 1400,R101,J12.100,42</pre>
+		<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,42</pre>
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 10 - 0
netbox/templates/dcim/site.html

@@ -52,6 +52,16 @@
                 <strong>Site</strong>
                 <strong>Site</strong>
             </div>
             </div>
             <table class="table table-hover panel-body">
             <table class="table table-hover panel-body">
+                <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if site.tenant %}
+                            <a href="{{ site.tenant.get_absolute_url }}">{{ site.tenant }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td>Facility</td>
                     <td>Facility</td>
                     <td>{{ site.facility }}</td>
                     <td>{{ site.facility }}</td>

+ 1 - 0
netbox/templates/dcim/site_edit.html

@@ -7,6 +7,7 @@
         <div class="panel-body">
         <div class="panel-body">
             {% render_field form.name %}
             {% render_field form.name %}
             {% render_field form.slug %}
             {% render_field form.slug %}
+            {% render_field form.tenant %}
             {% render_field form.facility %}
             {% render_field form.facility %}
             {% render_field form.asn %}
             {% render_field form.asn %}
             {% render_field form.physical_address %}
             {% render_field form.physical_address %}

+ 6 - 1
netbox/templates/dcim/site_import.html

@@ -38,6 +38,11 @@
 					<td>URL-friendly name</td>
 					<td>URL-friendly name</td>
 					<td>ash4-south</td>
 					<td>ash4-south</td>
 				</tr>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Pied Piper</td>
+				</tr>
 				<tr>
 				<tr>
 					<td>Facility</td>
 					<td>Facility</td>
 					<td>Name of the hosting facility (optional)</td>
 					<td>Name of the hosting facility (optional)</td>
@@ -51,7 +56,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<h4>Example</h4>
-		<pre>ASH-4 South,ash4-south,Equinix DC6,65000</pre>
+		<pre>ASH-4 South,ash4-south,Pied Piper,Equinix DC6,65000</pre>
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 1 - 0
netbox/templates/dcim/site_list.html

@@ -37,6 +37,7 @@
 				</form>
 				</form>
 			</div>
 			</div>
 		</div>
 		</div>
+		{% include 'inc/filter_panel.html' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 27 - 15
netbox/templates/home.html

@@ -50,7 +50,7 @@
     <div class="col-md-4">
     <div class="col-md-4">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
-                <strong>DCIM</strong>
+                <strong>Organization</strong>
             </div>
             </div>
             <div class="list-group">
             <div class="list-group">
                 <div class="list-group-item">
                 <div class="list-group-item">
@@ -58,6 +58,18 @@
                     <h4 class="list-group-item-heading"><a href="{% url 'dcim:site_list' %}">Sites</a></h4>
                     <h4 class="list-group-item-heading"><a href="{% url 'dcim:site_list' %}">Sites</a></h4>
                     <p class="list-group-item-text text-muted">Geographic locations</p>
                     <p class="list-group-item-text text-muted">Geographic locations</p>
                 </div>
                 </div>
+                <div class="list-group-item">
+                    <span class="badge pull-right">{{ stats.tenant_count }}</span>
+                    <h4 class="list-group-item-heading"><a href="{% url 'tenancy:tenant_list' %}">Tenants</a></h4>
+                    <p class="list-group-item-text text-muted">Customers or departments</p>
+                </div>
+            </div>
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>DCIM</strong>
+            </div>
+            <div class="list-group">
                 <div class="list-group-item">
                 <div class="list-group-item">
                     <span class="badge pull-right">{{ stats.rack_count }}</span>
                     <span class="badge pull-right">{{ stats.rack_count }}</span>
                     <h4 class="list-group-item-heading"><a href="{% url 'dcim:rack_list' %}">Racks</a></h4>
                     <h4 class="list-group-item-heading"><a href="{% url 'dcim:rack_list' %}">Racks</a></h4>
@@ -79,20 +91,6 @@
                 </div>
                 </div>
             </div>
             </div>
         </div>
         </div>
-        {% if perms.secrets %}
-            <div class="panel panel-default">
-                <div class="panel-heading">
-                    <strong>Secrets</strong>
-                </div>
-                <div class="list-group">
-                    <div class="list-group-item">
-                        <span class="badge pull-right">{{ stats.secret_count }}</span>
-                        <h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
-                        <p class="list-group-item-text text-muted">Sensitive data (such as passwords) which has been stored securely</p>
-                    </div>
-                </div>
-            </div>
-        {% endif %}
     </div>
     </div>
     <div class="col-md-4">
     <div class="col-md-4">
         <div class="panel panel-default">
         <div class="panel panel-default">
@@ -141,6 +139,20 @@
         </div>
         </div>
     </div>
     </div>
     <div class="col-md-4">
     <div class="col-md-4">
+        {% if perms.secrets %}
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Secrets</strong>
+                </div>
+                <div class="list-group">
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.secret_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
+                        <p class="list-group-item-text text-muted">Sensitive data (such as passwords) which has been stored securely</p>
+                    </div>
+                </div>
+            </div>
+        {% endif %}
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Recent Activity</strong>
                 <strong>Recent Activity</strong>

+ 13 - 3
netbox/templates/ipam/vlan.html

@@ -70,10 +70,10 @@
                     <td>{{ vlan.name }}</td>
                     <td>{{ vlan.name }}</td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>
-                    <td>Description</td>
+                    <td>Tenant</td>
                     <td>
                     <td>
-                        {% if vlan.description %}
-                            {{ vlan.description }}
+                        {% if vlan.tenant %}
+                            <a href="{{ vlan.tenant.get_absolute_url }}">{{ vlan.tenant }}</a>
                         {% else %}
                         {% else %}
                             <span class="text-muted">None</span>
                             <span class="text-muted">None</span>
                         {% endif %}
                         {% endif %}
@@ -89,6 +89,16 @@
                     <td>Role</td>
                     <td>Role</td>
                     <td>{{ vlan.role }}</td>
                     <td>{{ vlan.role }}</td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Description</td>
+                    <td>
+                        {% if vlan.description %}
+                            {{ vlan.description }}
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td>Created</td>
                     <td>Created</td>
                     <td>{{ vlan.created }}</td>
                     <td>{{ vlan.created }}</td>

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

@@ -9,7 +9,8 @@
             <td><a href="{% url 'ipam:vlan' pk=vlan.pk %}">{{ vlan.vid }}</a></td>
             <td><a href="{% url 'ipam:vlan' pk=vlan.pk %}">{{ vlan.vid }}</a></td>
             <td>{{ vlan.name }}</td>
             <td>{{ vlan.name }}</td>
             <td>{{ vlan.site }}</td>
             <td>{{ vlan.site }}</td>
-            <td>{{ vlan.status }}</td>
+            <td>{{ vlan.tenant }}</td>
+            <td>{{ vlan.get_status_display }}</td>
             <td>{{ vlan.role }}</td>
             <td>{{ vlan.role }}</td>
             <td>{{ vlan.description }}</td>
             <td>{{ vlan.description }}</td>
         </tr>
         </tr>

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

@@ -48,6 +48,11 @@
 					<td>Configured VLAN name</td>
 					<td>Configured VLAN name</td>
 					<td>Cameras</td>
 					<td>Cameras</td>
 				</tr>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Internal</td>
+				</tr>
 				<tr>
 				<tr>
 					<td>Status</td>
 					<td>Status</td>
 					<td>Current status</td>
 					<td>Current status</td>
@@ -66,7 +71,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<h4>Example</h4>
-		<pre>LAS2,Backend Network,1400,Cameras,Active,Security,Security team only</pre>
+		<pre>LAS2,Backend Network,1400,Cameras,Internal,Active,Security,Security team only</pre>
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 10 - 0
netbox/templates/ipam/vrf.html

@@ -30,6 +30,16 @@
                     <td>Route Distinguisher</td>
                     <td>Route Distinguisher</td>
                     <td>{{ vrf.rd }}</td>
                     <td>{{ vrf.rd }}</td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if vrf.tenant %}
+                            <a href="{{ vrf.tenant.get_absolute_url }}">{{ vrf.tenant }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td>Enforce Uniqueness</td>
                     <td>Enforce Uniqueness</td>
                     <td>
                     <td>

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

@@ -8,6 +8,7 @@
         <tr>
         <tr>
             <td><a href="{% url 'ipam:vrf' pk=vrf.pk %}">{{ vrf.name }}</a></td>
             <td><a href="{% url 'ipam:vrf' pk=vrf.pk %}">{{ vrf.name }}</a></td>
             <td>{{ vrf.rd }}</td>
             <td>{{ vrf.rd }}</td>
+            <td>{{ vrf.tenant }}</td>
             <td>{{ vrf.description }}</td>
             <td>{{ vrf.description }}</td>
         </tr>
         </tr>
     {% endfor %}
     {% endfor %}

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

@@ -38,6 +38,11 @@
 					<td>Route distinguisher</td>
 					<td>Route distinguisher</td>
 					<td>65000:123456</td>
 					<td>65000:123456</td>
 				</tr>
 				</tr>
+				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>ABC01</td>
+				</tr>
 				<tr>
 				<tr>
 					<td>Enforce uniqueness</td>
 					<td>Enforce uniqueness</td>
 					<td>Prevent duplicate prefixes/IP addresses</td>
 					<td>Prevent duplicate prefixes/IP addresses</td>
@@ -51,7 +56,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<h4>Example</h4>
-		<pre>Customer_ABC,65000:123456,True,Native VRF for customer ABC</pre>
+		<pre>Customer_ABC,65000:123456,ABC01,True,Native VRF for customer ABC</pre>
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

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

@@ -41,6 +41,7 @@
 				</form>
 				</form>
 			</div>
 			</div>
 		</div>
 		</div>
+		{% include 'inc/filter_panel.html' %}
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 124 - 0
netbox/templates/tenancy/tenant.html

@@ -0,0 +1,124 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block title %}{{ tenant }}{% endblock %}
+
+{% block content %}
+<div class="row">
+    <div class="col-md-9">
+        <ol class="breadcrumb">
+            <li><a href="{% url 'tenancy:tenant_list' %}?group={{ tenant.group.slug }}">{{ tenant.group }}</a></li>
+            <li>{{ tenant }}</li>
+        </ol>
+    </div>
+    <div class="col-md-3">
+        <form action="{% url 'tenancy:tenant_list' %}" method="get">
+            <div class="input-group">
+                <input type="text" name="q" class="form-control" placeholder="Name" />
+                <span class="input-group-btn">
+                    <button type="submit" class="btn btn-primary">
+                        <span class="glyphicon glyphicon-search" aria-hidden="true"></span>
+                    </button>
+                </span>
+            </div>
+        </form>
+    </div>
+</div>
+<div class="pull-right">
+    {% if perms.tenancy.change_tenant %}
+		<a href="{% url 'tenancy:tenant_edit' slug=tenant.slug %}" class="btn btn-warning">
+			<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
+			Edit this tenant
+		</a>
+    {% endif %}
+    {% if perms.tenancy.delete_tenant %}
+		<a href="{% url 'tenancy:tenant_delete' slug=tenant.slug %}" class="btn btn-danger">
+			<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
+			Delete this tenant
+		</a>
+    {% endif %}
+</div>
+<h1>{{ tenant }}</h1>
+<div class="row">
+	<div class="col-md-7">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Tenant</strong>
+            </div>
+            <table class="table table-hover panel-body">
+                <tr>
+                    <td>Group</td>
+                    <td>
+                        <a href="{{ tenant.group.get_absolute_url }}">{{ tenant.group }}</a>
+                    </td>
+                </tr>
+                <tr>
+                    <td>Description</td>
+                    <td>
+                        {% if tenant.description %}
+                            {{ tenant.description }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Created</td>
+                    <td>{{ tenant.created }}</td>
+                </tr>
+                <tr>
+                    <td>Last Updated</td>
+                    <td>{{ tenant.last_updated }}</td>
+                </tr>
+            </table>
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Comments</strong>
+            </div>
+            <div class="panel-body">
+                {% if tenant.comments  %}
+                    {{ tenant.comments|gfm }}
+                {% else %}
+                    <span class="text-muted">None</span>
+                {% endif %}
+            </div>
+        </div>
+	</div>
+	<div class="col-md-5">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Stats</strong>
+            </div>
+            <div class="row panel-body">
+                <div class="col-md-4 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">
+                    <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">
+                    <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">
+                    <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">
+                    <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">
+                    <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>
+            </div>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 13 - 0
netbox/templates/tenancy/tenant_bulk_edit.html

@@ -0,0 +1,13 @@
+{% extends 'utilities/bulk_edit_form.html' %}
+{% load form_helpers %}
+
+{% block title %}Tenant Bulk Edit{% endblock %}
+
+{% block select_objects_table %}
+    {% for tenant in selected_objects %}
+        <tr>
+            <td><a href="{% url 'tenancy:tenant' slug=tenant.slug %}">{{ tenant }}</a></td>
+            <td>{{ tenant.group }}</td>
+        </tr>
+    {% endfor %}
+{% endblock %}

+ 21 - 0
netbox/templates/tenancy/tenant_edit.html

@@ -0,0 +1,21 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load static from staticfiles %}
+{% load form_helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tenant</strong></div>
+        <div class="panel-body">
+            {% render_field form.name %}
+            {% render_field form.slug %}
+            {% render_field form.group %}
+            {% render_field form.description %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Comments</strong></div>
+        <div class="panel-body">
+            {% render_field form.comments %}
+        </div>
+    </div>
+{% endblock %}

+ 57 - 0
netbox/templates/tenancy/tenant_import.html

@@ -0,0 +1,57 @@
+{% extends '_base.html' %}
+{% load render_table from django_tables2 %}
+{% load form_helpers %}
+
+{% block title %}Tenant Import{% endblock %}
+
+{% block content %}
+<h1>Tenant Import</h1>
+<div class="row">
+	<div class="col-md-6">
+		<form action="." method="post" class="form">
+		    {% csrf_token %}
+		    {% render_form form %}
+		    <div class="form-group">
+		        <button type="submit" class="btn btn-primary">Submit</button>
+		        <a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
+		    </div>
+		</form>
+	</div>
+	<div class="col-md-6">
+		<h4>CSV Format</h4>
+		<table class="table">
+			<thead>
+				<tr>
+					<th>Field</th>
+					<th>Description</th>
+					<th>Example</th>
+				</tr>
+			</thead>
+			<tbody>
+				<tr>
+					<td>Name</td>
+					<td>Tenant name</td>
+					<td>WIDG01</td>
+				</tr>
+				<tr>
+					<td>Slug</td>
+					<td>URL-friendly name</td>
+					<td>widg01</td>
+				</tr>
+				<tr>
+					<td>Group</td>
+					<td>Tenant group</td>
+					<td>Customers</td>
+				</tr>
+				<tr>
+					<td>Description</td>
+					<td>Long-form name or other text (optional)</td>
+					<td>Widgets Inc.</td>
+				</tr>
+			</tbody>
+		</table>
+		<h4>Example</h4>
+		<pre>WIDG01,widg01,Customers,Widgets Inc.</pre>
+	</div>
+</div>
+{% endblock %}

+ 42 - 0
netbox/templates/tenancy/tenant_list.html

@@ -0,0 +1,42 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block title %}Tenants{% endblock %}
+
+{% block content %}
+<div class="pull-right">
+    {% if perms.tenancy.add_tenant %}
+		<a href="{% url 'tenancy:tenant_add' %}" class="btn btn-primary">
+			<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+			Add a tenant
+		</a>
+    {% endif %}
+    {% include 'inc/export_button.html' with obj_type='tenants' %}
+</div>
+<h1>Tenants</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' with bulk_edit_url='tenancy:tenant_bulk_edit' bulk_delete_url='tenancy:tenant_bulk_delete' %}
+    </div>
+    <div class="col-md-3">
+		<div class="panel panel-default">
+			<div class="panel-heading">
+				<strong>Search</strong>
+			</div>
+			<div class="panel-body">
+				<form action="{% url 'tenancy:tenant_list' %}" method="get">
+					<div class="input-group">
+						<input type="text" name="q" class="form-control" placeholder="Name" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
+						<span class="input-group-btn">
+							<button type="submit" class="btn btn-primary">
+								<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
+							</button>
+						</span>
+					</div>
+				</form>
+			</div>
+		</div>
+		{% include 'inc/filter_panel.html' %}
+    </div>
+</div>
+{% endblock %}

+ 21 - 0
netbox/templates/tenancy/tenantgroup_list.html

@@ -0,0 +1,21 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block title %}Tenant Groups{% endblock %}
+
+{% block content %}
+<div class="pull-right">
+    {% if perms.tenancy.add_tenantgroup %}
+        <a href="{% url 'tenancy:tenantgroup_add' %}" class="btn btn-primary">
+            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+            Add a tenant group
+        </a>
+    {% endif %}
+</div>
+<h1>Tenant Groups</h1>
+<div class="row">
+	<div class="col-md-12">
+        {% include 'utilities/obj_table.html' with bulk_delete_url='tenancy:tenantgroup_bulk_delete' %}
+    </div>
+</div>
+{% endblock %}

+ 0 - 0
netbox/tenancy/__init__.py


+ 23 - 0
netbox/tenancy/admin.py

@@ -0,0 +1,23 @@
+from django.contrib import admin
+
+from .models import Tenant, TenantGroup
+
+
+@admin.register(TenantGroup)
+class TenantGroupAdmin(admin.ModelAdmin):
+    prepopulated_fields = {
+        'slug': ['name'],
+    }
+    list_display = ['name', 'slug']
+
+
+@admin.register(Tenant)
+class TenantAdmin(admin.ModelAdmin):
+    prepopulated_fields = {
+        'slug': ['name'],
+    }
+    list_display = ['name', 'slug', 'group', 'description']
+
+    def get_queryset(self, request):
+        qs = super(TenantAdmin, self).get_queryset(request)
+        return qs.select_related('group')

+ 0 - 0
netbox/tenancy/api/__init__.py


+ 38 - 0
netbox/tenancy/api/serializers.py

@@ -0,0 +1,38 @@
+from rest_framework import serializers
+
+from tenancy.models import Tenant, TenantGroup
+
+
+#
+# Tenant groups
+#
+
+class TenantGroupSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = TenantGroup
+        fields = ['id', 'name', 'slug']
+
+
+class TenantGroupNestedSerializer(TenantGroupSerializer):
+
+    class Meta(TenantGroupSerializer.Meta):
+        pass
+
+
+#
+# Tenants
+#
+
+class TenantSerializer(serializers.ModelSerializer):
+    group = TenantGroupNestedSerializer()
+
+    class Meta:
+        model = Tenant
+        fields = ['id', 'name', 'slug', 'group', 'comments']
+
+
+class TenantNestedSerializer(TenantSerializer):
+
+    class Meta(TenantSerializer.Meta):
+        fields = ['id', 'name', 'slug']

+ 16 - 0
netbox/tenancy/api/urls.py

@@ -0,0 +1,16 @@
+from django.conf.urls import url
+
+from .views import *
+
+
+urlpatterns = [
+
+    # Tenant groups
+    url(r'^tenant-groups/$', TenantGroupListView.as_view(), name='tenantgroup_list'),
+    url(r'^tenant-groups/(?P<pk>\d+)/$', TenantGroupDetailView.as_view(), name='tenantgroup_detail'),
+
+    # Tenants
+    url(r'^tenants/$', TenantListView.as_view(), name='tenant_list'),
+    url(r'^tenants/(?P<pk>\d+)/$', TenantDetailView.as_view(), name='tenant_detail'),
+
+]

+ 39 - 0
netbox/tenancy/api/views.py

@@ -0,0 +1,39 @@
+from rest_framework import generics
+
+from tenancy.models import Tenant, TenantGroup
+from tenancy.filters import TenantFilter
+
+from . import serializers
+
+
+class TenantGroupListView(generics.ListAPIView):
+    """
+    List all tenant groups
+    """
+    queryset = TenantGroup.objects.all()
+    serializer_class = serializers.TenantGroupSerializer
+
+
+class TenantGroupDetailView(generics.RetrieveAPIView):
+    """
+    Retrieve a single circuit type
+    """
+    queryset = TenantGroup.objects.all()
+    serializer_class = serializers.TenantGroupSerializer
+
+
+class TenantListView(generics.ListAPIView):
+    """
+    List tenants (filterable)
+    """
+    queryset = Tenant.objects.select_related('group')
+    serializer_class = serializers.TenantSerializer
+    filter_class = TenantFilter
+
+
+class TenantDetailView(generics.RetrieveAPIView):
+    """
+    Retrieve a single tenant
+    """
+    queryset = Tenant.objects.select_related('group')
+    serializer_class = serializers.TenantSerializer

+ 5 - 0
netbox/tenancy/apps.py

@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class TenancyConfig(AppConfig):
+    name = 'tenancy'

+ 29 - 0
netbox/tenancy/filters.py

@@ -0,0 +1,29 @@
+import django_filters
+
+from .models import Tenant, TenantGroup
+
+
+class TenantFilter(django_filters.FilterSet):
+    q = django_filters.MethodFilter(
+        action='search',
+        label='Search',
+    )
+    group_id = django_filters.ModelMultipleChoiceFilter(
+        name='group',
+        queryset=TenantGroup.objects.all(),
+        label='Group (ID)',
+    )
+    group = django_filters.ModelMultipleChoiceFilter(
+        name='group',
+        queryset=TenantGroup.objects.all(),
+        to_field_name='slug',
+        label='Group (slug)',
+    )
+
+    class Meta:
+        model = Tenant
+        fields = ['q', 'group_id', 'group', 'name']
+
+    def search(self, queryset, value):
+        value = value.strip()
+        return queryset.filter(name__icontains=value)

+ 61 - 0
netbox/tenancy/forms.py

@@ -0,0 +1,61 @@
+from django import forms
+from django.db.models import Count
+
+from utilities.forms import (
+    BootstrapMixin, BulkImportForm, CommentField, CSVDataField, SlugField,
+)
+
+from .models import Tenant, TenantGroup
+
+
+#
+# Tenant groups
+#
+
+class TenantGroupForm(forms.ModelForm, BootstrapMixin):
+    slug = SlugField()
+
+    class Meta:
+        model = TenantGroup
+        fields = ['name', 'slug']
+
+
+#
+# Tenants
+#
+
+class TenantForm(forms.ModelForm, BootstrapMixin):
+    slug = SlugField()
+    comments = CommentField()
+
+    class Meta:
+        model = Tenant
+        fields = ['name', 'slug', 'group', 'description', 'comments']
+
+
+class TenantFromCSVForm(forms.ModelForm):
+    group = forms.ModelChoiceField(TenantGroup.objects.all(), to_field_name='name',
+                                   error_messages={'invalid_choice': 'Group not found.'})
+
+    class Meta:
+        model = Tenant
+        fields = ['name', 'slug', 'group', 'description']
+
+
+class TenantImportForm(BulkImportForm, BootstrapMixin):
+    csv = CSVDataField(csv_form=TenantFromCSVForm)
+
+
+class TenantBulkEditForm(forms.Form, BootstrapMixin):
+    pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput)
+    group = forms.ModelChoiceField(queryset=TenantGroup.objects.all(), required=False)
+
+
+def tenant_group_choices():
+    group_choices = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
+    return [(g.slug, u'{} ({})'.format(g.name, g.tenant_count)) for g in group_choices]
+
+
+class TenantFilterForm(forms.Form, BootstrapMixin):
+    group = forms.MultipleChoiceField(required=False, choices=tenant_group_choices,
+                                      widget=forms.SelectMultiple(attrs={'size': 8}))

+ 48 - 0
netbox/tenancy/migrations/0001_initial.py

@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-07-26 21:58
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Tenant',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created', models.DateField(auto_now_add=True)),
+                ('last_updated', models.DateTimeField(auto_now=True)),
+                ('name', models.CharField(max_length=30, unique=True)),
+                ('slug', models.SlugField(unique=True)),
+                ('description', models.CharField(blank=True, help_text=b'Long-form name (optional)', max_length=100)),
+                ('comments', models.TextField(blank=True)),
+            ],
+            options={
+                'ordering': ['group', 'name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='TenantGroup',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=50, unique=True)),
+                ('slug', models.SlugField(unique=True)),
+            ],
+            options={
+                'ordering': ['name'],
+            },
+        ),
+        migrations.AddField(
+            model_name='tenant',
+            name='group',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tenants', to='tenancy.TenantGroup'),
+        ),
+    ]

+ 0 - 0
netbox/tenancy/migrations/__init__.py


+ 50 - 0
netbox/tenancy/models.py

@@ -0,0 +1,50 @@
+from django.core.urlresolvers import reverse
+from django.db import models
+
+from utilities.models import CreatedUpdatedModel
+
+
+class TenantGroup(models.Model):
+    """
+    An arbitrary collection of Tenants.
+    """
+    name = models.CharField(max_length=50, unique=True)
+    slug = models.SlugField(unique=True)
+
+    class Meta:
+        ordering = ['name']
+
+    def __unicode__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug)
+
+
+class Tenant(CreatedUpdatedModel):
+    """
+    A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
+    department.
+    """
+    name = models.CharField(max_length=30, unique=True)
+    slug = models.SlugField(unique=True)
+    group = models.ForeignKey('TenantGroup', related_name='tenants', on_delete=models.PROTECT)
+    description = models.CharField(max_length=100, blank=True, help_text="Long-form name (optional)")
+    comments = models.TextField(blank=True)
+
+    class Meta:
+        ordering = ['group', 'name']
+
+    def __unicode__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('tenancy:tenant', args=[self.slug])
+
+    def to_csv(self):
+        return ','.join([
+            self.name,
+            self.slug,
+            self.group.name,
+            self.description,
+        ])

+ 44 - 0
netbox/tenancy/tables.py

@@ -0,0 +1,44 @@
+import django_tables2 as tables
+from django_tables2.utils import Accessor
+
+from utilities.tables import BaseTable, ToggleColumn
+
+from .models import Tenant, TenantGroup
+
+
+TENANTGROUP_EDIT_LINK = """
+{% if perms.tenancy.change_tenantgroup %}
+    <a href="{% url 'tenancy:tenantgroup_edit' slug=record.slug %}">Edit</a>
+{% endif %}
+"""
+
+
+#
+# Tenant groups
+#
+
+class TenantGroupTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn(verbose_name='Name')
+    tenant_count = tables.Column(verbose_name='Tenants')
+    slug = tables.Column(verbose_name='Slug')
+    edit = tables.TemplateColumn(template_code=TENANTGROUP_EDIT_LINK, verbose_name='')
+
+    class Meta(BaseTable.Meta):
+        model = TenantGroup
+        fields = ('pk', 'name', 'tenant_count', 'slug', 'edit')
+
+
+#
+# Tenants
+#
+
+class TenantTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn('tenancy:tenant', args=[Accessor('slug')], verbose_name='Name')
+    group = tables.Column(verbose_name='Group')
+    description = tables.Column(verbose_name='Description')
+
+    class Meta(BaseTable.Meta):
+        model = Tenant
+        fields = ('pk', 'name', 'group', 'description')

+ 24 - 0
netbox/tenancy/urls.py

@@ -0,0 +1,24 @@
+from django.conf.urls import url
+
+from . import views
+
+
+urlpatterns = [
+
+    # Tenant groups
+    url(r'^tenant-groups/$', views.TenantGroupListView.as_view(), name='tenantgroup_list'),
+    url(r'^tenant-groups/add/$', views.TenantGroupEditView.as_view(), name='tenantgroup_add'),
+    url(r'^tenant-groups/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
+    url(r'^tenant-groups/(?P<slug>[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
+
+    # Tenants
+    url(r'^tenants/$', views.TenantListView.as_view(), name='tenant_list'),
+    url(r'^tenants/add/$', views.TenantEditView.as_view(), name='tenant_add'),
+    url(r'^tenants/import/$', views.TenantBulkImportView.as_view(), name='tenant_import'),
+    url(r'^tenants/edit/$', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'),
+    url(r'^tenants/delete/$', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'),
+    url(r'^tenants/(?P<slug>[\w-]+)/$', views.tenant, name='tenant'),
+    url(r'^tenants/(?P<slug>[\w-]+)/edit/$', views.TenantEditView.as_view(), name='tenant_edit'),
+    url(r'^tenants/(?P<slug>[\w-]+)/delete/$', views.TenantDeleteView.as_view(), name='tenant_delete'),
+
+]

+ 110 - 0
netbox/tenancy/views.py

@@ -0,0 +1,110 @@
+from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.db.models import Count
+from django.shortcuts import get_object_or_404, render
+
+from utilities.views import (
+    BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+)
+
+from models import Tenant, TenantGroup
+from . import filters, forms, tables
+
+
+#
+# Tenant groups
+#
+
+class TenantGroupListView(ObjectListView):
+    queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
+    table = tables.TenantGroupTable
+    edit_permissions = ['tenancy.change_tenantgroup', 'tenancy.delete_tenantgroup']
+    template_name = 'tenancy/tenantgroup_list.html'
+
+
+class TenantGroupEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'tenancy.change_tenantgroup'
+    model = TenantGroup
+    form_class = forms.TenantGroupForm
+    success_url = 'tenancy:tenantgroup_list'
+    cancel_url = 'tenancy:tenantgroup_list'
+
+
+class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'tenancy.delete_tenantgroup'
+    cls = TenantGroup
+    default_redirect_url = 'tenancy:tenantgroup_list'
+
+
+#
+#  Tenants
+#
+
+class TenantListView(ObjectListView):
+    queryset = Tenant.objects.select_related('group')
+    filter = filters.TenantFilter
+    filter_form = forms.TenantFilterForm
+    table = tables.TenantTable
+    edit_permissions = ['tenancy.change_tenant', 'tenancy.delete_tenant']
+    template_name = 'tenancy/tenant_list.html'
+
+
+def tenant(request, slug):
+
+    tenant = get_object_or_404(Tenant.objects.annotate(
+        site_count=Count('sites', distinct=True),
+        rack_count=Count('racks', distinct=True),
+        device_count=Count('devices', distinct=True),
+        vrf_count=Count('vrfs', distinct=True),
+        vlan_count=Count('vlans', distinct=True),
+        circuit_count=Count('circuits', distinct=True),
+    ), slug=slug)
+
+    return render(request, 'tenancy/tenant.html', {
+        'tenant': tenant,
+    })
+
+
+class TenantEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'tenancy.change_tenant'
+    model = Tenant
+    form_class = forms.TenantForm
+    fields_initial = ['group']
+    template_name = 'tenancy/tenant_edit.html'
+    cancel_url = 'tenancy:tenant_list'
+
+
+class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'tenancy.delete_tenant'
+    model = Tenant
+    redirect_url = 'tenancy:tenant_list'
+
+
+class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'tenancy.add_tenant'
+    form = forms.TenantImportForm
+    table = tables.TenantTable
+    template_name = 'tenancy/tenant_import.html'
+    obj_list_url = 'tenancy:tenant_list'
+
+
+class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'tenancy.change_tenant'
+    cls = Tenant
+    form = forms.TenantBulkEditForm
+    template_name = 'tenancy/tenant_bulk_edit.html'
+    default_redirect_url = 'tenancy:tenant_list'
+
+    def update_objects(self, pk_list, form):
+
+        fields_to_update = {}
+        for field in ['group']:
+            if form.cleaned_data[field]:
+                fields_to_update[field] = form.cleaned_data[field]
+
+        return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
+
+
+class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'tenancy.delete_tenant'
+    cls = Tenant
+    default_redirect_url = 'tenancy:tenant_list'