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 @@
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 @@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 @@
Tenant | ++ {% if site.tenant %} + {{ site.tenant }} + {% else %} + None + {% endif %} + | +||
Facility | {{ site.facility }} | 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 @@URL-friendly name | ash4-south |
Tenant | +Name of tenant (optional) | +Pied Piper | +|
Facility | Name of the hosting facility (optional) | @@ -51,7 +56,7 @@
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 @@
Sensitive data (such as passwords) which has been stored securely
-Sensitive data (such as passwords) which has been stored securely
+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 @@
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 @@
Group | ++ {{ tenant.group }} + | +
Description | ++ {% if tenant.description %} + {{ tenant.description }} + {% else %} + N/A + {% endif %} + | +
Created | +{{ tenant.created }} | +
Last Updated | +{{ tenant.last_updated }} | +
Sites
+Racks
+Devices
+VRFs
+VLANs
+Circuits
+Field | +Description | +Example | +
---|---|---|
Name | +Tenant name | +WIDG01 | +
Slug | +URL-friendly name | +widg01 | +
Group | +Tenant group | +Customers | +
Description | +Long-form name or other text (optional) | +Widgets Inc. | +
WIDG01,widg01,Customers,Widgets Inc.+