Merge pull request #393 from digitalocean/multitenancy

Multitenancy
This commit is contained in:
Jeremy Stretch 2016-07-27 14:05:48 -04:00 committed by GitHub
commit 1413f5d89e
76 changed files with 1328 additions and 125 deletions

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

View File

@ -21,10 +21,11 @@ class CircuitTypeAdmin(admin.ModelAdmin):
@admin.register(Circuit) @admin.register(Circuit)
class CircuitAdmin(admin.ModelAdmin): class CircuitAdmin(admin.ModelAdmin):
list_display = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id'] list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'commit_rate',
list_filter = ['provider'] 'xconnect_id']
list_filter = ['provider', 'type', 'tenant']
exclude = ['interface'] exclude = ['interface']
def get_queryset(self, request): def get_queryset(self, request):
qs = super(CircuitAdmin, self).get_queryset(request) qs = super(CircuitAdmin, self).get_queryset(request)
return qs.select_related('provider', 'type', 'site') return qs.select_related('provider', 'type', 'tenant', 'site')

View File

@ -2,6 +2,7 @@ from rest_framework import serializers
from circuits.models import Provider, CircuitType, Circuit from circuits.models import Provider, CircuitType, Circuit
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
from tenancy.api.serializers import TenantNestedSerializer
# #
@ -45,13 +46,14 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer):
class CircuitSerializer(serializers.ModelSerializer): class CircuitSerializer(serializers.ModelSerializer):
provider = ProviderNestedSerializer() provider = ProviderNestedSerializer()
type = CircuitTypeNestedSerializer() type = CircuitTypeNestedSerializer()
tenant = TenantNestedSerializer()
site = SiteNestedSerializer() site = SiteNestedSerializer()
interface = InterfaceNestedSerializer() interface = InterfaceNestedSerializer()
class Meta: class Meta:
model = Circuit model = Circuit
fields = ['id', 'cid', 'provider', 'type', 'site', 'interface', 'install_date', 'port_speed', 'commit_rate', fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed',
'xconnect_id', 'comments'] 'commit_rate', 'xconnect_id', 'comments']
class CircuitNestedSerializer(CircuitSerializer): class CircuitNestedSerializer(CircuitSerializer):

View File

@ -42,7 +42,7 @@ class CircuitListView(generics.ListAPIView):
""" """
List circuits (filterable) List circuits (filterable)
""" """
queryset = Circuit.objects.select_related('type', 'provider', 'site', 'interface__device') queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')
serializer_class = serializers.CircuitSerializer serializer_class = serializers.CircuitSerializer
filter_class = CircuitFilter filter_class = CircuitFilter
@ -51,5 +51,5 @@ class CircuitDetailView(generics.RetrieveAPIView):
""" """
Retrieve a single circuit Retrieve a single circuit
""" """
queryset = Circuit.objects.select_related('type', 'provider', 'site', 'interface__device') queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')
serializer_class = serializers.CircuitSerializer serializer_class = serializers.CircuitSerializer

View File

@ -3,6 +3,7 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from dcim.models import Site from dcim.models import Site
from tenancy.models import Tenant
from .models import Provider, Circuit, CircuitType from .models import Provider, Circuit, CircuitType
@ -62,6 +63,17 @@ class CircuitFilter(django_filters.FilterSet):
to_field_name='slug', to_field_name='slug',
label='Circuit type (slug)', label='Circuit type (slug)',
) )
tenant_id = django_filters.ModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
name='site', name='site',
queryset=Site.objects.all(), queryset=Site.objects.all(),

View File

@ -2,6 +2,7 @@ from django import forms
from django.db.models import Count from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField, APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField,
) )
@ -99,7 +100,7 @@ class CircuitForm(forms.ModelForm, BootstrapMixin):
class Meta: class Meta:
model = Circuit model = Circuit
fields = [ fields = [
'cid', 'type', 'provider', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date', 'cid', 'type', 'provider', 'tenant', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
'port_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments' 'port_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
] ]
help_texts = { help_texts = {
@ -160,13 +161,15 @@ class CircuitFromCSVForm(forms.ModelForm):
error_messages={'invalid_choice': 'Provider not found.'}) error_messages={'invalid_choice': 'Provider not found.'})
type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name', type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid circuit type.'}) error_messages={'invalid_choice': 'Invalid circuit type.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name', site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'}) error_messages={'invalid_choice': 'Site not found.'})
class Meta: class Meta:
model = Circuit model = Circuit
fields = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id', fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'commit_rate',
'pp_info'] 'xconnect_id', 'pp_info']
class CircuitImportForm(BulkImportForm, BootstrapMixin): class CircuitImportForm(BulkImportForm, BootstrapMixin):
@ -177,6 +180,7 @@ class CircuitBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False) type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False) provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)') port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)')
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)') commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
comments = CommentField() comments = CommentField()
@ -192,6 +196,11 @@ def circuit_provider_choices():
return [(p.slug, u'{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices] return [(p.slug, u'{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices]
def circuit_tenant_choices():
tenant_choices = Tenant.objects.annotate(circuit_count=Count('circuits'))
return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in tenant_choices]
def circuit_site_choices(): def circuit_site_choices():
site_choices = Site.objects.annotate(circuit_count=Count('circuits')) site_choices = Site.objects.annotate(circuit_count=Count('circuits'))
return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices] return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
@ -201,5 +210,7 @@ class CircuitFilterForm(forms.Form, BootstrapMixin):
type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices) type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices)
provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices, provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices,
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=circuit_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices, site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))

View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.8 on 2016-07-26 21:59
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0001_initial'),
('circuits', '0003_provider_32bit_asn_support'),
]
operations = [
migrations.AddField(
model_name='circuit',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='tenancy.Tenant'),
),
]

View File

@ -3,6 +3,7 @@ from django.db import models
from dcim.fields import ASNField from dcim.fields import ASNField
from dcim.models import Site, Interface from dcim.models import Site, Interface
from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
@ -66,6 +67,7 @@ class Circuit(CreatedUpdatedModel):
cid = models.CharField(max_length=50, verbose_name='Circuit ID') cid = models.CharField(max_length=50, verbose_name='Circuit ID')
provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT) provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT) type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
site = models.ForeignKey(Site, related_name='circuits', on_delete=models.PROTECT) site = models.ForeignKey(Site, related_name='circuits', on_delete=models.PROTECT)
interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True) interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True)
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed') install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
@ -90,6 +92,7 @@ class Circuit(CreatedUpdatedModel):
self.cid, self.cid,
self.provider.name, self.provider.name,
self.type.name, self.type.name,
self.tenant.name if self.tenant else '',
self.site.name, self.site.name,
self.install_date.isoformat() if self.install_date else '', self.install_date.isoformat() if self.install_date else '',
str(self.port_speed), str(self.port_speed),

View File

@ -53,10 +53,11 @@ class CircuitTable(BaseTable):
cid = tables.LinkColumn('circuits:circuit', args=[Accessor('pk')], verbose_name='ID') cid = tables.LinkColumn('circuits:circuit', args=[Accessor('pk')], verbose_name='ID')
type = tables.Column(verbose_name='Type') type = tables.Column(verbose_name='Type')
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider') provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
port_speed_human = tables.Column(verbose_name='Port Speed') port_speed_human = tables.Column(verbose_name='Port Speed')
commit_rate_human = tables.Column(verbose_name='Commit Rate') commit_rate_human = tables.Column(verbose_name='Commit Rate')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Circuit model = Circuit
fields = ('pk', 'cid', 'type', 'provider', 'site', 'port_speed_human', 'commit_rate_human') fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'site', 'port_speed_human', 'commit_rate_human')

View File

@ -109,7 +109,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# #
class CircuitListView(ObjectListView): class CircuitListView(ObjectListView):
queryset = Circuit.objects.select_related('provider', 'type', 'site') queryset = Circuit.objects.select_related('provider', 'type', 'tenant', 'site')
filter = filters.CircuitFilter filter = filters.CircuitFilter
filter_form = forms.CircuitFilterForm filter_form = forms.CircuitFilterForm
table = tables.CircuitTable table = tables.CircuitTable
@ -159,7 +159,7 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
def update_objects(self, pk_list, form): def update_objects(self, pk_list, form):
fields_to_update = {} fields_to_update = {}
for field in ['type', 'provider', 'port_speed', 'commit_rate', 'comments']: for field in ['type', 'provider', 'tenant', 'port_speed', 'commit_rate', 'comments']:
if form.cleaned_data[field]: if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field] fields_to_update[field] = form.cleaned_data[field]

View File

@ -6,6 +6,7 @@ from dcim.models import (
DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet,
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
) )
from tenancy.api.serializers import TenantNestedSerializer
# #
@ -13,10 +14,11 @@ from dcim.models import (
# #
class SiteSerializer(serializers.ModelSerializer): class SiteSerializer(serializers.ModelSerializer):
tenant = TenantNestedSerializer()
class Meta: class Meta:
model = Site model = Site
fields = ['id', 'name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments', fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments',
'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits'] 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
@ -52,10 +54,11 @@ class RackGroupNestedSerializer(RackGroupSerializer):
class RackSerializer(serializers.ModelSerializer): class RackSerializer(serializers.ModelSerializer):
site = SiteNestedSerializer() site = SiteNestedSerializer()
group = RackGroupNestedSerializer() group = RackGroupNestedSerializer()
tenant = TenantNestedSerializer()
class Meta: class Meta:
model = Rack model = Rack
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments'] fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'u_height', 'comments']
class RackNestedSerializer(RackSerializer): class RackNestedSerializer(RackSerializer):
@ -69,8 +72,8 @@ class RackDetailSerializer(RackSerializer):
rear_units = serializers.SerializerMethodField() rear_units = serializers.SerializerMethodField()
class Meta(RackSerializer.Meta): class Meta(RackSerializer.Meta):
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments', 'front_units', fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'u_height', 'comments',
'rear_units'] 'front_units', 'rear_units']
def get_front_units(self, obj): def get_front_units(self, obj):
units = obj.get_rack_units(face=RACK_FACE_FRONT) units = obj.get_rack_units(face=RACK_FACE_FRONT)
@ -218,6 +221,7 @@ class DeviceIPAddressNestedSerializer(serializers.ModelSerializer):
class DeviceSerializer(serializers.ModelSerializer): class DeviceSerializer(serializers.ModelSerializer):
device_type = DeviceTypeNestedSerializer() device_type = DeviceTypeNestedSerializer()
device_role = DeviceRoleNestedSerializer() device_role = DeviceRoleNestedSerializer()
tenant = TenantNestedSerializer()
platform = PlatformNestedSerializer() platform = PlatformNestedSerializer()
rack = RackNestedSerializer() rack = RackNestedSerializer()
primary_ip = DeviceIPAddressNestedSerializer() primary_ip = DeviceIPAddressNestedSerializer()
@ -227,8 +231,8 @@ class DeviceSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Device model = Device
fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'platform', 'serial', 'rack', 'position', fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'rack',
'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments'] 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments']
def get_parent_device(self, obj): def get_parent_device(self, obj):
try: try:

View File

@ -27,7 +27,7 @@ class SiteListView(generics.ListAPIView):
""" """
List all sites List all sites
""" """
queryset = Site.objects.all() queryset = Site.objects.select_related('tenant')
serializer_class = serializers.SiteSerializer serializer_class = serializers.SiteSerializer
@ -35,7 +35,7 @@ class SiteDetailView(generics.RetrieveAPIView):
""" """
Retrieve a single site Retrieve a single site
""" """
queryset = Site.objects.all() queryset = Site.objects.select_related('tenant')
serializer_class = serializers.SiteSerializer serializer_class = serializers.SiteSerializer
@ -68,7 +68,7 @@ class RackListView(generics.ListAPIView):
""" """
List racks (filterable) List racks (filterable)
""" """
queryset = Rack.objects.select_related('site') queryset = Rack.objects.select_related('site', 'tenant')
serializer_class = serializers.RackSerializer serializer_class = serializers.RackSerializer
filter_class = filters.RackFilter filter_class = filters.RackFilter
@ -77,7 +77,7 @@ class RackDetailView(generics.RetrieveAPIView):
""" """
Retrieve a single rack Retrieve a single rack
""" """
queryset = Rack.objects.select_related('site') queryset = Rack.objects.select_related('site', 'tenant')
serializer_class = serializers.RackDetailSerializer serializer_class = serializers.RackDetailSerializer
@ -193,8 +193,9 @@ class DeviceListView(generics.ListAPIView):
""" """
List devices (filterable) List devices (filterable)
""" """
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'platform', 'rack__site')\ queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform',
.prefetch_related('primary_ip4__nat_outside', 'primary_ip6__nat_outside') 'rack__site').prefetch_related('primary_ip4__nat_outside',
'primary_ip6__nat_outside')
serializer_class = serializers.DeviceSerializer serializer_class = serializers.DeviceSerializer
filter_class = filters.DeviceFilter filter_class = filters.DeviceFilter
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer] renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer]

View File

@ -6,6 +6,7 @@ from .models import (
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site, Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
) )
from tenancy.models import Tenant
class SiteFilter(django_filters.FilterSet): class SiteFilter(django_filters.FilterSet):
@ -13,6 +14,17 @@ class SiteFilter(django_filters.FilterSet):
action='search', action='search',
label='Search', label='Search',
) )
tenant_id = django_filters.ModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
class Meta: class Meta:
model = Site model = Site
@ -74,6 +86,17 @@ class RackFilter(django_filters.FilterSet):
to_field_name='slug', to_field_name='slug',
label='Group', label='Group',
) )
tenant_id = django_filters.ModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
class Meta: class Meta:
model = Rack model = Rack
@ -143,6 +166,17 @@ class DeviceFilter(django_filters.FilterSet):
to_field_name='slug', to_field_name='slug',
label='Role (slug)', label='Role (slug)',
) )
tenant_id = django_filters.ModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
device_type_id = django_filters.ModelMultipleChoiceFilter( device_type_id = django_filters.ModelMultipleChoiceFilter(
name='device_type', name='device_type',
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.all(),

View File

@ -4,6 +4,7 @@ from django import forms
from django.db.models import Count, Q from django.db.models import Count, Q
from ipam.models import IPAddress from ipam.models import IPAddress
from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField, APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
@ -48,7 +49,7 @@ class SiteForm(forms.ModelForm, BootstrapMixin):
class Meta: class Meta:
model = Site model = Site
fields = ['name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments'] fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments']
widgets = { widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}), 'physical_address': SmallTextarea(attrs={'rows': 3}),
'shipping_address': SmallTextarea(attrs={'rows': 3}), 'shipping_address': SmallTextarea(attrs={'rows': 3}),
@ -63,16 +64,28 @@ class SiteForm(forms.ModelForm, BootstrapMixin):
class SiteFromCSVForm(forms.ModelForm): class SiteFromCSVForm(forms.ModelForm):
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
class Meta: class Meta:
model = Site model = Site
fields = ['name', 'slug', 'facility', 'asn'] fields = ['name', 'slug', 'tenant', 'facility', 'asn']
class SiteImportForm(BulkImportForm, BootstrapMixin): class SiteImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=SiteFromCSVForm) csv = CSVDataField(csv_form=SiteFromCSVForm)
def site_tenant_choices():
tenant_choices = Tenant.objects.annotate(site_count=Count('sites'))
return [(t.slug, u'{} ({})'.format(t.name, t.site_count)) for t in tenant_choices]
class SiteFilterForm(forms.Form, BootstrapMixin):
tenant = forms.MultipleChoiceField(required=False, choices=site_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
# #
# Rack groups # Rack groups
# #
@ -107,7 +120,7 @@ class RackForm(forms.ModelForm, BootstrapMixin):
class Meta: class Meta:
model = Rack model = Rack
fields = ['site', 'group', 'name', 'facility_id', 'u_height', 'comments'] fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'u_height', 'comments']
help_texts = { help_texts = {
'site': "The site at which the rack exists", 'site': "The site at which the rack exists",
'name': "Organizational rack name", 'name': "Organizational rack name",
@ -135,10 +148,12 @@ class RackFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'}) error_messages={'invalid_choice': 'Site not found.'})
group_name = forms.CharField(required=False) group_name = forms.CharField(required=False)
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
class Meta: class Meta:
model = Rack model = Rack
fields = ['site', 'group_name', 'name', 'facility_id', 'u_height'] fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'u_height']
def clean(self): def clean(self):
@ -161,6 +176,7 @@ class RackBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False) group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
u_height = forms.IntegerField(required=False, label='Height (U)') u_height = forms.IntegerField(required=False, label='Height (U)')
comments = CommentField() comments = CommentField()
@ -175,11 +191,18 @@ def rack_group_choices():
return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices] return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices]
def rack_tenant_choices():
tenant_choices = Tenant.objects.annotate(rack_count=Count('racks'))
return [(t.slug, u'{} ({})'.format(t.name, t.rack_count)) for t in tenant_choices]
class RackFilterForm(forms.Form, BootstrapMixin): class RackFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=rack_site_choices, site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group', group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
# #
@ -203,8 +226,8 @@ class DeviceTypeForm(forms.ModelForm, BootstrapMixin):
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
'is_network_device', 'subdevice_role'] 'is_pdu', 'is_network_device', 'subdevice_role']
class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin): class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
@ -324,7 +347,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
class Meta: class Meta:
model = Device model = Device
fields = ['name', 'device_role', 'device_type', 'serial', 'site', 'rack', 'position', 'face', 'status', fields = ['name', 'device_role', 'tenant', 'device_type', 'serial', 'site', 'rack', 'position', 'face', 'status',
'platform', 'primary_ip4', 'primary_ip6', 'comments'] 'platform', 'primary_ip4', 'primary_ip6', 'comments']
help_texts = { help_texts = {
'device_role': "The function this device serves", 'device_role': "The function this device serves",
@ -410,6 +433,8 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
class BaseDeviceFromCSVForm(forms.ModelForm): class BaseDeviceFromCSVForm(forms.ModelForm):
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name', device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid device role.'}) error_messages={'invalid_choice': 'Invalid device role.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name', manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid manufacturer.'}) error_messages={'invalid_choice': 'Invalid manufacturer.'})
model_name = forms.CharField() model_name = forms.CharField()
@ -441,8 +466,8 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm):
face = forms.CharField(required=False) face = forms.CharField(required=False)
class Meta(BaseDeviceFromCSVForm.Meta): class Meta(BaseDeviceFromCSVForm.Meta):
fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'site', 'rack_name', fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'site',
'position', 'face'] 'rack_name', 'position', 'face']
def clean(self): def clean(self):
@ -477,7 +502,7 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
device_bay_name = forms.CharField(required=False) device_bay_name = forms.CharField(required=False)
class Meta(BaseDeviceFromCSVForm.Meta): class Meta(BaseDeviceFromCSVForm.Meta):
fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'parent', fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'parent',
'device_bay_name'] 'device_bay_name']
def clean(self): def clean(self):
@ -512,6 +537,7 @@ class DeviceBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type') device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role') device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False, label='Tenant')
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, label='Platform') platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, label='Platform')
platform_delete = forms.BooleanField(required=False, label='Set platform to "none"') platform_delete = forms.BooleanField(required=False, label='Set platform to "none"')
status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status') status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status')
@ -533,6 +559,11 @@ def device_role_choices():
return [(r.slug, u'{} ({})'.format(r.name, r.device_count)) for r in role_choices] return [(r.slug, u'{} ({})'.format(r.name, r.device_count)) for r in role_choices]
def device_tenant_choices():
tenant_choices = Tenant.objects.annotate(device_count=Count('devices'))
return [(t.slug, u'{} ({})'.format(t.name, t.device_count)) for t in tenant_choices]
def device_type_choices(): def device_type_choices():
type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances')) type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances'))
return [(t.pk, u'{} ({})'.format(t, t.device_count)) for t in type_choices] return [(t.pk, u'{} ({})'.format(t, t.device_count)) for t in type_choices]
@ -550,6 +581,8 @@ class DeviceFilterForm(forms.Form, BootstrapMixin):
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
role = forms.MultipleChoiceField(required=False, choices=device_role_choices, role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=device_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type', device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type',
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices) platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices)

View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.8 on 2016-07-26 21:59
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0001_initial'),
('dcim', '0011_devicetype_part_number'),
]
operations = [
migrations.AddField(
model_name='device',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='tenancy.Tenant'),
),
migrations.AddField(
model_name='rack',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='tenancy.Tenant'),
),
migrations.AddField(
model_name='site',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sites', to='tenancy.Tenant'),
),
]

View File

@ -8,6 +8,7 @@ from django.db import models
from django.db.models import Count, Q, ObjectDoesNotExist from django.db.models import Count, Q, ObjectDoesNotExist
from extras.rpc import RPC_CLIENTS from extras.rpc import RPC_CLIENTS
from tenancy.models import Tenant
from utilities.fields import NullableCharField from utilities.fields import NullableCharField
from utilities.managers import NaturalOrderByManager from utilities.managers import NaturalOrderByManager
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
@ -152,6 +153,7 @@ class Site(CreatedUpdatedModel):
""" """
name = models.CharField(max_length=50, unique=True) name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True) slug = models.SlugField(unique=True)
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='sites', on_delete=models.PROTECT)
facility = models.CharField(max_length=50, blank=True) facility = models.CharField(max_length=50, blank=True)
asn = ASNField(blank=True, null=True, verbose_name='ASN') asn = ASNField(blank=True, null=True, verbose_name='ASN')
physical_address = models.CharField(max_length=200, blank=True) physical_address = models.CharField(max_length=200, blank=True)
@ -173,6 +175,7 @@ class Site(CreatedUpdatedModel):
return ','.join([ return ','.join([
self.name, self.name,
self.slug, self.slug,
self.tenant.name if self.tenant else '',
self.facility, self.facility,
str(self.asn), str(self.asn),
]) ])
@ -237,6 +240,7 @@ class Rack(CreatedUpdatedModel):
facility_id = NullableCharField(max_length=30, blank=True, null=True, verbose_name='Facility ID') facility_id = NullableCharField(max_length=30, blank=True, null=True, verbose_name='Facility ID')
site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT) site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT)
group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL) group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL)
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT)
u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)') u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)')
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
@ -272,6 +276,7 @@ class Rack(CreatedUpdatedModel):
self.group.name if self.group else '', self.group.name if self.group else '',
self.name, self.name,
self.facility_id or '', self.facility_id or '',
self.tenant.name if self.tenant else '',
str(self.u_height), str(self.u_height),
]) ])
@ -631,6 +636,7 @@ class Device(CreatedUpdatedModel):
""" """
device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT) device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT)
device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT) device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT)
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='devices', on_delete=models.PROTECT)
platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL) platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
name = NullableCharField(max_length=50, blank=True, null=True, unique=True) name = NullableCharField(max_length=50, blank=True, null=True, unique=True)
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
@ -724,6 +730,7 @@ class Device(CreatedUpdatedModel):
return ','.join([ return ','.join([
self.name or '', self.name or '',
self.device_role.name, self.device_role.name,
self.tenant.name if self.tenant else '',
self.device_type.manufacturer.name, self.device_type.manufacturer.name,
self.device_type.model, self.device_type.model,
self.platform.name if self.platform else '', self.platform.name if self.platform else '',

View File

@ -61,6 +61,7 @@ UTILIZATION_GRAPH = """
class SiteTable(BaseTable): class SiteTable(BaseTable):
name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name') name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
facility = tables.Column(verbose_name='Facility') facility = tables.Column(verbose_name='Facility')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
asn = tables.Column(verbose_name='ASN') asn = tables.Column(verbose_name='ASN')
rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks') rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices') device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
@ -70,7 +71,7 @@ class SiteTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Site model = Site
fields = ('name', 'facility', 'asn', 'rack_count', 'device_count', 'prefix_count', 'vlan_count', fields = ('name', 'facility', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count', 'vlan_count',
'circuit_count') 'circuit_count')
@ -101,14 +102,16 @@ class RackTable(BaseTable):
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
facility_id = tables.Column(verbose_name='Facility ID') facility_id = tables.Column(verbose_name='Facility ID')
u_height = tables.Column(verbose_name='Height (U)') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices') devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
u_consumed = tables.Column(accessor=Accessor('u_consumed'), verbose_name='Used (U)') u_consumed = tables.TemplateColumn("{{ record.u_consumed|default:'0' }}U", verbose_name='Used')
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Rack model = Rack
fields = ('pk', 'name', 'site', 'group', 'facility_id', 'u_height', 'devices', 'u_consumed', 'utilization') fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'u_height', 'devices', 'u_consumed',
'utilization')
class RackImportTable(BaseTable): class RackImportTable(BaseTable):
@ -116,11 +119,12 @@ class RackImportTable(BaseTable):
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
facility_id = tables.Column(verbose_name='Facility ID') facility_id = tables.Column(verbose_name='Facility ID')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
u_height = tables.Column(verbose_name='Height (U)') u_height = tables.Column(verbose_name='Height (U)')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Rack model = Rack
fields = ('site', 'group', 'name', 'facility_id', 'u_height') fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height')
# #
@ -259,6 +263,7 @@ class DeviceTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='') status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site') site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack') rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
device_role = tables.Column(verbose_name='Role') device_role = tables.Column(verbose_name='Role')
@ -268,11 +273,12 @@ class DeviceTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Device model = Device
fields = ('pk', 'name', 'status', 'site', 'rack', 'device_role', 'device_type', 'primary_ip') fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
class DeviceImportTable(BaseTable): class DeviceImportTable(BaseTable):
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site') site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack') rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
position = tables.Column(verbose_name='Position') position = tables.Column(verbose_name='Position')
@ -281,7 +287,7 @@ class DeviceImportTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Device model = Device
fields = ('name', 'site', 'rack', 'position', 'device_role', 'device_type') fields = ('name', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
empty_text = False empty_text = False

View File

@ -15,6 +15,7 @@ class SiteTest(APITestCase):
'id', 'id',
'name', 'name',
'slug', 'slug',
'tenant',
'facility', 'facility',
'asn', 'asn',
'physical_address', 'physical_address',
@ -40,6 +41,7 @@ class SiteTest(APITestCase):
'display_name', 'display_name',
'site', 'site',
'group', 'group',
'tenant',
'u_height', 'u_height',
'comments' 'comments'
] ]
@ -115,6 +117,7 @@ class RackTest(APITestCase):
'display_name', 'display_name',
'site', 'site',
'group', 'group',
'tenant',
'u_height', 'u_height',
'comments' 'comments'
] ]
@ -126,6 +129,7 @@ class RackTest(APITestCase):
'display_name', 'display_name',
'site', 'site',
'group', 'group',
'tenant',
'u_height', 'u_height',
'comments', 'comments',
'front_units', 'front_units',
@ -311,6 +315,7 @@ class DeviceTest(APITestCase):
'display_name', 'display_name',
'device_type', 'device_type',
'device_role', 'device_role',
'tenant',
'platform', 'platform',
'serial', 'serial',
'rack', 'rack',
@ -388,6 +393,7 @@ class DeviceTest(APITestCase):
'rack_name', 'rack_name',
'serial', 'serial',
'status', 'status',
'tenant',
] ]
response = self.client.get(endpoint) response = self.client.get(endpoint)

View File

@ -61,8 +61,9 @@ def expand_pattern(string):
# #
class SiteListView(ObjectListView): class SiteListView(ObjectListView):
queryset = Site.objects.all() queryset = Site.objects.select_related('tenant')
filter = filters.SiteFilter filter = filters.SiteFilter
filter_form = forms.SiteFilterForm
table = tables.SiteTable table = tables.SiteTable
template_name = 'dcim/site_list.html' template_name = 'dcim/site_list.html'
@ -200,7 +201,7 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
def update_objects(self, pk_list, form): def update_objects(self, pk_list, form):
fields_to_update = {} fields_to_update = {}
for field in ['site', 'group', 'u_height', 'comments']: for field in ['site', 'group', 'tenant', 'u_height', 'comments']:
if form.cleaned_data[field]: if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field] fields_to_update[field] = form.cleaned_data[field]
@ -632,7 +633,7 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
if form.cleaned_data['status']: if form.cleaned_data['status']:
status = form.cleaned_data['status'] status = form.cleaned_data['status']
fields_to_update['status'] = True if status == 'True' else False fields_to_update['status'] = True if status == 'True' else False
for field in ['device_type', 'device_role', 'serial']: for field in ['tenant', 'device_type', 'device_role', 'serial']:
if form.cleaned_data[field]: if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field] fields_to_update[field] = form.cleaned_data[field]

View File

@ -7,7 +7,12 @@ from .models import (
@admin.register(VRF) @admin.register(VRF)
class VRFAdmin(admin.ModelAdmin): class VRFAdmin(admin.ModelAdmin):
list_display = ['name', 'rd'] list_display = ['name', 'rd', 'tenant', 'enforce_unique']
list_filter = ['tenant']
def get_queryset(self, request):
qs = super(VRFAdmin, self).get_queryset(request)
return qs.select_related('tenant')
@admin.register(Role) @admin.register(Role)
@ -67,10 +72,10 @@ class VLANGroupAdmin(admin.ModelAdmin):
@admin.register(VLAN) @admin.register(VLAN)
class VLANAdmin(admin.ModelAdmin): class VLANAdmin(admin.ModelAdmin):
list_display = ['site', 'vid', 'name', 'status', 'role'] list_display = ['site', 'vid', 'name', 'tenant', 'status', 'role']
list_filter = ['site', 'status', 'role'] list_filter = ['site', 'tenant', 'status', 'role']
search_fields = ['vid', 'name'] search_fields = ['vid', 'name']
def get_queryset(self, request): def get_queryset(self, request):
qs = super(VLANAdmin, self).get_queryset(request) qs = super(VLANAdmin, self).get_queryset(request)
return qs.select_related('site', 'role') return qs.select_related('site', 'tenant', 'role')

View File

@ -2,6 +2,7 @@ from rest_framework import serializers
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
from tenancy.api.serializers import TenantNestedSerializer
# #
@ -9,10 +10,11 @@ from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLAN
# #
class VRFSerializer(serializers.ModelSerializer): class VRFSerializer(serializers.ModelSerializer):
tenant = TenantNestedSerializer()
class Meta: class Meta:
model = VRF model = VRF
fields = ['id', 'name', 'rd', 'enforce_unique', 'description'] fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description']
class VRFNestedSerializer(VRFSerializer): class VRFNestedSerializer(VRFSerializer):
@ -98,11 +100,12 @@ class VLANGroupNestedSerializer(VLANGroupSerializer):
class VLANSerializer(serializers.ModelSerializer): class VLANSerializer(serializers.ModelSerializer):
site = SiteNestedSerializer() site = SiteNestedSerializer()
group = VLANGroupNestedSerializer() group = VLANGroupNestedSerializer()
tenant = TenantNestedSerializer()
role = RoleNestedSerializer() role = RoleNestedSerializer()
class Meta: class Meta:
model = VLAN model = VLAN
fields = ['id', 'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'display_name'] fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name']
class VLANNestedSerializer(VLANSerializer): class VLANNestedSerializer(VLANSerializer):

View File

@ -14,7 +14,7 @@ class VRFListView(generics.ListAPIView):
""" """
List all VRFs List all VRFs
""" """
queryset = VRF.objects.all() queryset = VRF.objects.select_related('tenant')
serializer_class = serializers.VRFSerializer serializer_class = serializers.VRFSerializer
filter_class = filters.VRFFilter filter_class = filters.VRFFilter
@ -23,7 +23,7 @@ class VRFDetailView(generics.RetrieveAPIView):
""" """
Retrieve a single VRF Retrieve a single VRF
""" """
queryset = VRF.objects.all() queryset = VRF.objects.select_related('tenant')
serializer_class = serializers.VRFSerializer serializer_class = serializers.VRFSerializer
@ -161,7 +161,7 @@ class VLANListView(generics.ListAPIView):
""" """
List VLANs (filterable) List VLANs (filterable)
""" """
queryset = VLAN.objects.select_related('site', 'role') queryset = VLAN.objects.select_related('site', 'tenant', 'role')
serializer_class = serializers.VLANSerializer serializer_class = serializers.VLANSerializer
filter_class = filters.VLANFilter filter_class = filters.VLANFilter
@ -170,5 +170,5 @@ class VLANDetailView(generics.RetrieveAPIView):
""" """
Retrieve a single VLAN Retrieve a single VLAN
""" """
queryset = VLAN.objects.select_related('site', 'role') queryset = VLAN.objects.select_related('site', 'tenant', 'role')
serializer_class = serializers.VLANSerializer serializer_class = serializers.VLANSerializer

View File

@ -3,6 +3,7 @@ from netaddr import IPNetwork
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
from dcim.models import Site, Device, Interface from dcim.models import Site, Device, Interface
from tenancy.models import Tenant
from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
@ -13,6 +14,17 @@ class VRFFilter(django_filters.FilterSet):
lookup_type='icontains', lookup_type='icontains',
label='Name', label='Name',
) )
tenant_id = django_filters.ModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
class Meta: class Meta:
model = VRF model = VRF
@ -226,6 +238,17 @@ class VLANFilter(django_filters.FilterSet):
name='vid', name='vid',
label='VLAN number (1-4095)', label='VLAN number (1-4095)',
) )
tenant_id = django_filters.ModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
role_id = django_filters.ModelMultipleChoiceFilter( role_id = django_filters.ModelMultipleChoiceFilter(
name='role', name='role',
queryset=Role.objects.all(), queryset=Role.objects.all(),

View File

@ -4,6 +4,7 @@ from django import forms
from django.db.models import Count from django.db.models import Count
from dcim.models import Site, Device, Interface from dcim.models import Site, Device, Interface
from tenancy.models import Tenant
from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField
from .models import ( from .models import (
@ -23,7 +24,7 @@ class VRFForm(forms.ModelForm, BootstrapMixin):
class Meta: class Meta:
model = VRF model = VRF
fields = ['name', 'rd', 'enforce_unique', 'description'] fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
labels = { labels = {
'rd': "RD", 'rd': "RD",
} }
@ -33,10 +34,12 @@ class VRFForm(forms.ModelForm, BootstrapMixin):
class VRFFromCSVForm(forms.ModelForm): class VRFFromCSVForm(forms.ModelForm):
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
class Meta: class Meta:
model = VRF model = VRF
fields = ['name', 'rd', 'enforce_unique', 'description'] fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
class VRFImportForm(BulkImportForm, BootstrapMixin): class VRFImportForm(BulkImportForm, BootstrapMixin):
@ -45,9 +48,20 @@ class VRFImportForm(BulkImportForm, BootstrapMixin):
class VRFBulkEditForm(forms.Form, BootstrapMixin): class VRFBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
def vrf_tenant_choices():
tenant_choices = Tenant.objects.annotate(vrf_count=Count('vrfs'))
return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
class VRFFilterForm(forms.Form, BootstrapMixin):
tenant = forms.MultipleChoiceField(required=False, choices=vrf_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
# #
# RIRs # RIRs
# #
@ -444,7 +458,7 @@ class VLANForm(forms.ModelForm, BootstrapMixin):
class Meta: class Meta:
model = VLAN model = VLAN
fields = ['site', 'group', 'vid', 'name', 'description', 'status', 'role'] fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
help_texts = { help_texts = {
'site': "The site at which this VLAN exists", 'site': "The site at which this VLAN exists",
'group': "VLAN group (optional)", 'group': "VLAN group (optional)",
@ -475,13 +489,15 @@ class VLANFromCSVForm(forms.ModelForm):
error_messages={'invalid_choice': 'Device not found.'}) error_messages={'invalid_choice': 'Device not found.'})
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name', group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'VLAN group not found.'}) error_messages={'invalid_choice': 'VLAN group not found.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES]) status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES])
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name', role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'}) error_messages={'invalid_choice': 'Invalid role.'})
class Meta: class Meta:
model = VLAN model = VLAN
fields = ['site', 'group', 'vid', 'name', 'status_name', 'role', 'description'] fields = ['site', 'group', 'vid', 'name', 'tenant', 'status_name', 'role', 'description']
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
m = super(VLANFromCSVForm, self).save(commit=False) m = super(VLANFromCSVForm, self).save(commit=False)
@ -500,6 +516,7 @@ class VLANBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False) group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False) status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
@ -515,6 +532,11 @@ def vlan_group_choices():
return [(g.pk, u'{} ({})'.format(g, g.vlan_count)) for g in group_choices] return [(g.pk, u'{} ({})'.format(g, g.vlan_count)) for g in group_choices]
def vlan_tenant_choices():
tenant_choices = Tenant.objects.annotate(vrf_count=Count('vlans'))
return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
def vlan_status_choices(): def vlan_status_choices():
status_counts = {} status_counts = {}
for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'): for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
@ -532,6 +554,8 @@ class VLANFilterForm(forms.Form, BootstrapMixin):
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group', group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group',
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=vlan_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices) status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices, role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8})) widget=forms.SelectMultiple(attrs={'size': 8}))

View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.8 on 2016-07-27 14:39
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0001_initial'),
('ipam', '0005_auto_20160725_1842'),
]
operations = [
migrations.AddField(
model_name='vlan',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='tenancy.Tenant'),
),
migrations.AddField(
model_name='vrf',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vrfs', to='tenancy.Tenant'),
),
]

View File

@ -7,6 +7,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from dcim.models import Interface from dcim.models import Interface
from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
from .fields import IPNetworkField, IPAddressField from .fields import IPNetworkField, IPAddressField
@ -46,6 +47,7 @@ class VRF(CreatedUpdatedModel):
""" """
name = models.CharField(max_length=50) name = models.CharField(max_length=50)
rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher') rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher')
tenant = models.ForeignKey(Tenant, related_name='vrfs', blank=True, null=True, on_delete=models.PROTECT)
enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space', enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space',
help_text="Prevent duplicate prefixes/IP addresses within this VRF") help_text="Prevent duplicate prefixes/IP addresses within this VRF")
description = models.CharField(max_length=100, blank=True) description = models.CharField(max_length=100, blank=True)
@ -65,6 +67,8 @@ class VRF(CreatedUpdatedModel):
return ','.join([ return ','.join([
self.name, self.name,
self.rd, self.rd,
self.tenant.name if self.tenant else '',
'True' if self.enforce_unique else '',
self.description, self.description,
]) ])
@ -291,7 +295,7 @@ class Prefix(CreatedUpdatedModel):
class IPAddress(CreatedUpdatedModel): class IPAddress(CreatedUpdatedModel):
""" """
An IPAddress represents an individual IPV4 or IPv6 address and its mask. The mask length should match what is An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like
Prefixes, IPAddresses can optionally be assigned to a VRF. An IPAddress can optionally be assigned to an Interface. Prefixes, IPAddresses can optionally be assigned to a VRF. An IPAddress can optionally be assigned to an Interface.
Interfaces can have zero or more IPAddresses assigned to them. Interfaces can have zero or more IPAddresses assigned to them.
@ -407,9 +411,10 @@ class VLAN(CreatedUpdatedModel):
MaxValueValidator(4094) MaxValueValidator(4094)
]) ])
name = models.CharField(max_length=64) name = models.CharField(max_length=64)
description = models.CharField(max_length=100, blank=True) tenant = models.ForeignKey(Tenant, related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
status = models.PositiveSmallIntegerField('Status', choices=VLAN_STATUS_CHOICES, default=1) status = models.PositiveSmallIntegerField('Status', choices=VLAN_STATUS_CHOICES, default=1)
role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True) role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
description = models.CharField(max_length=100, blank=True)
class Meta: class Meta:
ordering = ['site', 'group', 'vid'] ordering = ['site', 'group', 'vid']
@ -438,6 +443,7 @@ class VLAN(CreatedUpdatedModel):
self.group.name if self.group else '', self.group.name if self.group else '',
str(self.vid), str(self.vid),
self.name, self.name,
self.tenant.name if self.tenant else '',
self.get_status_display(), self.get_status_display(),
self.role.name if self.role else '', self.role.name if self.role else '',
self.description, self.description,

View File

@ -58,11 +58,12 @@ class VRFTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name') name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
rd = tables.Column(verbose_name='RD') rd = tables.Column(verbose_name='RD')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
description = tables.Column(orderable=False, verbose_name='Description') description = tables.Column(orderable=False, verbose_name='Description')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VRF model = VRF
fields = ('pk', 'name', 'rd', 'description') fields = ('pk', 'name', 'rd', 'tenant', 'description')
# #
@ -203,9 +204,10 @@ class VLANTable(BaseTable):
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
name = tables.Column(verbose_name='Name') name = tables.Column(verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.Column(verbose_name='Role') role = tables.Column(verbose_name='Role')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VLAN model = VLAN
fields = ('pk', 'vid', 'site', 'group', 'name', 'status', 'role') fields = ('pk', 'vid', 'site', 'group', 'name', 'tenant', 'status', 'role')

View File

@ -36,8 +36,9 @@ def add_available_prefixes(parent, prefix_list):
# #
class VRFListView(ObjectListView): class VRFListView(ObjectListView):
queryset = VRF.objects.all() queryset = VRF.objects.select_related('tenant')
filter = filters.VRFFilter filter = filters.VRFFilter
filter_form = forms.VRFFilterForm
table = tables.VRFTable table = tables.VRFTable
edit_permissions = ['ipam.change_vrf', 'ipam.delete_vrf'] edit_permissions = ['ipam.change_vrf', 'ipam.delete_vrf']
template_name = 'ipam/vrf_list.html' template_name = 'ipam/vrf_list.html'
@ -85,7 +86,7 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
def update_objects(self, pk_list, form): def update_objects(self, pk_list, form):
fields_to_update = {} fields_to_update = {}
for field in ['description']: for field in ['tenant', 'description']:
if form.cleaned_data[field]: if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field] fields_to_update[field] = form.cleaned_data[field]
@ -558,7 +559,7 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
def update_objects(self, pk_list, form): def update_objects(self, pk_list, form):
fields_to_update = {} fields_to_update = {}
for field in ['site', 'group', 'status', 'role', 'description']: for field in ['site', 'group', 'tenant', 'status', 'role', 'description']:
if form.cleaned_data[field]: if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field] fields_to_update[field] = form.cleaned_data[field]

View File

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

View File

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

View File

@ -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(),

View File

@ -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 %}"> <li class="dropdown{% if request.path|startswith:'/dcim/sites/' or 'tenancy' in request.path %} active{% endif %}">
{% if perms.dcim.add_site %} <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
<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">
<ul class="dropdown-menu"> <li><a href="{% url 'dcim:site_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Sites</a></li>
<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> {% endif %}
{% else %} <li class="divider"></li>
<a href="{% url 'dcim:site_list' %}">Sites</a> <li><a href="{% url 'tenancy:tenant_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Tenants</a></li>
{% endif %} {% 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>

View File

@ -57,6 +57,45 @@
<td>Circuit ID</td> <td>Circuit ID</td>
<td>{{ circuit.cid }}</td> <td>{{ circuit.cid }}</td>
</tr> </tr>
<tr>
<td>Type</td>
<td><a href="{{ circuit.type.get_absolute_url }}">{{ circuit.type }}</a></td>
</tr>
<tr>
<td>Tenant</td>
<td>
{% if circuit.tenant %}
<a href="{{ circuit.tenant.get_absolute_url }}">{{ circuit.tenant }}</a>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Port Speed</td>
<td>{{ circuit.port_speed_human }}</td>
</tr>
<tr>
<td>Commit Rate</td>
<td>{% if circuit.commit_rate %}{{ circuit.commit_rate_human }}{% else %}<span class="text-muted">N/A</span>{% endif %}</td>
</tr>
<tr>
<td>Created</td>
<td>{{ circuit.created }}</td>
</tr>
<tr>
<td>Last Updated</td>
<td>{{ circuit.last_updated }}</td>
</tr>
</table>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Termination</strong>
</div>
<table class="table table-hover panel-body">
<tr> <tr>
<td>Site</td> <td>Site</td>
<td> <td>
@ -75,36 +114,36 @@
</tr> </tr>
<tr> <tr>
<td>Install Date</td> <td>Install Date</td>
<td>{{ circuit.install_date }}</td> <td>
</tr> {% if circuit.install_date %}
<tr> {{ circuit.install_date }}
<td>Port Speed</td> {% else %}
<td>{{ circuit.port_speed_human }}</td> <span class="text-muted">N/A</span>
</tr> {% endif %}
<tr> </td>
<td>Commit Rate</td>
<td>{% if circuit.commit_rate %}{{ circuit.commit_rate_human }}{% else %}<span class="text-muted">N/A</span>{% endif %}</td>
</tr> </tr>
<tr> <tr>
<td>Cross-Connect</td> <td>Cross-Connect</td>
<td>{{ circuit.xconnect_id }}</td> <td>
{% if circuit.xconnect_id %}
{{ circuit.xconnect_id }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<td>Patch Panel/Port</td> <td>Patch Panel/Port</td>
<td>{{ circuit.pp_info }}</td> <td>
</tr> {% if circuit.pp_info %}
<tr> {{ circuit.pp_info }}
<td>Created</td> {% else %}
<td>{{ circuit.created }}</td> <span class="text-muted">N/A</span>
</tr> {% endif %}
<tr> </td>
<td>Last Updated</td>
<td>{{ circuit.last_updated }}</td>
</tr> </tr>
</table> </table>
</div> </div>
</div>
<div class="col-md-6">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Comments</strong> <strong>Comments</strong>

View File

@ -9,13 +9,19 @@
{% render_field form.provider %} {% render_field form.provider %}
{% render_field form.cid %} {% render_field form.cid %}
{% render_field form.type %} {% render_field form.type %}
{% render_field form.tenant %}
{% render_field form.install_date %} {% render_field form.install_date %}
{% render_field form.port_speed %}
{% render_field form.commit_rate %}
{% render_field form.xconnect_id %} {% render_field form.xconnect_id %}
{% render_field form.pp_info %} {% render_field form.pp_info %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Bandwidth</strong></div>
<div class="panel-body">
{% render_field form.port_speed %}
{% render_field form.commit_rate %}
</div>
</div>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Termination</strong></div> <div class="panel-heading"><strong>Termination</strong></div>
<div class="panel-body"> <div class="panel-body">

View File

@ -43,6 +43,11 @@
<td>Circuit type</td> <td>Circuit type</td>
<td>Transit</td> <td>Transit</td>
</tr> </tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Strickland Propane</td>
</tr>
<tr> <tr>
<td>Site</td> <td>Site</td>
<td>Site name</td> <td>Site name</td>
@ -76,7 +81,7 @@
</tbody> </tbody>
</table> </table>
<h4>Example</h4> <h4>Example</h4>
<pre>IC-603122,TeliaSonera,Transit,ASH-4,2016-02-23,10000,2000,937649,PP8371 ports 13/14</pre> <pre>IC-603122,TeliaSonera,Transit,Strickland Propane,ASH-4,2016-02-23,10000,2000,937649,PP8371 ports 13/14</pre>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -14,6 +14,16 @@
<strong>Device</strong> <strong>Device</strong>
</div> </div>
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
<tr>
<td>Tenant</td>
<td>
{% if device.tenant %}
<a href="{{ device.tenant.get_absolute_url }}">{{ device.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr> <tr>
<td>Site</td> <td>Site</td>
<td> <td>

View File

@ -9,6 +9,7 @@
<td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td> <td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>
<td>{{ device.device_type }}</td> <td>{{ device.device_type }}</td>
<td>{{ device.device_role }}</td> <td>{{ device.device_role }}</td>
<td>{{ device.tenant }}</td>
<td>{{ device.serial }}</td> <td>{{ device.serial }}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -7,6 +7,7 @@
<div class="panel-body"> <div class="panel-body">
{% render_field form.name %} {% render_field form.name %}
{% render_field form.device_role %} {% render_field form.device_role %}
{% render_field form.tenant %}
</div> </div>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -36,6 +36,11 @@
<td>Functional role of device</td> <td>Functional role of device</td>
<td>ToR Switch</td> <td>ToR Switch</td>
</tr> </tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Pied Piper</td>
</tr>
<tr> <tr>
<td>Device manufacturer</td> <td>Device manufacturer</td>
<td>Hardware manufacturer</td> <td>Hardware manufacturer</td>
@ -79,7 +84,7 @@
</tbody> </tbody>
</table> </table>
<h4>Example</h4> <h4>Example</h4>
<pre>rack101_sw1,ToR Switch,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,Rear</pre> <pre>rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,Rear</pre>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -86,6 +86,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Tenant</td>
<td>
{% if rack.tenant %}
<a href="{{ rack.tenant.get_absolute_url }}">{{ rack.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr> <tr>
<td>Height</td> <td>Height</td>
<td>{{ rack.u_height }}U</td> <td>{{ rack.u_height }}U</td>

View File

@ -9,6 +9,7 @@
<td><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack }}</a></td> <td><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack }}</a></td>
<td>{{ rack.facility_id }}</td> <td>{{ rack.facility_id }}</td>
<td>{{ rack.site }}</td> <td>{{ rack.site }}</td>
<td>{{ rack.tenant }}</td>
<td>{{ rack.u_height }}</td> <td>{{ rack.u_height }}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -9,6 +9,7 @@
{% render_field form.group %} {% render_field form.group %}
{% render_field form.name %} {% render_field form.name %}
{% render_field form.facility_id %} {% render_field form.facility_id %}
{% render_field form.tenant %}
{% render_field form.u_height %} {% render_field form.u_height %}
</div> </div>
</div> </div>

View File

@ -48,6 +48,11 @@
<td>Rack ID assigned by the facility (optional)</td> <td>Rack ID assigned by the facility (optional)</td>
<td>J12.100</td> <td>J12.100</td>
</tr> </tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Pied Piper</td>
</tr>
<tr> <tr>
<td>Height</td> <td>Height</td>
<td>Height in rack units</td> <td>Height in rack units</td>
@ -56,7 +61,7 @@
</tbody> </tbody>
</table> </table>
<h4>Example</h4> <h4>Example</h4>
<pre>DC-4,Cage 1400,R101,J12.100,42</pre> <pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,42</pre>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -52,6 +52,16 @@
<strong>Site</strong> <strong>Site</strong>
</div> </div>
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
<tr>
<td>Tenant</td>
<td>
{% if site.tenant %}
<a href="{{ site.tenant.get_absolute_url }}">{{ site.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr> <tr>
<td>Facility</td> <td>Facility</td>
<td>{{ site.facility }}</td> <td>{{ site.facility }}</td>

View File

@ -7,6 +7,7 @@
<div class="panel-body"> <div class="panel-body">
{% render_field form.name %} {% render_field form.name %}
{% render_field form.slug %} {% render_field form.slug %}
{% render_field form.tenant %}
{% render_field form.facility %} {% render_field form.facility %}
{% render_field form.asn %} {% render_field form.asn %}
{% render_field form.physical_address %} {% render_field form.physical_address %}

View File

@ -38,6 +38,11 @@
<td>URL-friendly name</td> <td>URL-friendly name</td>
<td>ash4-south</td> <td>ash4-south</td>
</tr> </tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Pied Piper</td>
</tr>
<tr> <tr>
<td>Facility</td> <td>Facility</td>
<td>Name of the hosting facility (optional)</td> <td>Name of the hosting facility (optional)</td>
@ -51,7 +56,7 @@
</tbody> </tbody>
</table> </table>
<h4>Example</h4> <h4>Example</h4>
<pre>ASH-4 South,ash4-south,Equinix DC6,65000</pre> <pre>ASH-4 South,ash4-south,Pied Piper,Equinix DC6,65000</pre>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -37,6 +37,7 @@
</form> </form>
</div> </div>
</div> </div>
{% include 'inc/filter_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

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

View File

@ -70,10 +70,10 @@
<td>{{ vlan.name }}</td> <td>{{ vlan.name }}</td>
</tr> </tr>
<tr> <tr>
<td>Description</td> <td>Tenant</td>
<td> <td>
{% if vlan.description %} {% if vlan.tenant %}
{{ vlan.description }} <a href="{{ vlan.tenant.get_absolute_url }}">{{ vlan.tenant }}</a>
{% else %} {% else %}
<span class="text-muted">None</span> <span class="text-muted">None</span>
{% endif %} {% endif %}
@ -89,6 +89,16 @@
<td>Role</td> <td>Role</td>
<td>{{ vlan.role }}</td> <td>{{ vlan.role }}</td>
</tr> </tr>
<tr>
<td>Description</td>
<td>
{% if vlan.description %}
{{ vlan.description }}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr> <tr>
<td>Created</td> <td>Created</td>
<td>{{ vlan.created }}</td> <td>{{ vlan.created }}</td>

View File

@ -9,7 +9,8 @@
<td><a href="{% url 'ipam:vlan' pk=vlan.pk %}">{{ vlan.vid }}</a></td> <td><a href="{% url 'ipam:vlan' pk=vlan.pk %}">{{ vlan.vid }}</a></td>
<td>{{ vlan.name }}</td> <td>{{ vlan.name }}</td>
<td>{{ vlan.site }}</td> <td>{{ vlan.site }}</td>
<td>{{ vlan.status }}</td> <td>{{ vlan.tenant }}</td>
<td>{{ vlan.get_status_display }}</td>
<td>{{ vlan.role }}</td> <td>{{ vlan.role }}</td>
<td>{{ vlan.description }}</td> <td>{{ vlan.description }}</td>
</tr> </tr>

View File

@ -48,6 +48,11 @@
<td>Configured VLAN name</td> <td>Configured VLAN name</td>
<td>Cameras</td> <td>Cameras</td>
</tr> </tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Internal</td>
</tr>
<tr> <tr>
<td>Status</td> <td>Status</td>
<td>Current status</td> <td>Current status</td>
@ -66,7 +71,7 @@
</tbody> </tbody>
</table> </table>
<h4>Example</h4> <h4>Example</h4>
<pre>LAS2,Backend Network,1400,Cameras,Active,Security,Security team only</pre> <pre>LAS2,Backend Network,1400,Cameras,Internal,Active,Security,Security team only</pre>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -30,6 +30,16 @@
<td>Route Distinguisher</td> <td>Route Distinguisher</td>
<td>{{ vrf.rd }}</td> <td>{{ vrf.rd }}</td>
</tr> </tr>
<tr>
<td>Tenant</td>
<td>
{% if vrf.tenant %}
<a href="{{ vrf.tenant.get_absolute_url }}">{{ vrf.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr> <tr>
<td>Enforce Uniqueness</td> <td>Enforce Uniqueness</td>
<td> <td>

View File

@ -8,6 +8,7 @@
<tr> <tr>
<td><a href="{% url 'ipam:vrf' pk=vrf.pk %}">{{ vrf.name }}</a></td> <td><a href="{% url 'ipam:vrf' pk=vrf.pk %}">{{ vrf.name }}</a></td>
<td>{{ vrf.rd }}</td> <td>{{ vrf.rd }}</td>
<td>{{ vrf.tenant }}</td>
<td>{{ vrf.description }}</td> <td>{{ vrf.description }}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -38,6 +38,11 @@
<td>Route distinguisher</td> <td>Route distinguisher</td>
<td>65000:123456</td> <td>65000:123456</td>
</tr> </tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>ABC01</td>
</tr>
<tr> <tr>
<td>Enforce uniqueness</td> <td>Enforce uniqueness</td>
<td>Prevent duplicate prefixes/IP addresses</td> <td>Prevent duplicate prefixes/IP addresses</td>
@ -51,7 +56,7 @@
</tbody> </tbody>
</table> </table>
<h4>Example</h4> <h4>Example</h4>
<pre>Customer_ABC,65000:123456,True,Native VRF for customer ABC</pre> <pre>Customer_ABC,65000:123456,ABC01,True,Native VRF for customer ABC</pre>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -41,6 +41,7 @@
</form> </form>
</div> </div>
</div> </div>
{% include 'inc/filter_panel.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,124 @@
{% 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-7">
<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>Description</td>
<td>
{% if tenant.description %}
{{ tenant.description }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</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 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 class="col-md-5">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Stats</strong>
</div>
<div class="row panel-body">
<div class="col-md-4 text-center">
<h2><a href="{% url 'dcim:site_list' %}?tenant={{ tenant.slug }}" class="btn {% if tenant.site_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ tenant.site_count }}</a></h2>
<p>Sites</p>
</div>
<div class="col-md-4 text-center">
<h2><a href="{% url 'dcim:rack_list' %}?tenant={{ tenant.slug }}" class="btn {% if tenant.rack_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ tenant.rack_count }}</a></h2>
<p>Racks</p>
</div>
<div class="col-md-4 text-center">
<h2><a href="{% url 'dcim:device_list' %}?tenant={{ tenant.slug }}" class="btn {% if tenant.device_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ tenant.device_count }}</a></h2>
<p>Devices</p>
</div>
</div>
<div class="row panel-body">
<div class="col-md-4 text-center">
<h2><a href="{% url 'ipam:vrf_list' %}?tenant={{ tenant.slug }}" class="btn {% if tenant.vrf_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ tenant.vrf_count }}</a></h2>
<p>VRFs</p>
</div>
<div class="col-md-4 text-center">
<h2><a href="{% url 'ipam:vlan_list' %}?tenant={{ tenant.slug }}" class="btn {% if tenant.vlan_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ tenant.vlan_count }}</a></h2>
<p>VLANs</p>
</div>
<div class="col-md-4 text-center">
<h2><a href="{% url 'circuits:circuit_list' %}?tenant={{ tenant.slug }}" class="btn {% if tenant.circuit_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ tenant.circuit_count }}</a></h2>
<p>Circuits</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View 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 %}

View File

@ -0,0 +1,21 @@
{% 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 %}
{% render_field form.description %}
</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 %}

View File

@ -0,0 +1,57 @@
{% 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>WIDG01</td>
</tr>
<tr>
<td>Slug</td>
<td>URL-friendly name</td>
<td>widg01</td>
</tr>
<tr>
<td>Group</td>
<td>Tenant group</td>
<td>Customers</td>
</tr>
<tr>
<td>Description</td>
<td>Long-form name or other text (optional)</td>
<td>Widgets Inc.</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>WIDG01,widg01,Customers,Widgets Inc.</pre>
</div>
</div>
{% endblock %}

View 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 %}

View 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 %}

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', 'description']
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', 'description', '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', 'description']
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,48 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.8 on 2016-07-26 21:58
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=30, unique=True)),
('slug', models.SlugField(unique=True)),
('description', models.CharField(blank=True, help_text=b'Long-form name (optional)', max_length=100)),
('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

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

@ -0,0 +1,50 @@
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=30, unique=True)
slug = models.SlugField(unique=True)
group = models.ForeignKey('TenantGroup', related_name='tenants', on_delete=models.PROTECT)
description = models.CharField(max_length=100, blank=True, help_text="Long-form name (optional)")
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,
self.description,
])

44
netbox/tenancy/tables.py Normal file
View File

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

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

110
netbox/tenancy/views.py Normal file
View File

@ -0,0 +1,110 @@
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.objects.annotate(
site_count=Count('sites', distinct=True),
rack_count=Count('racks', distinct=True),
device_count=Count('devices', distinct=True),
vrf_count=Count('vrfs', distinct=True),
vlan_count=Count('vlans', distinct=True),
circuit_count=Count('circuits', distinct=True),
), 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'