mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Initial multitenancy implementation
This commit is contained in:
9
docs/data-model/tenancy.md
Normal file
9
docs/data-model/tenancy.md
Normal file
@ -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."
|
@ -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',
|
||||
)
|
||||
|
@ -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')),
|
||||
|
||||
|
@ -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(),
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
@ -58,6 +58,18 @@
|
||||
<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>
|
||||
</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>
|
||||
@ -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
netbox/templates/tenancy/tenant.html
Normal file
81
netbox/templates/tenancy/tenant.html
Normal file
@ -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
netbox/templates/tenancy/tenant_bulk_edit.html
Normal file
13
netbox/templates/tenancy/tenant_bulk_edit.html
Normal file
@ -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
netbox/templates/tenancy/tenant_edit.html
Normal file
20
netbox/templates/tenancy/tenant_edit.html
Normal file
@ -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
netbox/templates/tenancy/tenant_import.html
Normal file
52
netbox/templates/tenancy/tenant_import.html
Normal file
@ -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
netbox/templates/tenancy/tenant_list.html
Normal file
42
netbox/templates/tenancy/tenant_list.html
Normal file
@ -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
netbox/templates/tenancy/tenantgroup_list.html
Normal file
21
netbox/templates/tenancy/tenantgroup_list.html
Normal file
@ -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
netbox/tenancy/__init__.py
Normal file
0
netbox/tenancy/__init__.py
Normal file
23
netbox/tenancy/admin.py
Normal file
23
netbox/tenancy/admin.py
Normal 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')
|
0
netbox/tenancy/api/__init__.py
Normal file
0
netbox/tenancy/api/__init__.py
Normal file
38
netbox/tenancy/api/serializers.py
Normal file
38
netbox/tenancy/api/serializers.py
Normal 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']
|
16
netbox/tenancy/api/urls.py
Normal file
16
netbox/tenancy/api/urls.py
Normal 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'),
|
||||
|
||||
]
|
39
netbox/tenancy/api/views.py
Normal file
39
netbox/tenancy/api/views.py
Normal 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
5
netbox/tenancy/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TenancyConfig(AppConfig):
|
||||
name = 'tenancy'
|
29
netbox/tenancy/filters.py
Normal file
29
netbox/tenancy/filters.py
Normal 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
61
netbox/tenancy/forms.py
Normal 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}))
|
47
netbox/tenancy/migrations/0001_initial.py
Normal file
47
netbox/tenancy/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
0
netbox/tenancy/migrations/__init__.py
Normal file
0
netbox/tenancy/migrations/__init__.py
Normal file
48
netbox/tenancy/models.py
Normal file
48
netbox/tenancy/models.py
Normal 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
43
netbox/tenancy/tables.py
Normal 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
24
netbox/tenancy/urls.py
Normal 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
103
netbox/tenancy/views.py
Normal 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'
|
Reference in New Issue
Block a user