Initial multitenancy implementation

This commit is contained in:
Jeremy Stretch
2016-07-26 14:58:37 -04:00
parent 95018c69ff
commit fb56ade50e
27 changed files with 768 additions and 26 deletions

View File

23
netbox/tenancy/admin.py Normal file
View File

@@ -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')

View File

View File

@@ -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']

View File

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

View File

@@ -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
netbox/tenancy/apps.py Normal file
View File

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

29
netbox/tenancy/filters.py Normal file
View File

@@ -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
netbox/tenancy/forms.py Normal file
View File

@@ -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}))

View File

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

View File

48
netbox/tenancy/models.py Normal file
View File

@@ -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
netbox/tenancy/tables.py Normal file
View File

@@ -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
netbox/tenancy/urls.py Normal file
View File

@@ -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
netbox/tenancy/views.py Normal file
View File

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