Parcourir la source

Merge pull request #393 from digitalocean/multitenancy

Multitenancy
Jeremy Stretch il y a 8 ans
Parent
commit
1413f5d89e
76 fichiers modifiés avec 1327 ajouts et 124 suppressions
  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)
 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']
 
     def get_queryset(self, 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 dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
+from tenancy.api.serializers import TenantNestedSerializer
 
 
 #
@@ -45,13 +46,14 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer):
 class CircuitSerializer(serializers.ModelSerializer):
     provider = ProviderNestedSerializer()
     type = CircuitTypeNestedSerializer()
+    tenant = TenantNestedSerializer()
     site = SiteNestedSerializer()
     interface = InterfaceNestedSerializer()
 
     class Meta:
         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):

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

@@ -42,7 +42,7 @@ class CircuitListView(generics.ListAPIView):
     """
     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
     filter_class = CircuitFilter
 
@@ -51,5 +51,5 @@ class CircuitDetailView(generics.RetrieveAPIView):
     """
     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

+ 12 - 0
netbox/circuits/filters.py

@@ -3,6 +3,7 @@ import django_filters
 from django.db.models import Q
 
 from dcim.models import Site
+from tenancy.models import Tenant
 from .models import Provider, Circuit, CircuitType
 
 
@@ -62,6 +63,17 @@ class CircuitFilter(django_filters.FilterSet):
         to_field_name='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(
         name='site',
         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 dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
+from tenancy.models import Tenant
 from utilities.forms import (
     APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField,
 )
@@ -99,7 +100,7 @@ class CircuitForm(forms.ModelForm, BootstrapMixin):
     class Meta:
         model = Circuit
         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'
         ]
         help_texts = {
@@ -160,13 +161,15 @@ class CircuitFromCSVForm(forms.ModelForm):
                                       error_messages={'invalid_choice': 'Provider not found.'})
     type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
                                   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',
                                   error_messages={'invalid_choice': 'Site not found.'})
 
     class Meta:
         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):
@@ -177,6 +180,7 @@ class CircuitBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
     type = forms.ModelChoiceField(queryset=CircuitType.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)')
     commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
     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]
 
 
+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():
     site_choices = Site.objects.annotate(circuit_count=Count('circuits'))
     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)
     provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices,
                                          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,
                                      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.models import Site, Interface
+from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
 
 
@@ -66,6 +67,7 @@ class Circuit(CreatedUpdatedModel):
     cid = models.CharField(max_length=50, verbose_name='Circuit ID')
     provider = models.ForeignKey('Provider', 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)
     interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True)
     install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
@@ -90,6 +92,7 @@ class Circuit(CreatedUpdatedModel):
             self.cid,
             self.provider.name,
             self.type.name,
+            self.tenant.name if self.tenant else '',
             self.site.name,
             self.install_date.isoformat() if self.install_date else '',
             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')
     type = tables.Column(verbose_name='Type')
     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')
     port_speed_human = tables.Column(verbose_name='Port Speed')
     commit_rate_human = tables.Column(verbose_name='Commit Rate')
 
     class Meta(BaseTable.Meta):
         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):
-    queryset = Circuit.objects.select_related('provider', 'type', 'site')
+    queryset = Circuit.objects.select_related('provider', 'type', 'tenant', 'site')
     filter = filters.CircuitFilter
     filter_form = forms.CircuitFilterForm
     table = tables.CircuitTable
@@ -159,7 +159,7 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
 
         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]:
                 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,
     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):
+    tenant = TenantNestedSerializer()
 
     class Meta:
         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']
 
 
@@ -52,10 +54,11 @@ class RackGroupNestedSerializer(RackGroupSerializer):
 class RackSerializer(serializers.ModelSerializer):
     site = SiteNestedSerializer()
     group = RackGroupNestedSerializer()
+    tenant = TenantNestedSerializer()
 
     class Meta:
         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):
@@ -69,8 +72,8 @@ class RackDetailSerializer(RackSerializer):
     rear_units = serializers.SerializerMethodField()
 
     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):
         units = obj.get_rack_units(face=RACK_FACE_FRONT)
@@ -218,6 +221,7 @@ class DeviceIPAddressNestedSerializer(serializers.ModelSerializer):
 class DeviceSerializer(serializers.ModelSerializer):
     device_type = DeviceTypeNestedSerializer()
     device_role = DeviceRoleNestedSerializer()
+    tenant = TenantNestedSerializer()
     platform = PlatformNestedSerializer()
     rack = RackNestedSerializer()
     primary_ip = DeviceIPAddressNestedSerializer()
@@ -227,8 +231,8 @@ class DeviceSerializer(serializers.ModelSerializer):
 
     class Meta:
         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):
         try:

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

@@ -27,7 +27,7 @@ class SiteListView(generics.ListAPIView):
     """
     List all sites
     """
-    queryset = Site.objects.all()
+    queryset = Site.objects.select_related('tenant')
     serializer_class = serializers.SiteSerializer
 
 
@@ -35,7 +35,7 @@ class SiteDetailView(generics.RetrieveAPIView):
     """
     Retrieve a single site
     """
-    queryset = Site.objects.all()
+    queryset = Site.objects.select_related('tenant')
     serializer_class = serializers.SiteSerializer
 
 
@@ -68,7 +68,7 @@ class RackListView(generics.ListAPIView):
     """
     List racks (filterable)
     """
-    queryset = Rack.objects.select_related('site')
+    queryset = Rack.objects.select_related('site', 'tenant')
     serializer_class = serializers.RackSerializer
     filter_class = filters.RackFilter
 
@@ -77,7 +77,7 @@ class RackDetailView(generics.RetrieveAPIView):
     """
     Retrieve a single rack
     """
-    queryset = Rack.objects.select_related('site')
+    queryset = Rack.objects.select_related('site', 'tenant')
     serializer_class = serializers.RackDetailSerializer
 
 
@@ -193,8 +193,9 @@ class DeviceListView(generics.ListAPIView):
     """
     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
     filter_class = filters.DeviceFilter
     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,
     Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
 )
+from tenancy.models import Tenant
 
 
 class SiteFilter(django_filters.FilterSet):
@@ -13,6 +14,17 @@ class SiteFilter(django_filters.FilterSet):
         action='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:
         model = Site
@@ -74,6 +86,17 @@ class RackFilter(django_filters.FilterSet):
         to_field_name='slug',
         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:
         model = Rack
@@ -143,6 +166,17 @@ class DeviceFilter(django_filters.FilterSet):
         to_field_name='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(
         name='device_type',
         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 ipam.models import IPAddress
+from tenancy.models import Tenant
 from utilities.forms import (
     APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
     FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
@@ -48,7 +49,7 @@ class SiteForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = Site
-        fields = ['name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments']
+        fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments']
         widgets = {
             'physical_address': SmallTextarea(attrs={'rows': 3}),
             'shipping_address': SmallTextarea(attrs={'rows': 3}),
@@ -63,16 +64,28 @@ class SiteForm(forms.ModelForm, BootstrapMixin):
 
 
 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:
         model = Site
-        fields = ['name', 'slug', 'facility', 'asn']
+        fields = ['name', 'slug', 'tenant', 'facility', 'asn']
 
 
 class SiteImportForm(BulkImportForm, BootstrapMixin):
     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
 #
@@ -107,7 +120,7 @@ class RackForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = Rack
-        fields = ['site', 'group', 'name', 'facility_id', 'u_height', 'comments']
+        fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'u_height', 'comments']
         help_texts = {
             'site': "The site at which the rack exists",
             'name': "Organizational rack name",
@@ -135,10 +148,12 @@ class RackFromCSVForm(forms.ModelForm):
     site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
                                   error_messages={'invalid_choice': 'Site not found.'})
     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:
         model = Rack
-        fields = ['site', 'group_name', 'name', 'facility_id', 'u_height']
+        fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'u_height']
 
     def clean(self):
 
@@ -161,6 +176,7 @@ class RackBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
     site = forms.ModelChoiceField(queryset=Site.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)')
     comments = CommentField()
 
@@ -175,11 +191,18 @@ def rack_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):
     site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
     group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
                                          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:
         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):
@@ -324,7 +347,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         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']
         help_texts = {
             'device_role': "The function this device serves",
@@ -410,6 +433,8 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
 class BaseDeviceFromCSVForm(forms.ModelForm):
     device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name',
                                          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',
                                           error_messages={'invalid_choice': 'Invalid manufacturer.'})
     model_name = forms.CharField()
@@ -441,8 +466,8 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm):
     face = forms.CharField(required=False)
 
     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):
 
@@ -477,7 +502,7 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
     device_bay_name = forms.CharField(required=False)
 
     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']
 
     def clean(self):
@@ -512,6 +537,7 @@ class DeviceBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
     device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
     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_delete = forms.BooleanField(required=False, label='Set platform to "none"')
     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]
 
 
+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():
     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]
@@ -550,6 +581,8 @@ class DeviceFilterForm(forms.Form, BootstrapMixin):
                                               widget=forms.SelectMultiple(attrs={'size': 8}))
     role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
                                      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',
                                                widget=forms.SelectMultiple(attrs={'size': 8}))
     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 extras.rpc import RPC_CLIENTS
+from tenancy.models import Tenant
 from utilities.fields import NullableCharField
 from utilities.managers import NaturalOrderByManager
 from utilities.models import CreatedUpdatedModel
@@ -152,6 +153,7 @@ class Site(CreatedUpdatedModel):
     """
     name = models.CharField(max_length=50, 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)
     asn = ASNField(blank=True, null=True, verbose_name='ASN')
     physical_address = models.CharField(max_length=200, blank=True)
@@ -173,6 +175,7 @@ class Site(CreatedUpdatedModel):
         return ','.join([
             self.name,
             self.slug,
+            self.tenant.name if self.tenant else '',
             self.facility,
             str(self.asn),
         ])
@@ -237,6 +240,7 @@ class Rack(CreatedUpdatedModel):
     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)
     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)')
     comments = models.TextField(blank=True)
 
@@ -272,6 +276,7 @@ class Rack(CreatedUpdatedModel):
             self.group.name if self.group else '',
             self.name,
             self.facility_id or '',
+            self.tenant.name if self.tenant else '',
             str(self.u_height),
         ])
 
@@ -631,6 +636,7 @@ class Device(CreatedUpdatedModel):
     """
     device_type = models.ForeignKey('DeviceType', related_name='instances', 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)
     name = NullableCharField(max_length=50, blank=True, null=True, unique=True)
     serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
@@ -724,6 +730,7 @@ class Device(CreatedUpdatedModel):
         return ','.join([
             self.name or '',
             self.device_role.name,
+            self.tenant.name if self.tenant else '',
             self.device_type.manufacturer.name,
             self.device_type.model,
             self.platform.name if self.platform else '',

+ 13 - 7
netbox/dcim/tables.py

@@ -61,6 +61,7 @@ UTILIZATION_GRAPH = """
 class SiteTable(BaseTable):
     name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
     facility = tables.Column(verbose_name='Facility')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     asn = tables.Column(verbose_name='ASN')
     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')
@@ -70,7 +71,7 @@ class SiteTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -101,14 +102,16 @@ class RackTable(BaseTable):
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     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')
-    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')
 
     class Meta(BaseTable.Meta):
         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):
@@ -116,11 +119,12 @@ class RackImportTable(BaseTable):
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     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)')
 
     class Meta(BaseTable.Meta):
         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()
     status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_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')
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
     device_role = tables.Column(verbose_name='Role')
@@ -268,11 +273,12 @@ class DeviceTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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):
     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')
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
     position = tables.Column(verbose_name='Position')
@@ -281,7 +287,7 @@ class DeviceImportTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Device
-        fields = ('name', 'site', 'rack', 'position', 'device_role', 'device_type')
+        fields = ('name', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
         empty_text = False
 
 

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

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

+ 4 - 3
netbox/dcim/views.py

@@ -61,8 +61,9 @@ def expand_pattern(string):
 #
 
 class SiteListView(ObjectListView):
-    queryset = Site.objects.all()
+    queryset = Site.objects.select_related('tenant')
     filter = filters.SiteFilter
+    filter_form = forms.SiteFilterForm
     table = tables.SiteTable
     template_name = 'dcim/site_list.html'
 
@@ -200,7 +201,7 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
 
         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]:
                 fields_to_update[field] = form.cleaned_data[field]
 
@@ -632,7 +633,7 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
         if form.cleaned_data['status']:
             status = form.cleaned_data['status']
             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]:
                 fields_to_update[field] = form.cleaned_data[field]
 

+ 9 - 4
netbox/ipam/admin.py

@@ -7,7 +7,12 @@ from .models import (
 
 @admin.register(VRF)
 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)
@@ -67,10 +72,10 @@ class VLANGroupAdmin(admin.ModelAdmin):
 
 @admin.register(VLAN)
 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']
 
     def get_queryset(self, 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 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):
+    tenant = TenantNestedSerializer()
 
     class Meta:
         model = VRF
-        fields = ['id', 'name', 'rd', 'enforce_unique', 'description']
+        fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description']
 
 
 class VRFNestedSerializer(VRFSerializer):
@@ -98,11 +100,12 @@ class VLANGroupNestedSerializer(VLANGroupSerializer):
 class VLANSerializer(serializers.ModelSerializer):
     site = SiteNestedSerializer()
     group = VLANGroupNestedSerializer()
+    tenant = TenantNestedSerializer()
     role = RoleNestedSerializer()
 
     class Meta:
         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):

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

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

+ 23 - 0
netbox/ipam/filters.py

@@ -3,6 +3,7 @@ from netaddr import IPNetwork
 from netaddr.core import AddrFormatError
 
 from dcim.models import Site, Device, Interface
+from tenancy.models import Tenant
 
 from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
 
@@ -13,6 +14,17 @@ class VRFFilter(django_filters.FilterSet):
         lookup_type='icontains',
         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:
         model = VRF
@@ -226,6 +238,17 @@ class VLANFilter(django_filters.FilterSet):
         name='vid',
         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(
         name='role',
         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 dcim.models import Site, Device, Interface
+from tenancy.models import Tenant
 from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField
 
 from .models import (
@@ -23,7 +24,7 @@ class VRFForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = VRF
-        fields = ['name', 'rd', 'enforce_unique', 'description']
+        fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
         labels = {
             'rd': "RD",
         }
@@ -33,10 +34,12 @@ class VRFForm(forms.ModelForm, BootstrapMixin):
 
 
 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:
         model = VRF
-        fields = ['name', 'rd', 'enforce_unique', 'description']
+        fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
 
 
 class VRFImportForm(BulkImportForm, BootstrapMixin):
@@ -45,9 +48,20 @@ class VRFImportForm(BulkImportForm, BootstrapMixin):
 
 class VRFBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     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
 #
@@ -444,7 +458,7 @@ class VLANForm(forms.ModelForm, BootstrapMixin):
 
     class Meta:
         model = VLAN
-        fields = ['site', 'group', 'vid', 'name', 'description', 'status', 'role']
+        fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
         help_texts = {
             'site': "The site at which this VLAN exists",
             'group': "VLAN group (optional)",
@@ -475,13 +489,15 @@ class VLANFromCSVForm(forms.ModelForm):
                                   error_messages={'invalid_choice': 'Device not found.'})
     group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name',
                                    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])
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
                                   error_messages={'invalid_choice': 'Invalid role.'})
 
     class Meta:
         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):
         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)
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
     group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
     description = forms.CharField(max_length=100, required=False)
@@ -515,6 +532,11 @@ def vlan_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():
     status_counts = {}
     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}))
     group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group',
                                          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)
     role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
                                      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 dcim.models import Interface
+from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
 
 from .fields import IPNetworkField, IPAddressField
@@ -46,6 +47,7 @@ class VRF(CreatedUpdatedModel):
     """
     name = models.CharField(max_length=50)
     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',
                                          help_text="Prevent duplicate prefixes/IP addresses within this VRF")
     description = models.CharField(max_length=100, blank=True)
@@ -65,6 +67,8 @@ class VRF(CreatedUpdatedModel):
         return ','.join([
             self.name,
             self.rd,
+            self.tenant.name if self.tenant else '',
+            'True' if self.enforce_unique else '',
             self.description,
         ])
 
@@ -291,7 +295,7 @@ class Prefix(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
     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.
@@ -407,9 +411,10 @@ class VLAN(CreatedUpdatedModel):
         MaxValueValidator(4094)
     ])
     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)
     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:
         ordering = ['site', 'group', 'vid']
@@ -438,6 +443,7 @@ class VLAN(CreatedUpdatedModel):
             self.group.name if self.group else '',
             str(self.vid),
             self.name,
+            self.tenant.name if self.tenant else '',
             self.get_status_display(),
             self.role.name if self.role else '',
             self.description,

+ 4 - 2
netbox/ipam/tables.py

@@ -58,11 +58,12 @@ class VRFTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
     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')
 
     class Meta(BaseTable.Meta):
         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')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     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')
     role = tables.Column(verbose_name='Role')
 
     class Meta(BaseTable.Meta):
         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):
-    queryset = VRF.objects.all()
+    queryset = VRF.objects.select_related('tenant')
     filter = filters.VRFFilter
+    filter_form = forms.VRFFilterForm
     table = tables.VRFTable
     edit_permissions = ['ipam.change_vrf', 'ipam.delete_vrf']
     template_name = 'ipam/vrf_list.html'
@@ -85,7 +86,7 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
     def update_objects(self, pk_list, form):
 
         fields_to_update = {}
-        for field in ['description']:
+        for field in ['tenant', 'description']:
             if 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):
 
         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]:
                 fields_to_update[field] = form.cleaned_data[field]
 

+ 2 - 1
netbox/netbox/settings.py

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

+ 2 - 0
netbox/netbox/urls.py

@@ -22,6 +22,7 @@ urlpatterns = [
     url(r'^dcim/', include('dcim.urls', namespace='dcim')),
     url(r'^ipam/', include('ipam.urls', namespace='ipam')),
     url(r'^secrets/', include('secrets.urls', namespace='secrets')),
+    url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')),
     url(r'^profile/', include('users.urls', namespace='users')),
 
     # API
@@ -29,6 +30,7 @@ urlpatterns = [
     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/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-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 ipam.models import Aggregate, Prefix, IPAddress, VLAN
 from secrets.models import Secret
+from tenancy.models import Tenant
 
 
 def home(request):
 
     stats = {
 
-        # DCIM
+        # Organization
         'site_count': Site.objects.count(),
+        'tenant_count': Tenant.objects.count(),
+
+        # DCIM
         'rack_count': Rack.objects.count(),
         'device_count': Device.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">
                 {% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
                 <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_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 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>

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

@@ -58,26 +58,20 @@
                     <td>{{ circuit.cid }}</td>
                 </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>
-                    <td>Termination</td>
+                    <td>Tenant</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 %}
-                            <span class="text-muted">Not defined</span>
+                            <span class="text-muted">N/A</span>
                         {% endif %}
                     </td>
                 </tr>
                 <tr>
-                    <td>Install Date</td>
-                    <td>{{ circuit.install_date }}</td>
-                </tr>
-                <tr>
                     <td>Port Speed</td>
                     <td>{{ circuit.port_speed_human }}</td>
                 </tr>
@@ -86,14 +80,6 @@
                     <td>{% if circuit.commit_rate %}{{ circuit.commit_rate_human }}{% else %}<span class="text-muted">N/A</span>{% endif %}</td>
                 </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>
                     <td>Created</td>
                     <td>{{ circuit.created }}</td>
                 </tr>
@@ -107,6 +93,59 @@
 	<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-heading">
                 <strong>Comments</strong>
             </div>
             <div class="panel-body">

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

@@ -9,14 +9,20 @@
             {% render_field form.provider %}
             {% render_field form.cid %}
             {% render_field form.type %}
+            {% render_field form.tenant %}
             {% render_field form.install_date %}
-            {% render_field form.port_speed %}
-            {% render_field form.commit_rate %}
             {% render_field form.xconnect_id %}
             {% render_field form.pp_info %}
         </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-heading"><strong>Termination</strong></div>
         <div class="panel-body">
             {% render_field form.site %}

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

@@ -44,6 +44,11 @@
 					<td>Transit</td>
 				</tr>
 				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Strickland Propane</td>
+				</tr>
+				<tr>
 					<td>Site</td>
 					<td>Site name</td>
 					<td>ASH-4</td>
@@ -76,7 +81,7 @@
 			</tbody>
 		</table>
 		<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>
 {% endblock %}

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

@@ -15,6 +15,16 @@
             </div>
             <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>
                     <td>Site</td>
                     <td>
                         <a href="{% url 'dcim:site' slug=device.rack.site.slug %}">{{ device.rack.site }}</a>

+ 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>{{ device.device_type }}</td>
             <td>{{ device.device_role }}</td>
+            <td>{{ device.tenant }}</td>
             <td>{{ device.serial }}</td>
         </tr>
     {% endfor %}

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

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

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

@@ -37,6 +37,11 @@
 					<td>ToR Switch</td>
 				</tr>
 				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Pied Piper</td>
+				</tr>
+				<tr>
 					<td>Device manufacturer</td>
 					<td>Hardware manufacturer</td>
 					<td>Juniper</td>
@@ -79,7 +84,7 @@
 			</tbody>
 		</table>
 		<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>
 {% endblock %}

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

@@ -87,6 +87,16 @@
                     </td>
                 </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>
                     <td>Height</td>
                     <td>{{ rack.u_height }}U</td>
                 </tr>

+ 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>{{ rack.facility_id }}</td>
             <td>{{ rack.site }}</td>
+            <td>{{ rack.tenant }}</td>
             <td>{{ rack.u_height }}</td>
         </tr>
     {% endfor %}

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

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

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

@@ -49,6 +49,11 @@
 					<td>J12.100</td>
 				</tr>
 				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Pied Piper</td>
+				</tr>
+				<tr>
 					<td>Height</td>
 					<td>Height in rack units</td>
 					<td>42</td>
@@ -56,7 +61,7 @@
 			</tbody>
 		</table>
 		<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>
 {% endblock %}

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

@@ -53,6 +53,16 @@
             </div>
             <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>
                     <td>Facility</td>
                     <td>{{ site.facility }}</td>
                 </tr>

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

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

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

@@ -39,6 +39,11 @@
 					<td>ash4-south</td>
 				</tr>
 				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Pied Piper</td>
+				</tr>
+				<tr>
 					<td>Facility</td>
 					<td>Name of the hosting facility (optional)</td>
 					<td>Equinix DC6</td>
@@ -51,7 +56,7 @@
 			</tbody>
 		</table>
 		<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>
 {% endblock %}

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

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

+ 27 - 15
netbox/templates/home.html

@@ -50,7 +50,7 @@
     <div class="col-md-4">
         <div class="panel panel-default">
             <div class="panel-heading">
-                <strong>DCIM</strong>
+                <strong>Organization</strong>
             </div>
             <div class="list-group">
                 <div class="list-group-item">
@@ -59,6 +59,18 @@
                     <p class="list-group-item-text text-muted">Geographic locations</p>
                 </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">
                     <span class="badge pull-right">{{ stats.rack_count }}</span>
                     <h4 class="list-group-item-heading"><a href="{% url 'dcim:rack_list' %}">Racks</a></h4>
                     <p class="list-group-item-text text-muted">Equipment racks, optionally organized by group</p>
@@ -79,20 +91,6 @@
                 </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 class="col-md-4">
         <div class="panel panel-default">
@@ -141,6 +139,20 @@
         </div>
     </div>
     <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-heading">
                 <strong>Recent Activity</strong>

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

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

+ 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>{{ vlan.name }}</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.description }}</td>
         </tr>

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

@@ -49,6 +49,11 @@
 					<td>Cameras</td>
 				</tr>
 				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>Internal</td>
+				</tr>
+				<tr>
 					<td>Status</td>
 					<td>Current status</td>
 					<td>Active</td>
@@ -66,7 +71,7 @@
 			</tbody>
 		</table>
 		<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>
 {% endblock %}

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

@@ -31,6 +31,16 @@
                     <td>{{ vrf.rd }}</td>
                 </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>
                     <td>Enforce Uniqueness</td>
                     <td>
                         {% if vrf.enforce_unique %}

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

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

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

@@ -39,6 +39,11 @@
 					<td>65000:123456</td>
 				</tr>
 				<tr>
+					<td>Tenant</td>
+					<td>Name of tenant (optional)</td>
+					<td>ABC01</td>
+				</tr>
+				<tr>
 					<td>Enforce uniqueness</td>
 					<td>Prevent duplicate prefixes/IP addresses</td>
 					<td>True</td>
@@ -51,7 +56,7 @@
 			</tbody>
 		</table>
 		<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>
 {% endblock %}

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

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