Browse Source

Initial multitenancy implementation

Jeremy Stretch 8 years ago
parent
commit
fa2ccc1c18

+ 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."

+ 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>

+ 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>

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

@@ -0,0 +1,81 @@
+{% 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-6">
+        <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>Created</td>
+                    <td>{{ tenant.created }}</td>
+                </tr>
+                <tr>
+                    <td>Last Updated</td>
+                    <td>{{ tenant.last_updated }}</td>
+                </tr>
+            </table>
+        </div>
+	</div>
+	<div class="col-md-6">
+        <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>
+{% 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 %}

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

@@ -0,0 +1,20 @@
+{% 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 %}
+        </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 %}

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

@@ -0,0 +1,52 @@
+{% 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>Widgets Inc.</td>
+				</tr>
+				<tr>
+					<td>Slug</td>
+					<td>URL-friendly name</td>
+					<td>widgets-inc</td>
+				</tr>
+				<tr>
+					<td>Group</td>
+					<td>Tenant group</td>
+					<td>Customers</td>
+				</tr>
+			</tbody>
+		</table>
+		<h4>Example</h4>
+		<pre>Widgets Inc.,widgets-inc,Customers</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']
+
+    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', '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', 'comments']
+
+
+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}))

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

@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.8 on 2016-07-26 18:15
+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=50, unique=True)),
+                ('slug', models.SlugField(unique=True)),
+                ('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


+ 48 - 0
netbox/tenancy/models.py

@@ -0,0 +1,48 @@
+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=50, unique=True)
+    slug = models.SlugField(unique=True)
+    group = models.ForeignKey('TenantGroup', related_name='tenants', on_delete=models.PROTECT)
+    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,
+        ])

+ 43 - 0
netbox/tenancy/tables.py

@@ -0,0 +1,43 @@
+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')
+
+    class Meta(BaseTable.Meta):
+        model = Tenant
+        fields = ('pk', 'name', 'group')

+ 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'),
+
+]

+ 103 - 0
netbox/tenancy/views.py

@@ -0,0 +1,103 @@
+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, 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'