Преглед изворни кода

Initial multitenancy implementation

Jeremy Stretch пре 9 година
родитељ
комит
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.")
                                "the documentation.")
 
 
 
 
-VERSION = '1.3.3-dev'
+VERSION = '1.4.0-dev'
 
 
 # Import local configuration
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -108,6 +108,7 @@ INSTALLED_APPS = (
     'ipam',
     'ipam',
     'extras',
     'extras',
     'secrets',
     'secrets',
+    'tenancy',
     'users',
     'users',
     'utilities',
     'utilities',
 )
 )

+ 2 - 0
netbox/netbox/urls.py

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

+ 5 - 1
netbox/netbox/views.py

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

+ 18 - 9
netbox/templates/_base.html

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

+ 27 - 15
netbox/templates/home.html

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

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