From fa2ccc1c18643f65892e0d5c706a11e5ac18992a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Jul 2016 14:58:37 -0400 Subject: [PATCH 01/16] Initial multitenancy implementation --- docs/data-model/tenancy.md | 9 ++ netbox/netbox/settings.py | 3 +- netbox/netbox/urls.py | 2 + netbox/netbox/views.py | 6 +- netbox/templates/_base.html | 27 +++-- netbox/templates/home.html | 42 ++++--- netbox/templates/tenancy/tenant.html | 81 ++++++++++++++ .../templates/tenancy/tenant_bulk_edit.html | 13 +++ netbox/templates/tenancy/tenant_edit.html | 20 ++++ netbox/templates/tenancy/tenant_import.html | 52 +++++++++ netbox/templates/tenancy/tenant_list.html | 42 +++++++ .../templates/tenancy/tenantgroup_list.html | 21 ++++ netbox/tenancy/__init__.py | 0 netbox/tenancy/admin.py | 23 ++++ netbox/tenancy/api/__init__.py | 0 netbox/tenancy/api/serializers.py | 38 +++++++ netbox/tenancy/api/urls.py | 16 +++ netbox/tenancy/api/views.py | 39 +++++++ netbox/tenancy/apps.py | 5 + netbox/tenancy/filters.py | 29 +++++ netbox/tenancy/forms.py | 61 +++++++++++ netbox/tenancy/migrations/0001_initial.py | 47 ++++++++ netbox/tenancy/migrations/__init__.py | 0 netbox/tenancy/models.py | 48 ++++++++ netbox/tenancy/tables.py | 43 ++++++++ netbox/tenancy/urls.py | 24 ++++ netbox/tenancy/views.py | 103 ++++++++++++++++++ 27 files changed, 768 insertions(+), 26 deletions(-) create mode 100644 docs/data-model/tenancy.md create mode 100644 netbox/templates/tenancy/tenant.html create mode 100644 netbox/templates/tenancy/tenant_bulk_edit.html create mode 100644 netbox/templates/tenancy/tenant_edit.html create mode 100644 netbox/templates/tenancy/tenant_import.html create mode 100644 netbox/templates/tenancy/tenant_list.html create mode 100644 netbox/templates/tenancy/tenantgroup_list.html create mode 100644 netbox/tenancy/__init__.py create mode 100644 netbox/tenancy/admin.py create mode 100644 netbox/tenancy/api/__init__.py create mode 100644 netbox/tenancy/api/serializers.py create mode 100644 netbox/tenancy/api/urls.py create mode 100644 netbox/tenancy/api/views.py create mode 100644 netbox/tenancy/apps.py create mode 100644 netbox/tenancy/filters.py create mode 100644 netbox/tenancy/forms.py create mode 100644 netbox/tenancy/migrations/0001_initial.py create mode 100644 netbox/tenancy/migrations/__init__.py create mode 100644 netbox/tenancy/models.py create mode 100644 netbox/tenancy/tables.py create mode 100644 netbox/tenancy/urls.py create mode 100644 netbox/tenancy/views.py 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/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 @@ {% 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 %} From 6f686283771d104db91c8b6d44dcb88c46a0489b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Jul 2016 17:10:11 -0400 Subject: [PATCH 05/16] Added tenant to circuit bulk editing; enabled filtering of circuits by tenant --- netbox/circuits/filters.py | 12 ++++++++++++ netbox/circuits/forms.py | 8 ++++++++ netbox/circuits/views.py | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) 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 f246bf130b5..d5535ef5390 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -180,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() @@ -195,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] @@ -204,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/views.py b/netbox/circuits/views.py index db42a6db99c..3076a9141fd 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -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] From 7ca4c816c062a759093ad56e6e9f0a2f53309c2c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Jul 2016 17:16:03 -0400 Subject: [PATCH 06/16] Added related_name to tenant fields on Site, Rack, and Device --- .../migrations/0013_tenant_related_names.py | 31 +++++++++++++++++++ netbox/dcim/models.py | 6 ++-- 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 netbox/dcim/migrations/0013_tenant_related_names.py diff --git a/netbox/dcim/migrations/0013_tenant_related_names.py b/netbox/dcim/migrations/0013_tenant_related_names.py new file mode 100644 index 00000000000..f5e3bd725d4 --- /dev/null +++ b/netbox/dcim/migrations/0013_tenant_related_names.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-07-26 21:15 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0012_site_rack_device_add_tenant'), + ] + + operations = [ + migrations.AlterField( + 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.AlterField( + 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.AlterField( + 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 aab81806f89..39d075a947a 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -153,7 +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, on_delete=models.PROTECT) + 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) @@ -240,7 +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, on_delete=models.PROTECT) + 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) @@ -636,7 +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, 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') From faa12abc70efa8a05c6166fd3066dd91f6644351 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Jul 2016 17:28:46 -0400 Subject: [PATCH 07/16] Enabled filtering of sites, racks, and devices by tenant --- netbox/dcim/filters.py | 34 ++++++++++++++++++++++++++++ netbox/dcim/forms.py | 24 ++++++++++++++++++++ netbox/dcim/views.py | 1 + netbox/templates/dcim/site_list.html | 1 + 4 files changed, 60 insertions(+) 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 c755a55a00a..9658de907df 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -76,6 +76,16 @@ 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 # @@ -181,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})) # @@ -542,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] @@ -559,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/views.py b/netbox/dcim/views.py index 0d103497486..dc09403b388 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -63,6 +63,7 @@ def expand_pattern(string): class SiteListView(ObjectListView): queryset = Site.objects.select_related('tenant') filter = filters.SiteFilter + filter_form = forms.SiteFilterForm table = tables.SiteTable template_name = 'dcim/site_list.html' 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 %} From 27c21237ff728439e871514c3120b390226011c6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Jul 2016 17:44:32 -0400 Subject: [PATCH 08/16] Added description to Tenant model --- netbox/templates/tenancy/tenant.html | 10 ++++++++ netbox/tenancy/forms.py | 4 +-- .../migrations/0002_add_tenant_description.py | 25 +++++++++++++++++++ netbox/tenancy/models.py | 3 ++- netbox/tenancy/tables.py | 3 ++- 5 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 netbox/tenancy/migrations/0002_add_tenant_description.py diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 780b63d8e86..90bccd99cae 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -52,6 +52,16 @@

{{ tenant }}

{{ tenant.group }} + + Description + + {% if tenant.description %} + {{ tenant.description }} + {% else %} + N/A + {% endif %} + + Created {{ tenant.created }} diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index a8095623874..2661f910186 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -30,7 +30,7 @@ class TenantForm(forms.ModelForm, BootstrapMixin): class Meta: model = Tenant - fields = ['name', 'slug', 'group', 'comments'] + fields = ['name', 'slug', 'group', 'description', 'comments'] class TenantFromCSVForm(forms.ModelForm): @@ -39,7 +39,7 @@ class TenantFromCSVForm(forms.ModelForm): class Meta: model = Tenant - fields = ['name', 'slug', 'group', 'comments'] + fields = ['name', 'slug', 'group', 'description', 'comments'] class TenantImportForm(BulkImportForm, BootstrapMixin): diff --git a/netbox/tenancy/migrations/0002_add_tenant_description.py b/netbox/tenancy/migrations/0002_add_tenant_description.py new file mode 100644 index 00000000000..4dea3ac38e8 --- /dev/null +++ b/netbox/tenancy/migrations/0002_add_tenant_description.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-07-26 21:44 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='tenant', + name='description', + field=models.CharField(blank=True, help_text=b'Long-form name (optional)', max_length=100), + ), + migrations.AlterField( + model_name='tenant', + name='name', + field=models.CharField(max_length=30, unique=True), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 26d92b79d79..748a68ea566 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -26,9 +26,10 @@ class Tenant(CreatedUpdatedModel): A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal department. """ - name = models.CharField(max_length=50, unique=True) + 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: diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 34496f4461e..d6e27174717 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -37,7 +37,8 @@ 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') + fields = ('pk', 'name', 'group', 'description') From 41b2b7dbf638e5efe56919726726141fff9a3f3b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Jul 2016 17:47:40 -0400 Subject: [PATCH 09/16] Fixed Tenant import --- netbox/templates/tenancy/tenant_import.html | 11 ++++++++--- netbox/tenancy/forms.py | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/netbox/templates/tenancy/tenant_import.html b/netbox/templates/tenancy/tenant_import.html index 9d05fa8d7fb..ef76cacb1c6 100644 --- a/netbox/templates/tenancy/tenant_import.html +++ b/netbox/templates/tenancy/tenant_import.html @@ -31,22 +31,27 @@

CSV Format

Name Tenant name - Widgets Inc. + WIDG01 Slug URL-friendly name - widgets-inc + widg01 Group Tenant group Customers + + Description + Long-form name or other text (optional) + Widgets Inc. +

Example

-
Widgets Inc.,widgets-inc,Customers
+
WIDG01,widg01,Customers,Widgets Inc.
{% endblock %} diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 2661f910186..14ffe7ef4b8 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -39,7 +39,7 @@ class TenantFromCSVForm(forms.ModelForm): class Meta: model = Tenant - fields = ['name', 'slug', 'group', 'description', 'comments'] + fields = ['name', 'slug', 'group', 'description'] class TenantImportForm(BulkImportForm, BootstrapMixin): From 2236d2f941ec2c6df2eab5b3c0e99eb1d5ada915 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Jul 2016 17:49:41 -0400 Subject: [PATCH 10/16] Fixed tenant assignment on bulk edit of racks, devices --- netbox/dcim/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index dc09403b388..00c462d9e90 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -201,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] @@ -633,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] From 65b008a493df0c9501422b15106cee35aff7ab60 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Jul 2016 18:01:01 -0400 Subject: [PATCH 11/16] Cleaned up migrations --- .../migrations/0004_circuit_add_tenant.py | 2 +- .../0012_site_rack_device_add_tenant.py | 8 ++--- .../migrations/0013_tenant_related_names.py | 31 ------------------- netbox/tenancy/migrations/0001_initial.py | 5 +-- .../migrations/0002_add_tenant_description.py | 25 --------------- 5 files changed, 8 insertions(+), 63 deletions(-) delete mode 100644 netbox/dcim/migrations/0013_tenant_related_names.py delete mode 100644 netbox/tenancy/migrations/0002_add_tenant_description.py diff --git a/netbox/circuits/migrations/0004_circuit_add_tenant.py b/netbox/circuits/migrations/0004_circuit_add_tenant.py index 87988bb4304..641b13afde8 100644 --- a/netbox/circuits/migrations/0004_circuit_add_tenant.py +++ b/netbox/circuits/migrations/0004_circuit_add_tenant.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.9.8 on 2016-07-26 19:29 +# Generated by Django 1.9.8 on 2016-07-26 21:59 from __future__ import unicode_literals from django.db import migrations, models diff --git a/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py b/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py index f1092b4fa0b..8dcf8f81a5f 100644 --- a/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py +++ b/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.9.8 on 2016-07-26 20:06 +# Generated by Django 1.9.8 on 2016-07-26 21:59 from __future__ import unicode_literals from django.db import migrations, models @@ -17,16 +17,16 @@ class Migration(migrations.Migration): migrations.AddField( model_name='device', name='tenant', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='tenancy.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, to='tenancy.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, to='tenancy.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/migrations/0013_tenant_related_names.py b/netbox/dcim/migrations/0013_tenant_related_names.py deleted file mode 100644 index f5e3bd725d4..00000000000 --- a/netbox/dcim/migrations/0013_tenant_related_names.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.8 on 2016-07-26 21:15 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0012_site_rack_device_add_tenant'), - ] - - operations = [ - migrations.AlterField( - 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.AlterField( - 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.AlterField( - 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/tenancy/migrations/0001_initial.py b/netbox/tenancy/migrations/0001_initial.py index 990f78874cc..ed2f800ef53 100644 --- a/netbox/tenancy/migrations/0001_initial.py +++ b/netbox/tenancy/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.9.8 on 2016-07-26 18:15 +# Generated by Django 1.9.8 on 2016-07-26 21:58 from __future__ import unicode_literals from django.db import migrations, models @@ -20,8 +20,9 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created', models.DateField(auto_now_add=True)), ('last_updated', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=50, unique=True)), + ('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={ diff --git a/netbox/tenancy/migrations/0002_add_tenant_description.py b/netbox/tenancy/migrations/0002_add_tenant_description.py deleted file mode 100644 index 4dea3ac38e8..00000000000 --- a/netbox/tenancy/migrations/0002_add_tenant_description.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.8 on 2016-07-26 21:44 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tenancy', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='tenant', - name='description', - field=models.CharField(blank=True, help_text=b'Long-form name (optional)', max_length=100), - ), - migrations.AlterField( - model_name='tenant', - name='name', - field=models.CharField(max_length=30, unique=True), - ), - ] From 2abee211a20b241139f70bc6bd702ca01fccc38b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Jul 2016 11:29:20 -0400 Subject: [PATCH 12/16] Implemented tenancy for VRFs and VLANs --- netbox/ipam/admin.py | 13 +++++--- netbox/ipam/filters.py | 23 +++++++++++++ netbox/ipam/forms.py | 32 ++++++++++++++++--- .../migrations/0006_vrf_vlan_add_tenant.py | 27 ++++++++++++++++ netbox/ipam/models.py | 10 ++++-- netbox/ipam/tables.py | 6 ++-- netbox/ipam/views.py | 7 ++-- netbox/templates/ipam/vlan.html | 16 ++++++++-- netbox/templates/ipam/vlan_bulk_edit.html | 3 +- netbox/templates/ipam/vlan_import.html | 7 +++- netbox/templates/ipam/vrf.html | 10 ++++++ netbox/templates/ipam/vrf_bulk_edit.html | 1 + netbox/templates/ipam/vrf_import.html | 7 +++- netbox/templates/ipam/vrf_list.html | 1 + netbox/templates/tenancy/tenant_edit.html | 1 + netbox/tenancy/models.py | 1 + 16 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py 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/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..9c8f4a4b8e9 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/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_edit.html b/netbox/templates/tenancy/tenant_edit.html index cffb29510fe..3616e59668f 100644 --- a/netbox/templates/tenancy/tenant_edit.html +++ b/netbox/templates/tenancy/tenant_edit.html @@ -9,6 +9,7 @@ {% render_field form.name %} {% render_field form.slug %} {% render_field form.group %} + {% render_field form.description %}
diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 748a68ea566..72bc92cae28 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -46,4 +46,5 @@ def to_csv(self): self.name, self.slug, self.group.name, + self.description, ]) From e4960873f379dd313fbc8763280f86dcb7296fde Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Jul 2016 11:56:47 -0400 Subject: [PATCH 13/16] Added stats to tenant view --- netbox/templates/tenancy/tenant.html | 39 +++++++++++++++++++++++++--- netbox/tenancy/admin.py | 2 +- netbox/tenancy/views.py | 9 ++++++- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 90bccd99cae..c52252f638f 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -40,7 +40,7 @@

{{ tenant }}

-
+
Tenant @@ -72,8 +72,6 @@

{{ tenant }}

-
-
Comments @@ -86,6 +84,41 @@

{{ tenant }}

{% endif %}
+
+
+
+
+ Stats +
+
+ + + +
+
+ + + +
+
{% endblock %} diff --git a/netbox/tenancy/admin.py b/netbox/tenancy/admin.py index d381b88ffa9..efd0d2ac821 100644 --- a/netbox/tenancy/admin.py +++ b/netbox/tenancy/admin.py @@ -16,7 +16,7 @@ class TenantAdmin(admin.ModelAdmin): prepopulated_fields = { 'slug': ['name'], } - list_display = ['name', 'slug', 'group'] + list_display = ['name', 'slug', 'group', 'description'] def get_queryset(self, request): qs = super(TenantAdmin, self).get_queryset(request) diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index db8befedf64..0cf10aa80bd 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -50,7 +50,14 @@ class TenantListView(ObjectListView): def tenant(request, slug): - tenant = get_object_or_404(Tenant, slug=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, From 2981ead41b468ac7060260b156b3a5a9fd1a2fef Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Jul 2016 13:37:55 -0400 Subject: [PATCH 14/16] Extended IPAM API to support tenancy --- netbox/dcim/tests/test_apis.py | 6 ++++++ netbox/ipam/api/serializers.py | 7 +++++-- netbox/ipam/api/views.py | 8 ++++---- 3 files changed, 15 insertions(+), 6 deletions(-) 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/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 From 300e67388b2f8d6fd81f76065e9eebbb973221aa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Jul 2016 13:42:17 -0400 Subject: [PATCH 15/16] Tenancy-related API cleanup --- netbox/circuits/api/views.py | 4 ++-- netbox/dcim/api/views.py | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) 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/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] From 4cc84aed5a980f24dbd4236d96660c401cb94a90 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Jul 2016 13:59:18 -0400 Subject: [PATCH 16/16] PEP8 fix --- netbox/ipam/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 9c8f4a4b8e9..1fe790292ce 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -555,7 +555,7 @@ class VLANFilterForm(forms.Form, BootstrapMixin): 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})) + 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}))