Recent Activity
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html
new file mode 100644
index 000000000..780b63d8e
--- /dev/null
+++ b/netbox/templates/tenancy/tenant.html
@@ -0,0 +1,81 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block title %}{{ tenant }}{% endblock %}
+
+{% block content %}
+
+
+
{{ tenant }}
+
+
+
+
+ Tenant
+
+
+
+ Group |
+
+ {{ tenant.group }}
+ |
+
+
+ Created |
+ {{ tenant.created }} |
+
+
+ Last Updated |
+ {{ tenant.last_updated }} |
+
+
+
+
+
+
+
+ Comments
+
+
+ {% if tenant.comments %}
+ {{ tenant.comments|gfm }}
+ {% else %}
+ None
+ {% endif %}
+
+
+
+
+{% endblock %}
diff --git a/netbox/templates/tenancy/tenant_bulk_edit.html b/netbox/templates/tenancy/tenant_bulk_edit.html
new file mode 100644
index 000000000..f9bd55cf2
--- /dev/null
+++ b/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 %}
+
+ {{ tenant }} |
+ {{ tenant.group }} |
+
+ {% endfor %}
+{% endblock %}
diff --git a/netbox/templates/tenancy/tenant_edit.html b/netbox/templates/tenancy/tenant_edit.html
new file mode 100644
index 000000000..cffb29510
--- /dev/null
+++ b/netbox/templates/tenancy/tenant_edit.html
@@ -0,0 +1,20 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load static from staticfiles %}
+{% load form_helpers %}
+
+{% block form %}
+
+
Tenant
+
+ {% render_field form.name %}
+ {% render_field form.slug %}
+ {% render_field form.group %}
+
+
+
+
Comments
+
+ {% render_field form.comments %}
+
+
+{% endblock %}
diff --git a/netbox/templates/tenancy/tenant_import.html b/netbox/templates/tenancy/tenant_import.html
new file mode 100644
index 000000000..9d05fa8d7
--- /dev/null
+++ b/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 %}
+
Tenant Import
+
+
+
+
CSV Format
+
+
+
+ Field |
+ Description |
+ Example |
+
+
+
+
+ Name |
+ Tenant name |
+ Widgets Inc. |
+
+
+ Slug |
+ URL-friendly name |
+ widgets-inc |
+
+
+ Group |
+ Tenant group |
+ Customers |
+
+
+
+
Example
+
Widgets Inc.,widgets-inc,Customers
+
+
+{% endblock %}
diff --git a/netbox/templates/tenancy/tenant_list.html b/netbox/templates/tenancy/tenant_list.html
new file mode 100644
index 000000000..2d46412a3
--- /dev/null
+++ b/netbox/templates/tenancy/tenant_list.html
@@ -0,0 +1,42 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block title %}Tenants{% endblock %}
+
+{% block content %}
+
+ {% if perms.tenancy.add_tenant %}
+
+
+ Add a tenant
+
+ {% endif %}
+ {% include 'inc/export_button.html' with obj_type='tenants' %}
+
+
Tenants
+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='tenancy:tenant_bulk_edit' bulk_delete_url='tenancy:tenant_bulk_delete' %}
+
+
+
+ {% include 'inc/filter_panel.html' %}
+
+
+{% endblock %}
diff --git a/netbox/templates/tenancy/tenantgroup_list.html b/netbox/templates/tenancy/tenantgroup_list.html
new file mode 100644
index 000000000..be270a95c
--- /dev/null
+++ b/netbox/templates/tenancy/tenantgroup_list.html
@@ -0,0 +1,21 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block title %}Tenant Groups{% endblock %}
+
+{% block content %}
+
+
Tenant Groups
+
+
+ {% include 'utilities/obj_table.html' with bulk_delete_url='tenancy:tenantgroup_bulk_delete' %}
+
+
+{% endblock %}
diff --git a/netbox/tenancy/__init__.py b/netbox/tenancy/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/tenancy/admin.py b/netbox/tenancy/admin.py
new file mode 100644
index 000000000..d381b88ff
--- /dev/null
+++ b/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')
diff --git a/netbox/tenancy/api/__init__.py b/netbox/tenancy/api/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py
new file mode 100644
index 000000000..30a4a3ca1
--- /dev/null
+++ b/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']
diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py
new file mode 100644
index 000000000..af1d1d6aa
--- /dev/null
+++ b/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
\d+)/$', TenantGroupDetailView.as_view(), name='tenantgroup_detail'),
+
+ # Tenants
+ url(r'^tenants/$', TenantListView.as_view(), name='tenant_list'),
+ url(r'^tenants/(?P\d+)/$', TenantDetailView.as_view(), name='tenant_detail'),
+
+]
diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py
new file mode 100644
index 000000000..ca8ab11d7
--- /dev/null
+++ b/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
diff --git a/netbox/tenancy/apps.py b/netbox/tenancy/apps.py
new file mode 100644
index 000000000..53cb9a056
--- /dev/null
+++ b/netbox/tenancy/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class TenancyConfig(AppConfig):
+ name = 'tenancy'
diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py
new file mode 100644
index 000000000..8ae273c88
--- /dev/null
+++ b/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)
diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py
new file mode 100644
index 000000000..a80956238
--- /dev/null
+++ b/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}))
diff --git a/netbox/tenancy/migrations/0001_initial.py b/netbox/tenancy/migrations/0001_initial.py
new file mode 100644
index 000000000..990f78874
--- /dev/null
+++ b/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'),
+ ),
+ ]
diff --git a/netbox/tenancy/migrations/__init__.py b/netbox/tenancy/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py
new file mode 100644
index 000000000..26d92b79d
--- /dev/null
+++ b/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,
+ ])
diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py
new file mode 100644
index 000000000..34496f446
--- /dev/null
+++ b/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 %}
+ Edit
+{% 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')
diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py
new file mode 100644
index 000000000..48819f675
--- /dev/null
+++ b/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[\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[\w-]+)/$', views.tenant, name='tenant'),
+ url(r'^tenants/(?P[\w-]+)/edit/$', views.TenantEditView.as_view(), name='tenant_edit'),
+ url(r'^tenants/(?P[\w-]+)/delete/$', views.TenantDeleteView.as_view(), name='tenant_delete'),
+
+]
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
new file mode 100644
index 000000000..db8befedf
--- /dev/null
+++ b/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'