diff --git a/docs/data-model/tenancy.md b/docs/data-model/tenancy.md new file mode 100644 index 00000000000..d3e8b8c29e6 --- /dev/null +++ b/docs/data-model/tenancy.md @@ -0,0 +1,9 @@ +NetBox supports the concept of individual tenants within its parent organization. Typically, these are used to represent individual customers or internal departments. + +# Tenants + +A tenant represents a discrete organization. Certain resources within NetBox can be assigned to a tenant. This makes it very convenient to track which resources are assigned to which customers, for instance. + +### Tenant Groups + +Tenants are grouped by type. For instance, you might create one group called "Customers" and one called "Acquisitions." diff --git a/netbox/circuits/admin.py b/netbox/circuits/admin.py index 090ad7d3993..31caecdec03 100644 --- a/netbox/circuits/admin.py +++ b/netbox/circuits/admin.py @@ -21,10 +21,11 @@ class CircuitTypeAdmin(admin.ModelAdmin): @admin.register(Circuit) class CircuitAdmin(admin.ModelAdmin): - list_display = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id'] - list_filter = ['provider'] + list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'commit_rate', + 'xconnect_id'] + list_filter = ['provider', 'type', 'tenant'] exclude = ['interface'] def get_queryset(self, request): qs = super(CircuitAdmin, self).get_queryset(request) - return qs.select_related('provider', 'type', 'site') + return qs.select_related('provider', 'type', 'tenant', 'site') diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index ebedda878f8..0efedd04c1c 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -2,6 +2,7 @@ from circuits.models import Provider, CircuitType, Circuit from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer +from tenancy.api.serializers import TenantNestedSerializer # @@ -45,13 +46,14 @@ class Meta(CircuitTypeSerializer.Meta): class CircuitSerializer(serializers.ModelSerializer): provider = ProviderNestedSerializer() type = CircuitTypeNestedSerializer() + tenant = TenantNestedSerializer() site = SiteNestedSerializer() interface = InterfaceNestedSerializer() class Meta: model = Circuit - fields = ['id', 'cid', 'provider', 'type', 'site', 'interface', 'install_date', 'port_speed', 'commit_rate', - 'xconnect_id', 'comments'] + fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed', + 'commit_rate', 'xconnect_id', 'comments'] class CircuitNestedSerializer(CircuitSerializer): diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index d2d36830258..74cc6656d47 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -42,7 +42,7 @@ class CircuitListView(generics.ListAPIView): """ 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 filter_class = CircuitFilter @@ -51,5 +51,5 @@ class CircuitDetailView(generics.RetrieveAPIView): """ 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 diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index fc6628e87e3..59ff6ca4c4e 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -3,6 +3,7 @@ from django.db.models import Q from dcim.models import Site +from tenancy.models import Tenant from .models import Provider, Circuit, CircuitType @@ -62,6 +63,17 @@ class CircuitFilter(django_filters.FilterSet): to_field_name='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( name='site', queryset=Site.objects.all(), diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 263225aaea3..d5535ef5390 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -2,6 +2,7 @@ from django.db.models import Count from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL +from tenancy.models import Tenant from utilities.forms import ( APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField, ) @@ -99,7 +100,7 @@ class CircuitForm(forms.ModelForm, BootstrapMixin): class Meta: model = Circuit 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' ] help_texts = { @@ -160,13 +161,15 @@ class CircuitFromCSVForm(forms.ModelForm): error_messages={'invalid_choice': 'Provider not found.'}) type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name', 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', error_messages={'invalid_choice': 'Site not found.'}) class Meta: model = Circuit - fields = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id', - 'pp_info'] + fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'commit_rate', + 'xconnect_id', 'pp_info'] class CircuitImportForm(BulkImportForm, BootstrapMixin): @@ -177,6 +180,7 @@ class CircuitBulkEditForm(forms.Form, BootstrapMixin): pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput) type = forms.ModelChoiceField(queryset=CircuitType.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)') commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)') 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] +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(): site_choices = Site.objects.annotate(circuit_count=Count('circuits')) 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) provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices, 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, widget=forms.SelectMultiple(attrs={'size': 8})) diff --git a/netbox/circuits/migrations/0004_circuit_add_tenant.py b/netbox/circuits/migrations/0004_circuit_add_tenant.py new file mode 100644 index 00000000000..641b13afde8 --- /dev/null +++ b/netbox/circuits/migrations/0004_circuit_add_tenant.py @@ -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'), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index cd5d760b318..15dbea21649 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -3,6 +3,7 @@ from dcim.fields import ASNField from dcim.models import Site, Interface +from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel @@ -66,6 +67,7 @@ class Circuit(CreatedUpdatedModel): cid = models.CharField(max_length=50, verbose_name='Circuit ID') provider = models.ForeignKey('Provider', 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) interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True) install_date = models.DateField(blank=True, null=True, verbose_name='Date installed') @@ -90,6 +92,7 @@ def to_csv(self): self.cid, self.provider.name, self.type.name, + self.tenant.name if self.tenant else '', self.site.name, self.install_date.isoformat() if self.install_date else '', str(self.port_speed), diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index c13727cb775..0fa236fac5e 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -53,10 +53,11 @@ class CircuitTable(BaseTable): cid = tables.LinkColumn('circuits:circuit', args=[Accessor('pk')], verbose_name='ID') type = tables.Column(verbose_name='Type') 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') port_speed_human = tables.Column(verbose_name='Port Speed') commit_rate_human = tables.Column(verbose_name='Commit Rate') class Meta(BaseTable.Meta): 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') diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 28314639627..3076a9141fd 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -109,7 +109,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class CircuitListView(ObjectListView): - queryset = Circuit.objects.select_related('provider', 'type', 'site') + queryset = Circuit.objects.select_related('provider', 'type', 'tenant', 'site') filter = filters.CircuitFilter filter_form = forms.CircuitFilterForm table = tables.CircuitTable @@ -159,7 +159,7 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): def update_objects(self, pk_list, form): 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]: fields_to_update[field] = form.cleaned_data[field] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9224ce2b371..7a6693c375a 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,6 +6,7 @@ DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site, ) +from tenancy.api.serializers import TenantNestedSerializer # @@ -13,10 +14,11 @@ # class SiteSerializer(serializers.ModelSerializer): + tenant = TenantNestedSerializer() class Meta: 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'] @@ -52,10 +54,11 @@ class Meta(SiteSerializer.Meta): class RackSerializer(serializers.ModelSerializer): site = SiteNestedSerializer() group = RackGroupNestedSerializer() + tenant = TenantNestedSerializer() class Meta: 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): @@ -69,8 +72,8 @@ class RackDetailSerializer(RackSerializer): rear_units = serializers.SerializerMethodField() class Meta(RackSerializer.Meta): - fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments', 'front_units', - 'rear_units'] + fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'u_height', 'comments', + 'front_units', 'rear_units'] def get_front_units(self, obj): units = obj.get_rack_units(face=RACK_FACE_FRONT) @@ -218,6 +221,7 @@ class Meta: class DeviceSerializer(serializers.ModelSerializer): device_type = DeviceTypeNestedSerializer() device_role = DeviceRoleNestedSerializer() + tenant = TenantNestedSerializer() platform = PlatformNestedSerializer() rack = RackNestedSerializer() primary_ip = DeviceIPAddressNestedSerializer() @@ -227,8 +231,8 @@ class DeviceSerializer(serializers.ModelSerializer): class Meta: model = Device - fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'platform', 'serial', 'rack', 'position', - 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments'] + fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'rack', + 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments'] def get_parent_device(self, obj): try: diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 8eac377f2d5..3775eda5210 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -27,7 +27,7 @@ class SiteListView(generics.ListAPIView): """ List all sites """ - queryset = Site.objects.all() + queryset = Site.objects.select_related('tenant') serializer_class = serializers.SiteSerializer @@ -35,7 +35,7 @@ class SiteDetailView(generics.RetrieveAPIView): """ Retrieve a single site """ - queryset = Site.objects.all() + queryset = Site.objects.select_related('tenant') serializer_class = serializers.SiteSerializer @@ -68,7 +68,7 @@ class RackListView(generics.ListAPIView): """ List racks (filterable) """ - queryset = Rack.objects.select_related('site') + queryset = Rack.objects.select_related('site', 'tenant') serializer_class = serializers.RackSerializer filter_class = filters.RackFilter @@ -77,7 +77,7 @@ class RackDetailView(generics.RetrieveAPIView): """ Retrieve a single rack """ - queryset = Rack.objects.select_related('site') + queryset = Rack.objects.select_related('site', 'tenant') serializer_class = serializers.RackDetailSerializer @@ -193,8 +193,9 @@ class DeviceListView(generics.ListAPIView): """ List devices (filterable) """ - queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'platform', 'rack__site')\ - .prefetch_related('primary_ip4__nat_outside', 'primary_ip6__nat_outside') + queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform', + 'rack__site').prefetch_related('primary_ip4__nat_outside', + 'primary_ip6__nat_outside') serializer_class = serializers.DeviceSerializer filter_class = filters.DeviceFilter renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer] diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 0a4b14150d6..08f0d671e94 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -6,6 +6,7 @@ ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site, ) +from tenancy.models import Tenant class SiteFilter(django_filters.FilterSet): @@ -13,6 +14,17 @@ class SiteFilter(django_filters.FilterSet): action='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: model = Site @@ -74,6 +86,17 @@ class RackFilter(django_filters.FilterSet): to_field_name='slug', 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: model = Rack @@ -143,6 +166,17 @@ class DeviceFilter(django_filters.FilterSet): to_field_name='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( name='device_type', queryset=DeviceType.objects.all(), diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 7befaf78a29..9658de907df 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -4,6 +4,7 @@ from django.db.models import Count, Q from ipam.models import IPAddress +from tenancy.models import Tenant from utilities.forms import ( APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, @@ -48,7 +49,7 @@ class SiteForm(forms.ModelForm, BootstrapMixin): class Meta: model = Site - fields = ['name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments'] + fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments'] widgets = { 'physical_address': SmallTextarea(attrs={'rows': 3}), 'shipping_address': SmallTextarea(attrs={'rows': 3}), @@ -63,16 +64,28 @@ class Meta: 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: model = Site - fields = ['name', 'slug', 'facility', 'asn'] + fields = ['name', 'slug', 'tenant', 'facility', 'asn'] class SiteImportForm(BulkImportForm, BootstrapMixin): 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 # @@ -107,7 +120,7 @@ class RackForm(forms.ModelForm, BootstrapMixin): class Meta: model = Rack - fields = ['site', 'group', 'name', 'facility_id', 'u_height', 'comments'] + fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'u_height', 'comments'] help_texts = { 'site': "The site at which the rack exists", 'name': "Organizational rack name", @@ -135,10 +148,12 @@ class RackFromCSVForm(forms.ModelForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={'invalid_choice': 'Site not found.'}) 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: model = Rack - fields = ['site', 'group_name', 'name', 'facility_id', 'u_height'] + fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'u_height'] def clean(self): @@ -161,6 +176,7 @@ class RackBulkEditForm(forms.Form, BootstrapMixin): pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.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)') comments = CommentField() @@ -175,11 +191,18 @@ def rack_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): site = forms.MultipleChoiceField(required=False, choices=rack_site_choices, widget=forms.SelectMultiple(attrs={'size': 8})) group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group', 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: model = DeviceType - fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', - 'is_network_device', 'subdevice_role'] + fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', + 'is_pdu', 'is_network_device', 'subdevice_role'] class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin): @@ -324,7 +347,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin): class Meta: 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'] help_texts = { 'device_role': "The function this device serves", @@ -410,6 +433,8 @@ def __init__(self, *args, **kwargs): class BaseDeviceFromCSVForm(forms.ModelForm): device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name', 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', error_messages={'invalid_choice': 'Invalid manufacturer.'}) model_name = forms.CharField() @@ -441,8 +466,8 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm): face = forms.CharField(required=False) class Meta(BaseDeviceFromCSVForm.Meta): - fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'site', 'rack_name', - 'position', 'face'] + fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'site', + 'rack_name', 'position', 'face'] def clean(self): @@ -477,7 +502,7 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm): device_bay_name = forms.CharField(required=False) 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'] def clean(self): @@ -512,6 +537,7 @@ class DeviceBulkEditForm(forms.Form, BootstrapMixin): pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type') 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_delete = forms.BooleanField(required=False, label='Set platform to "none"') 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] +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(): 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] @@ -550,6 +581,8 @@ class DeviceFilterForm(forms.Form, BootstrapMixin): widget=forms.SelectMultiple(attrs={'size': 8})) role = forms.MultipleChoiceField(required=False, choices=device_role_choices, 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', widget=forms.SelectMultiple(attrs={'size': 8})) platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices) diff --git a/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py b/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py new file mode 100644 index 00000000000..8dcf8f81a5f --- /dev/null +++ b/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py @@ -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'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index fc77e318b72..39d075a947a 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -8,6 +8,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist from extras.rpc import RPC_CLIENTS +from tenancy.models import Tenant from utilities.fields import NullableCharField from utilities.managers import NaturalOrderByManager from utilities.models import CreatedUpdatedModel @@ -152,6 +153,7 @@ class Site(CreatedUpdatedModel): """ name = models.CharField(max_length=50, 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) asn = ASNField(blank=True, null=True, verbose_name='ASN') physical_address = models.CharField(max_length=200, blank=True) @@ -173,6 +175,7 @@ def to_csv(self): return ','.join([ self.name, self.slug, + self.tenant.name if self.tenant else '', self.facility, str(self.asn), ]) @@ -237,6 +240,7 @@ class Rack(CreatedUpdatedModel): 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) 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)') comments = models.TextField(blank=True) @@ -272,6 +276,7 @@ def to_csv(self): self.group.name if self.group else '', self.name, self.facility_id or '', + self.tenant.name if self.tenant else '', str(self.u_height), ]) @@ -631,6 +636,7 @@ class Device(CreatedUpdatedModel): """ device_type = models.ForeignKey('DeviceType', related_name='instances', 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) name = NullableCharField(max_length=50, blank=True, null=True, unique=True) serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') @@ -724,6 +730,7 @@ def to_csv(self): return ','.join([ self.name or '', self.device_role.name, + self.tenant.name if self.tenant else '', self.device_type.manufacturer.name, self.device_type.model, self.platform.name if self.platform else '', diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 373a2e0c0cf..75ad3fb72f1 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -61,6 +61,7 @@ class SiteTable(BaseTable): name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name') facility = tables.Column(verbose_name='Facility') + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') asn = tables.Column(verbose_name='ASN') 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') @@ -70,7 +71,7 @@ class SiteTable(BaseTable): class Meta(BaseTable.Meta): 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') @@ -101,14 +102,16 @@ class RackTable(BaseTable): site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') 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') - 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') class Meta(BaseTable.Meta): 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): @@ -116,11 +119,12 @@ class RackImportTable(BaseTable): site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') 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)') class Meta(BaseTable.Meta): 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() status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_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') rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack') device_role = tables.Column(verbose_name='Role') @@ -268,11 +273,12 @@ class DeviceTable(BaseTable): class Meta(BaseTable.Meta): 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): 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') rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack') position = tables.Column(verbose_name='Position') @@ -281,7 +287,7 @@ class DeviceImportTable(BaseTable): class Meta(BaseTable.Meta): model = Device - fields = ('name', 'site', 'rack', 'position', 'device_role', 'device_type') + fields = ('name', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type') empty_text = False diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index 22128ba29d7..8b0d8ca53f1 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/netbox/dcim/tests/test_apis.py @@ -15,6 +15,7 @@ class SiteTest(APITestCase): 'id', 'name', 'slug', + 'tenant', 'facility', 'asn', 'physical_address', @@ -40,6 +41,7 @@ class SiteTest(APITestCase): 'display_name', 'site', 'group', + 'tenant', 'u_height', 'comments' ] @@ -115,6 +117,7 @@ class RackTest(APITestCase): 'display_name', 'site', 'group', + 'tenant', 'u_height', 'comments' ] @@ -126,6 +129,7 @@ class RackTest(APITestCase): 'display_name', 'site', 'group', + 'tenant', 'u_height', 'comments', 'front_units', @@ -311,6 +315,7 @@ class DeviceTest(APITestCase): 'display_name', 'device_type', 'device_role', + 'tenant', 'platform', 'serial', 'rack', @@ -388,6 +393,7 @@ def test_get_list_flat(self, endpoint='/api/dcim/devices/?format=json_flat'): 'rack_name', 'serial', 'status', + 'tenant', ] response = self.client.get(endpoint) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 464418ac840..00c462d9e90 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -61,8 +61,9 @@ def expand_pattern(string): # class SiteListView(ObjectListView): - queryset = Site.objects.all() + queryset = Site.objects.select_related('tenant') filter = filters.SiteFilter + filter_form = forms.SiteFilterForm table = tables.SiteTable template_name = 'dcim/site_list.html' @@ -200,7 +201,7 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView): def update_objects(self, pk_list, form): 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]: fields_to_update[field] = form.cleaned_data[field] @@ -632,7 +633,7 @@ def update_objects(self, pk_list, form): if form.cleaned_data['status']: status = form.cleaned_data['status'] 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]: fields_to_update[field] = form.cleaned_data[field] diff --git a/netbox/ipam/admin.py b/netbox/ipam/admin.py index 8668aeb77d9..5ab79b92d2e 100644 --- a/netbox/ipam/admin.py +++ b/netbox/ipam/admin.py @@ -7,7 +7,12 @@ @admin.register(VRF) 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) @@ -67,10 +72,10 @@ class VLANGroupAdmin(admin.ModelAdmin): @admin.register(VLAN) class VLANAdmin(admin.ModelAdmin): - list_display = ['site', 'vid', 'name', 'status', 'role'] - list_filter = ['site', 'status', 'role'] + list_display = ['site', 'vid', 'name', 'tenant', 'status', 'role'] + list_filter = ['site', 'tenant', 'status', 'role'] search_fields = ['vid', 'name'] def get_queryset(self, request): qs = super(VLANAdmin, self).get_queryset(request) - return qs.select_related('site', 'role') + return qs.select_related('site', 'tenant', 'role') diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index bda8b1076c9..bdcab381c98 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -2,6 +2,7 @@ from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup +from tenancy.api.serializers import TenantNestedSerializer # @@ -9,10 +10,11 @@ # class VRFSerializer(serializers.ModelSerializer): + tenant = TenantNestedSerializer() class Meta: model = VRF - fields = ['id', 'name', 'rd', 'enforce_unique', 'description'] + fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description'] class VRFNestedSerializer(VRFSerializer): @@ -98,11 +100,12 @@ class Meta(VLANGroupSerializer.Meta): class VLANSerializer(serializers.ModelSerializer): site = SiteNestedSerializer() group = VLANGroupNestedSerializer() + tenant = TenantNestedSerializer() role = RoleNestedSerializer() class Meta: 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): diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 30a15e2186d..baa1050cd7f 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -14,7 +14,7 @@ class VRFListView(generics.ListAPIView): """ List all VRFs """ - queryset = VRF.objects.all() + queryset = VRF.objects.select_related('tenant') serializer_class = serializers.VRFSerializer filter_class = filters.VRFFilter @@ -23,7 +23,7 @@ class VRFDetailView(generics.RetrieveAPIView): """ Retrieve a single VRF """ - queryset = VRF.objects.all() + queryset = VRF.objects.select_related('tenant') serializer_class = serializers.VRFSerializer @@ -161,7 +161,7 @@ class VLANListView(generics.ListAPIView): """ List VLANs (filterable) """ - queryset = VLAN.objects.select_related('site', 'role') + queryset = VLAN.objects.select_related('site', 'tenant', 'role') serializer_class = serializers.VLANSerializer filter_class = filters.VLANFilter @@ -170,5 +170,5 @@ class VLANDetailView(generics.RetrieveAPIView): """ Retrieve a single VLAN """ - queryset = VLAN.objects.select_related('site', 'role') + queryset = VLAN.objects.select_related('site', 'tenant', 'role') serializer_class = serializers.VLANSerializer diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index ef87bbaa1aa..badd9a08f51 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -3,6 +3,7 @@ from netaddr.core import AddrFormatError from dcim.models import Site, Device, Interface +from tenancy.models import Tenant from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role @@ -13,6 +14,17 @@ class VRFFilter(django_filters.FilterSet): lookup_type='icontains', 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: model = VRF @@ -226,6 +238,17 @@ class VLANFilter(django_filters.FilterSet): name='vid', 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( name='role', queryset=Role.objects.all(), diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index c2286122cb6..1fe790292ce 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -4,6 +4,7 @@ from django.db.models import Count from dcim.models import Site, Device, Interface +from tenancy.models import Tenant from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField from .models import ( @@ -23,7 +24,7 @@ class VRFForm(forms.ModelForm, BootstrapMixin): class Meta: model = VRF - fields = ['name', 'rd', 'enforce_unique', 'description'] + fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] labels = { 'rd': "RD", } @@ -33,10 +34,12 @@ class Meta: 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: model = VRF - fields = ['name', 'rd', 'enforce_unique', 'description'] + fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] class VRFImportForm(BulkImportForm, BootstrapMixin): @@ -45,9 +48,20 @@ class VRFImportForm(BulkImportForm, BootstrapMixin): class VRFBulkEditForm(forms.Form, BootstrapMixin): 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) +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 # @@ -444,7 +458,7 @@ class VLANForm(forms.ModelForm, BootstrapMixin): class Meta: model = VLAN - fields = ['site', 'group', 'vid', 'name', 'description', 'status', 'role'] + fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] help_texts = { 'site': "The site at which this VLAN exists", 'group': "VLAN group (optional)", @@ -475,13 +489,15 @@ class VLANFromCSVForm(forms.ModelForm): error_messages={'invalid_choice': 'Device not found.'}) group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name', 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]) role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'Invalid role.'}) class Meta: 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): 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) site = forms.ModelChoiceField(queryset=Site.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) role = forms.ModelChoiceField(queryset=Role.objects.all(), 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] +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(): status_counts = {} 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})) group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group', 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) role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices, widget=forms.SelectMultiple(attrs={'size': 8})) diff --git a/netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py b/netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py new file mode 100644 index 00000000000..8d519261def --- /dev/null +++ b/netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py @@ -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'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 510d8410aff..d28e4eb12a3 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -7,6 +7,7 @@ from django.db import models from dcim.models import Interface +from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel from .fields import IPNetworkField, IPAddressField @@ -46,6 +47,7 @@ class VRF(CreatedUpdatedModel): """ name = models.CharField(max_length=50) 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', help_text="Prevent duplicate prefixes/IP addresses within this VRF") description = models.CharField(max_length=100, blank=True) @@ -65,6 +67,8 @@ def to_csv(self): return ','.join([ self.name, self.rd, + self.tenant.name if self.tenant else '', + 'True' if self.enforce_unique else '', self.description, ]) @@ -291,7 +295,7 @@ def get_status_class(self): 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 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. @@ -407,9 +411,10 @@ class VLAN(CreatedUpdatedModel): MaxValueValidator(4094) ]) 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) 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: ordering = ['site', 'group', 'vid'] @@ -438,6 +443,7 @@ def to_csv(self): self.group.name if self.group else '', str(self.vid), self.name, + self.tenant.name if self.tenant else '', self.get_status_display(), self.role.name if self.role else '', self.description, diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 602462a2ef8..79685410509 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -58,11 +58,12 @@ class VRFTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name') 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') class Meta(BaseTable.Meta): 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') group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') 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') role = tables.Column(verbose_name='Role') class Meta(BaseTable.Meta): model = VLAN - fields = ('pk', 'vid', 'site', 'group', 'name', 'status', 'role') + fields = ('pk', 'vid', 'site', 'group', 'name', 'tenant', 'status', 'role') diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 193adf27323..90afcfdd20d 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -36,8 +36,9 @@ def add_available_prefixes(parent, prefix_list): # class VRFListView(ObjectListView): - queryset = VRF.objects.all() + queryset = VRF.objects.select_related('tenant') filter = filters.VRFFilter + filter_form = forms.VRFFilterForm table = tables.VRFTable edit_permissions = ['ipam.change_vrf', 'ipam.delete_vrf'] template_name = 'ipam/vrf_list.html' @@ -85,7 +86,7 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): def update_objects(self, pk_list, form): fields_to_update = {} - for field in ['description']: + for field in ['tenant', 'description']: if 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): 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]: fields_to_update[field] = form.cleaned_data[field] diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 64904937805..256c6d4ce54 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ "the documentation.") -VERSION = '1.3.3-dev' +VERSION = '1.4.0-dev' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: @@ -108,6 +108,7 @@ 'ipam', 'extras', 'secrets', + 'tenancy', 'users', 'utilities', ) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 3a9d6b00a21..b67f04cfd35 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -22,6 +22,7 @@ url(r'^dcim/', include('dcim.urls', namespace='dcim')), url(r'^ipam/', include('ipam.urls', namespace='ipam')), url(r'^secrets/', include('secrets.urls', namespace='secrets')), + url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')), url(r'^profile/', include('users.urls', namespace='users')), # API @@ -29,6 +30,7 @@ url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')), url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')), url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')), + url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')), url(r'^api/docs/', include('rest_framework_swagger.urls')), url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 9fda7f92c55..5abde702e65 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -7,14 +7,18 @@ from extras.models import UserAction from ipam.models import Aggregate, Prefix, IPAddress, VLAN from secrets.models import Secret +from tenancy.models import Tenant def home(request): stats = { - # DCIM + # Organization 'site_count': Site.objects.count(), + 'tenant_count': Tenant.objects.count(), + + # DCIM 'rack_count': Rack.objects.count(), 'device_count': Device.objects.count(), 'interface_connections_count': InterfaceConnection.objects.count(), diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index fedbd43bffb..d3097fb688e 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -24,17 +24,26 @@
+
+
+ Termination +
+ + + + + + + + + + + + + + + + + + + + + +
Site + {{ circuit.site }} +
Termination + {% if circuit.interface %} + {{ circuit.interface.device }} {{ circuit.interface }} + {% else %} + Not defined + {% endif %} +
Install Date + {% if circuit.install_date %} + {{ circuit.install_date }} + {% else %} + N/A + {% endif %} +
Cross-Connect + {% if circuit.xconnect_id %} + {{ circuit.xconnect_id }} + {% else %} + N/A + {% endif %} +
Patch Panel/Port + {% if circuit.pp_info %} + {{ circuit.pp_info }} + {% else %} + N/A + {% endif %} +
+
Comments diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html index 1d5a0318ca0..c489db332b3 100644 --- a/netbox/templates/circuits/circuit_edit.html +++ b/netbox/templates/circuits/circuit_edit.html @@ -9,13 +9,19 @@ {% render_field form.provider %} {% render_field form.cid %} {% render_field form.type %} + {% render_field form.tenant %} {% render_field form.install_date %} - {% render_field form.port_speed %} - {% render_field form.commit_rate %} {% render_field form.xconnect_id %} {% render_field form.pp_info %}
+
+
Bandwidth
+
+ {% render_field form.port_speed %} + {% render_field form.commit_rate %} +
+
Termination
diff --git a/netbox/templates/circuits/circuit_import.html b/netbox/templates/circuits/circuit_import.html index 516afbc0ac1..a6cd33ecde6 100644 --- a/netbox/templates/circuits/circuit_import.html +++ b/netbox/templates/circuits/circuit_import.html @@ -43,6 +43,11 @@

CSV Format

Circuit type Transit + + Tenant + Name of tenant (optional) + Strickland Propane + Site Site name @@ -76,7 +81,7 @@

CSV Format

Example

-
IC-603122,TeliaSonera,Transit,ASH-4,2016-02-23,10000,2000,937649,PP8371 ports 13/14
+
IC-603122,TeliaSonera,Transit,Strickland Propane,ASH-4,2016-02-23,10000,2000,937649,PP8371 ports 13/14
{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 700a001bc70..2a0a7dee138 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -14,6 +14,16 @@ Device
+ + + + + {% endfor %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index b0142dcb014..726e397cdf1 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -7,6 +7,7 @@
{% render_field form.name %} {% render_field form.device_role %} + {% render_field form.tenant %}
diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html index f0d1cca6d0b..83e99e63b17 100644 --- a/netbox/templates/dcim/device_import.html +++ b/netbox/templates/dcim/device_import.html @@ -36,6 +36,11 @@

CSV Format

+ + + + + @@ -79,7 +84,7 @@

CSV Format

Tenant + {% if device.tenant %} + {{ device.tenant }} + {% else %} + None + {% endif %} +
Site diff --git a/netbox/templates/dcim/device_bulk_edit.html b/netbox/templates/dcim/device_bulk_edit.html index bd617eab945..8a9e08136a1 100644 --- a/netbox/templates/dcim/device_bulk_edit.html +++ b/netbox/templates/dcim/device_bulk_edit.html @@ -9,6 +9,7 @@ {{ device }} {{ device.device_type }} {{ device.device_role }}{{ device.tenant }} {{ device.serial }}
Functional role of device ToR Switch
TenantName of tenant (optional)Pied Piper
Device manufacturer Hardware manufacturer

Example

-
rack101_sw1,ToR Switch,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,Rear
+
rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,Rear
{% endblock %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 552716d392b..93df009b9a9 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -86,6 +86,16 @@

Rack {{ rack.name }}

{% endif %} + + Tenant + + {% if rack.tenant %} + {{ rack.tenant }} + {% else %} + None + {% endif %} + + Height {{ rack.u_height }}U diff --git a/netbox/templates/dcim/rack_bulk_edit.html b/netbox/templates/dcim/rack_bulk_edit.html index 2d58a1556f8..1085dd1e278 100644 --- a/netbox/templates/dcim/rack_bulk_edit.html +++ b/netbox/templates/dcim/rack_bulk_edit.html @@ -9,6 +9,7 @@ {{ rack }} {{ rack.facility_id }} {{ rack.site }} + {{ rack.tenant }} {{ rack.u_height }} {% endfor %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 990b2546534..ab055e8ee45 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -9,6 +9,7 @@ {% render_field form.group %} {% render_field form.name %} {% render_field form.facility_id %} + {% render_field form.tenant %} {% render_field form.u_height %} diff --git a/netbox/templates/dcim/rack_import.html b/netbox/templates/dcim/rack_import.html index f370a624281..b6e797d3944 100644 --- a/netbox/templates/dcim/rack_import.html +++ b/netbox/templates/dcim/rack_import.html @@ -48,6 +48,11 @@

CSV Format

Rack ID assigned by the facility (optional) J12.100 + + Tenant + Name of tenant (optional) + Pied Piper + Height Height in rack units @@ -56,7 +61,7 @@

CSV Format

Example

-
DC-4,Cage 1400,R101,J12.100,42
+
DC-4,Cage 1400,R101,J12.100,Pied Piper,42
{% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index a9db55e1953..28562c0883d 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -52,6 +52,16 @@

{{ site.name }}

Site + + + + diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index b951cefc18a..405f3fd5261 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -7,6 +7,7 @@
{% render_field form.name %} {% render_field form.slug %} + {% render_field form.tenant %} {% render_field form.facility %} {% render_field form.asn %} {% render_field form.physical_address %} diff --git a/netbox/templates/dcim/site_import.html b/netbox/templates/dcim/site_import.html index 4c2c79fef20..030a3aaa087 100644 --- a/netbox/templates/dcim/site_import.html +++ b/netbox/templates/dcim/site_import.html @@ -38,6 +38,11 @@

CSV Format

+ + + + + @@ -51,7 +56,7 @@

CSV Format

Tenant + {% if site.tenant %} + {{ site.tenant }} + {% else %} + None + {% endif %} +
Facility {{ site.facility }}URL-friendly name ash4-south
TenantName of tenant (optional)Pied Piper
Facility Name of the hosting facility (optional)

Example

-
ASH-4 South,ash4-south,Equinix DC6,65000
+
ASH-4 South,ash4-south,Pied Piper,Equinix DC6,65000
{% endblock %} diff --git a/netbox/templates/dcim/site_list.html b/netbox/templates/dcim/site_list.html index d354104430e..0c04faa2701 100644 --- a/netbox/templates/dcim/site_list.html +++ b/netbox/templates/dcim/site_list.html @@ -37,6 +37,7 @@

Sites

+ {% include 'inc/filter_panel.html' %} {% endblock %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index cdbc6a42719..b9362b2c439 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -50,7 +50,7 @@
- DCIM + Organization
@@ -58,6 +58,18 @@

Sites

Geographic locations

+
+ {{ stats.tenant_count }} +

Tenants

+

Customers or departments

+
+
+
+
+
+ DCIM +
+
{{ stats.rack_count }}

Racks

@@ -79,20 +91,6 @@

Connections

- {% if perms.secrets %} -
-
- Secrets -
-
-
- {{ stats.secret_count }} -

Secrets

-

Sensitive data (such as passwords) which has been stored securely

-
-
-
- {% endif %}
@@ -141,6 +139,20 @@

+ {% if perms.secrets %} +
+
+ Secrets +
+
+
+ {{ stats.secret_count }} +

Secrets

+

Sensitive data (such as passwords) which has been stored securely

+
+
+
+ {% endif %}
Recent Activity diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 8adbcd1be82..cf525ff5810 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -70,10 +70,10 @@

VLAN {{ vlan.display_name }}

{{ vlan.name }} - Description + Tenant - {% if vlan.description %} - {{ vlan.description }} + {% if vlan.tenant %} + {{ vlan.tenant }} {% else %} None {% endif %} @@ -89,6 +89,16 @@

VLAN {{ vlan.display_name }}

Role {{ vlan.role }} + + Description + + {% if vlan.description %} + {{ vlan.description }} + {% else %} + None + {% endif %} + + Created {{ vlan.created }} diff --git a/netbox/templates/ipam/vlan_bulk_edit.html b/netbox/templates/ipam/vlan_bulk_edit.html index 67f98be08b4..38b01fe24c7 100644 --- a/netbox/templates/ipam/vlan_bulk_edit.html +++ b/netbox/templates/ipam/vlan_bulk_edit.html @@ -9,7 +9,8 @@ {{ vlan.vid }} {{ vlan.name }} {{ vlan.site }} - {{ vlan.status }} + {{ vlan.tenant }} + {{ vlan.get_status_display }} {{ vlan.role }} {{ vlan.description }} diff --git a/netbox/templates/ipam/vlan_import.html b/netbox/templates/ipam/vlan_import.html index affee3c184e..2ba22feb785 100644 --- a/netbox/templates/ipam/vlan_import.html +++ b/netbox/templates/ipam/vlan_import.html @@ -48,6 +48,11 @@

CSV Format

Configured VLAN name Cameras + + Tenant + Name of tenant (optional) + Internal + Status Current status @@ -66,7 +71,7 @@

CSV Format

Example

-
LAS2,Backend Network,1400,Cameras,Active,Security,Security team only
+
LAS2,Backend Network,1400,Cameras,Internal,Active,Security,Security team only
{% endblock %} diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index e3ce30c3b79..948ee1d89dd 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -30,6 +30,16 @@

{{ vrf }}

Route Distinguisher {{ vrf.rd }} + + Tenant + + {% if vrf.tenant %} + {{ vrf.tenant }} + {% else %} + None + {% endif %} + + Enforce Uniqueness diff --git a/netbox/templates/ipam/vrf_bulk_edit.html b/netbox/templates/ipam/vrf_bulk_edit.html index 0c6d83be632..344fe09054a 100644 --- a/netbox/templates/ipam/vrf_bulk_edit.html +++ b/netbox/templates/ipam/vrf_bulk_edit.html @@ -8,6 +8,7 @@ {{ vrf.name }} {{ vrf.rd }} + {{ vrf.tenant }} {{ vrf.description }} {% endfor %} diff --git a/netbox/templates/ipam/vrf_import.html b/netbox/templates/ipam/vrf_import.html index ce16181c4ad..cbdee420df3 100644 --- a/netbox/templates/ipam/vrf_import.html +++ b/netbox/templates/ipam/vrf_import.html @@ -38,6 +38,11 @@

CSV Format

Route distinguisher 65000:123456 + + Tenant + Name of tenant (optional) + ABC01 + Enforce uniqueness Prevent duplicate prefixes/IP addresses @@ -51,7 +56,7 @@

CSV Format

Example

-
Customer_ABC,65000:123456,True,Native VRF for customer ABC
+
Customer_ABC,65000:123456,ABC01,True,Native VRF for customer ABC
{% endblock %} diff --git a/netbox/templates/ipam/vrf_list.html b/netbox/templates/ipam/vrf_list.html index 641aae1d6c0..eaa03e4611e 100644 --- a/netbox/templates/ipam/vrf_list.html +++ b/netbox/templates/ipam/vrf_list.html @@ -41,6 +41,7 @@

VRFs

+ {% include 'inc/filter_panel.html' %} {% endblock %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html new file mode 100644 index 00000000000..c52252f638f --- /dev/null +++ b/netbox/templates/tenancy/tenant.html @@ -0,0 +1,124 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}{{ tenant }}{% endblock %} + +{% block content %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.tenancy.change_tenant %} + + + Edit this tenant + + {% endif %} + {% if perms.tenancy.delete_tenant %} + + + Delete this tenant + + {% endif %} +
+

{{ tenant }}

+
+
+
+
+ Tenant +
+ + + + + + + + + + + + + + + + + +
Group + {{ tenant.group }} +
Description + {% if tenant.description %} + {{ tenant.description }} + {% else %} + N/A + {% endif %} +
Created{{ tenant.created }}
Last Updated{{ tenant.last_updated }}
+
+
+
+ Comments +
+
+ {% if tenant.comments %} + {{ tenant.comments|gfm }} + {% else %} + None + {% endif %} +
+
+
+
+
+
+ Stats +
+
+ + + +
+
+ + + +
+
+
+
+{% endblock %} diff --git a/netbox/templates/tenancy/tenant_bulk_edit.html b/netbox/templates/tenancy/tenant_bulk_edit.html new file mode 100644 index 00000000000..f9bd55cf250 --- /dev/null +++ b/netbox/templates/tenancy/tenant_bulk_edit.html @@ -0,0 +1,13 @@ +{% extends 'utilities/bulk_edit_form.html' %} +{% load form_helpers %} + +{% block title %}Tenant Bulk Edit{% endblock %} + +{% block select_objects_table %} + {% for tenant in selected_objects %} + + {{ tenant }} + {{ tenant.group }} + + {% endfor %} +{% endblock %} diff --git a/netbox/templates/tenancy/tenant_edit.html b/netbox/templates/tenancy/tenant_edit.html new file mode 100644 index 00000000000..3616e59668f --- /dev/null +++ b/netbox/templates/tenancy/tenant_edit.html @@ -0,0 +1,21 @@ +{% extends 'utilities/obj_edit.html' %} +{% load static from staticfiles %} +{% load form_helpers %} + +{% block form %} +
+
Tenant
+
+ {% render_field form.name %} + {% render_field form.slug %} + {% render_field form.group %} + {% render_field form.description %} +
+
+
+
Comments
+
+ {% render_field form.comments %} +
+
+{% endblock %} diff --git a/netbox/templates/tenancy/tenant_import.html b/netbox/templates/tenancy/tenant_import.html new file mode 100644 index 00000000000..ef76cacb1c6 --- /dev/null +++ b/netbox/templates/tenancy/tenant_import.html @@ -0,0 +1,57 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}Tenant Import{% endblock %} + +{% block content %} +

Tenant Import

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ + Cancel +
+
+
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
NameTenant nameWIDG01
SlugURL-friendly namewidg01
GroupTenant groupCustomers
DescriptionLong-form name or other text (optional)Widgets Inc.
+

Example

+
WIDG01,widg01,Customers,Widgets Inc.
+
+
+{% endblock %} diff --git a/netbox/templates/tenancy/tenant_list.html b/netbox/templates/tenancy/tenant_list.html new file mode 100644 index 00000000000..2d46412a316 --- /dev/null +++ b/netbox/templates/tenancy/tenant_list.html @@ -0,0 +1,42 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}Tenants{% endblock %} + +{% block content %} +
+ {% if perms.tenancy.add_tenant %} + + + Add a tenant + + {% endif %} + {% include 'inc/export_button.html' with obj_type='tenants' %} +
+

Tenants

+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='tenancy:tenant_bulk_edit' bulk_delete_url='tenancy:tenant_bulk_delete' %} +
+
+
+
+ Search +
+
+
+
+ + + + +
+
+
+
+ {% include 'inc/filter_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/tenancy/tenantgroup_list.html b/netbox/templates/tenancy/tenantgroup_list.html new file mode 100644 index 00000000000..be270a95c3f --- /dev/null +++ b/netbox/templates/tenancy/tenantgroup_list.html @@ -0,0 +1,21 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}Tenant Groups{% endblock %} + +{% block content %} +
+ {% if perms.tenancy.add_tenantgroup %} + + + Add a tenant group + + {% endif %} +
+

Tenant Groups

+
+
+ {% include 'utilities/obj_table.html' with bulk_delete_url='tenancy:tenantgroup_bulk_delete' %} +
+
+{% endblock %} diff --git a/netbox/tenancy/__init__.py b/netbox/tenancy/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/tenancy/admin.py b/netbox/tenancy/admin.py new file mode 100644 index 00000000000..efd0d2ac821 --- /dev/null +++ b/netbox/tenancy/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin + +from .models import Tenant, TenantGroup + + +@admin.register(TenantGroup) +class TenantGroupAdmin(admin.ModelAdmin): + prepopulated_fields = { + 'slug': ['name'], + } + list_display = ['name', 'slug'] + + +@admin.register(Tenant) +class TenantAdmin(admin.ModelAdmin): + prepopulated_fields = { + 'slug': ['name'], + } + list_display = ['name', 'slug', 'group', 'description'] + + def get_queryset(self, request): + qs = super(TenantAdmin, self).get_queryset(request) + return qs.select_related('group') diff --git a/netbox/tenancy/api/__init__.py b/netbox/tenancy/api/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py new file mode 100644 index 00000000000..30a4a3ca1a8 --- /dev/null +++ b/netbox/tenancy/api/serializers.py @@ -0,0 +1,38 @@ +from rest_framework import serializers + +from tenancy.models import Tenant, TenantGroup + + +# +# Tenant groups +# + +class TenantGroupSerializer(serializers.ModelSerializer): + + class Meta: + model = TenantGroup + fields = ['id', 'name', 'slug'] + + +class TenantGroupNestedSerializer(TenantGroupSerializer): + + class Meta(TenantGroupSerializer.Meta): + pass + + +# +# Tenants +# + +class TenantSerializer(serializers.ModelSerializer): + group = TenantGroupNestedSerializer() + + class Meta: + model = Tenant + fields = ['id', 'name', 'slug', 'group', 'comments'] + + +class TenantNestedSerializer(TenantSerializer): + + class Meta(TenantSerializer.Meta): + fields = ['id', 'name', 'slug'] diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py new file mode 100644 index 00000000000..af1d1d6aa58 --- /dev/null +++ b/netbox/tenancy/api/urls.py @@ -0,0 +1,16 @@ +from django.conf.urls import url + +from .views import * + + +urlpatterns = [ + + # Tenant groups + url(r'^tenant-groups/$', TenantGroupListView.as_view(), name='tenantgroup_list'), + url(r'^tenant-groups/(?P\d+)/$', TenantGroupDetailView.as_view(), name='tenantgroup_detail'), + + # Tenants + url(r'^tenants/$', TenantListView.as_view(), name='tenant_list'), + url(r'^tenants/(?P\d+)/$', TenantDetailView.as_view(), name='tenant_detail'), + +] diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py new file mode 100644 index 00000000000..ca8ab11d7fa --- /dev/null +++ b/netbox/tenancy/api/views.py @@ -0,0 +1,39 @@ +from rest_framework import generics + +from tenancy.models import Tenant, TenantGroup +from tenancy.filters import TenantFilter + +from . import serializers + + +class TenantGroupListView(generics.ListAPIView): + """ + List all tenant groups + """ + queryset = TenantGroup.objects.all() + serializer_class = serializers.TenantGroupSerializer + + +class TenantGroupDetailView(generics.RetrieveAPIView): + """ + Retrieve a single circuit type + """ + queryset = TenantGroup.objects.all() + serializer_class = serializers.TenantGroupSerializer + + +class TenantListView(generics.ListAPIView): + """ + List tenants (filterable) + """ + queryset = Tenant.objects.select_related('group') + serializer_class = serializers.TenantSerializer + filter_class = TenantFilter + + +class TenantDetailView(generics.RetrieveAPIView): + """ + Retrieve a single tenant + """ + queryset = Tenant.objects.select_related('group') + serializer_class = serializers.TenantSerializer diff --git a/netbox/tenancy/apps.py b/netbox/tenancy/apps.py new file mode 100644 index 00000000000..53cb9a056c9 --- /dev/null +++ b/netbox/tenancy/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TenancyConfig(AppConfig): + name = 'tenancy' diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py new file mode 100644 index 00000000000..8ae273c8846 --- /dev/null +++ b/netbox/tenancy/filters.py @@ -0,0 +1,29 @@ +import django_filters + +from .models import Tenant, TenantGroup + + +class TenantFilter(django_filters.FilterSet): + q = django_filters.MethodFilter( + action='search', + label='Search', + ) + group_id = django_filters.ModelMultipleChoiceFilter( + name='group', + queryset=TenantGroup.objects.all(), + label='Group (ID)', + ) + group = django_filters.ModelMultipleChoiceFilter( + name='group', + queryset=TenantGroup.objects.all(), + to_field_name='slug', + label='Group (slug)', + ) + + class Meta: + model = Tenant + fields = ['q', 'group_id', 'group', 'name'] + + def search(self, queryset, value): + value = value.strip() + return queryset.filter(name__icontains=value) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py new file mode 100644 index 00000000000..14ffe7ef4b8 --- /dev/null +++ b/netbox/tenancy/forms.py @@ -0,0 +1,61 @@ +from django import forms +from django.db.models import Count + +from utilities.forms import ( + BootstrapMixin, BulkImportForm, CommentField, CSVDataField, SlugField, +) + +from .models import Tenant, TenantGroup + + +# +# Tenant groups +# + +class TenantGroupForm(forms.ModelForm, BootstrapMixin): + slug = SlugField() + + class Meta: + model = TenantGroup + fields = ['name', 'slug'] + + +# +# Tenants +# + +class TenantForm(forms.ModelForm, BootstrapMixin): + slug = SlugField() + comments = CommentField() + + class Meta: + model = Tenant + fields = ['name', 'slug', 'group', '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})) diff --git a/netbox/tenancy/migrations/0001_initial.py b/netbox/tenancy/migrations/0001_initial.py new file mode 100644 index 00000000000..ed2f800ef53 --- /dev/null +++ b/netbox/tenancy/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/netbox/tenancy/migrations/__init__.py b/netbox/tenancy/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py new file mode 100644 index 00000000000..72bc92cae28 --- /dev/null +++ b/netbox/tenancy/models.py @@ -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, + ]) diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py new file mode 100644 index 00000000000..d6e27174717 --- /dev/null +++ b/netbox/tenancy/tables.py @@ -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 %} + Edit +{% endif %} +""" + + +# +# Tenant groups +# + +class TenantGroupTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn(verbose_name='Name') + tenant_count = tables.Column(verbose_name='Tenants') + slug = tables.Column(verbose_name='Slug') + edit = tables.TemplateColumn(template_code=TENANTGROUP_EDIT_LINK, verbose_name='') + + class Meta(BaseTable.Meta): + model = TenantGroup + fields = ('pk', 'name', 'tenant_count', 'slug', 'edit') + + +# +# Tenants +# + +class TenantTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn('tenancy:tenant', args=[Accessor('slug')], verbose_name='Name') + group = tables.Column(verbose_name='Group') + description = tables.Column(verbose_name='Description') + + class Meta(BaseTable.Meta): + model = Tenant + fields = ('pk', 'name', 'group', 'description') diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py new file mode 100644 index 00000000000..48819f6756f --- /dev/null +++ b/netbox/tenancy/urls.py @@ -0,0 +1,24 @@ +from django.conf.urls import url + +from . import views + + +urlpatterns = [ + + # Tenant groups + url(r'^tenant-groups/$', views.TenantGroupListView.as_view(), name='tenantgroup_list'), + url(r'^tenant-groups/add/$', views.TenantGroupEditView.as_view(), name='tenantgroup_add'), + url(r'^tenant-groups/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), + url(r'^tenant-groups/(?P[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), + + # Tenants + url(r'^tenants/$', views.TenantListView.as_view(), name='tenant_list'), + url(r'^tenants/add/$', views.TenantEditView.as_view(), name='tenant_add'), + url(r'^tenants/import/$', views.TenantBulkImportView.as_view(), name='tenant_import'), + url(r'^tenants/edit/$', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), + url(r'^tenants/delete/$', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), + url(r'^tenants/(?P[\w-]+)/$', views.tenant, name='tenant'), + url(r'^tenants/(?P[\w-]+)/edit/$', views.TenantEditView.as_view(), name='tenant_edit'), + url(r'^tenants/(?P[\w-]+)/delete/$', views.TenantDeleteView.as_view(), name='tenant_delete'), + +] diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py new file mode 100644 index 00000000000..0cf10aa80bd --- /dev/null +++ b/netbox/tenancy/views.py @@ -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'