diff --git a/docs/data-model/dcim.md b/docs/data-model/dcim.md index a345312d5..932affe69 100644 --- a/docs/data-model/dcim.md +++ b/docs/data-model/dcim.md @@ -6,6 +6,10 @@ How you define sites will depend on the nature of your organization, but typical Sites can be assigned an optional facility ID to identify the actual facility housing colocated equipment. +### Regions + +Sites can optionally be arranged by geographic region. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. + --- # Racks diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 466104883..1ffda899b 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -119,9 +119,17 @@ class CircuitListView(ObjectListView): def circuit(request, pk): - circuit = get_object_or_404(Circuit, pk=pk) - termination_a = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_A).first() - termination_z = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_Z).first() + circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk) + termination_a = CircuitTermination.objects.select_related( + 'site__region', 'interface__device' + ).filter( + circuit=circuit, term_side=TERM_SIDE_A + ).first() + termination_z = CircuitTermination.objects.select_related( + 'site__region', 'interface__device' + ).filter( + circuit=circuit, term_side=TERM_SIDE_Z + ).first() return render(request, 'circuits/circuit.html', { 'circuit': circuit, diff --git a/netbox/dcim/admin.py b/netbox/dcim/admin.py index fb4c281ac..16f07dfcf 100644 --- a/netbox/dcim/admin.py +++ b/netbox/dcim/admin.py @@ -1,13 +1,24 @@ from django.contrib import admin from django.db.models import Count +from mptt.admin import MPTTModelAdmin + from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform, - PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Site, + PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, + Site, ) +@admin.register(Region) +class RegionAdmin(MPTTModelAdmin): + list_display = ['name', 'parent', 'slug'] + prepopulated_fields = { + 'slug': ['name'], + } + + @admin.register(Site) class SiteAdmin(admin.ModelAdmin): list_display = ['name', 'slug', 'facility', 'asn'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 5f5d009cb..70377b1ba 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,18 +6,46 @@ from dcim.models import ( DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, - RACK_WIDTH_CHOICES, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, + RACK_WIDTH_CHOICES, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, ) from extras.api.serializers import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceFieldSerializer +# +# Regions +# + +class NestedRegionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') + + class Meta: + model = Region + fields = ['id', 'url', 'name', 'slug'] + + +class RegionSerializer(serializers.ModelSerializer): + parent = NestedRegionSerializer() + + class Meta: + model = Region + fields = ['id', 'url', 'name', 'slug', 'parent'] + + +class WritableRegionSerializer(serializers.ModelSerializer): + + class Meta: + model = Region + fields = ['id', 'name', 'slug', 'parent'] + + # # Sites # class SiteSerializer(CustomFieldModelSerializer): + region = NestedRegionSerializer() tenant = NestedTenantSerializer() class Meta: diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index d4afdaadc..fce61454b 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -10,6 +10,7 @@ from . import views router = routers.DefaultRouter() # Sites +router.register(r'regions', views.RegionViewSet) router.register(r'sites', views.SiteViewSet) # Racks diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e87f11255..6e65ac595 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -13,7 +13,7 @@ from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, - RackRole, Site, + RackRole, Region, Site, ) from dcim import filters from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer @@ -25,6 +25,16 @@ from .exceptions import MissingFilterException from . import serializers +# +# Regions +# + +class RegionViewSet(WritableSerializerMixin, CustomFieldModelViewSet): + queryset = Region.objects.all() + serializer_class = serializers.RegionSerializer + write_serializer_class = serializers.WritableRegionSerializer + + # # Sites # diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 2b8418363..96c763bfa 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -10,7 +10,7 @@ from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Site, VIRTUAL_IFACE_TYPES, + RackReservation, RackRole, Region, Site, VIRTUAL_IFACE_TYPES, ) @@ -19,6 +19,17 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): action='search', label='Search', ) + region_id = NullableModelMultipleChoiceFilter( + name='region', + queryset=Region.objects.all(), + label='Region (ID)', + ) + region = NullableModelMultipleChoiceFilter( + name='region', + queryset=Region.objects.all(), + to_field_name='slug', + label='Region (slug)', + ) tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index efd1860e3..da9b2411b 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1,5 +1,7 @@ import re +from mptt.forms import TreeNodeChoiceField + from django import forms from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ValidationError @@ -11,7 +13,7 @@ from tenancy.models import Tenant from utilities.forms import ( APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, - SmallTextarea, SlugField, + SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField, ) from .formfields import MACAddressFormField @@ -20,7 +22,7 @@ from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, - RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, + RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, VIRTUAL_IFACE_TYPES ) @@ -63,18 +65,33 @@ class DeviceComponentForm(BootstrapMixin, forms.Form): super(DeviceComponentForm, self).__init__(*args, **kwargs) +# +# Regions +# + +class RegionForm(BootstrapMixin, forms.ModelForm): + slug = SlugField() + + class Meta: + model = Region + fields = ['parent', 'name', 'slug'] + + # # Sites # class SiteForm(BootstrapMixin, CustomFieldForm): + region = TreeNodeChoiceField(queryset=Region.objects.all()) slug = SlugField() comments = CommentField() class Meta: model = Site - fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name', - 'contact_phone', 'contact_email', 'comments'] + fields = [ + 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', + 'contact_name', 'contact_phone', 'contact_email', 'comments', + ] widgets = { 'physical_address': SmallTextarea(attrs={'rows': 3}), 'shipping_address': SmallTextarea(attrs={'rows': 3}), @@ -89,12 +106,22 @@ class SiteForm(BootstrapMixin, CustomFieldForm): class SiteFromCSVForm(forms.ModelForm): - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) + region = forms.ModelChoiceField( + Region.objects.all(), to_field_name='name', required=False, error_messages={ + 'invalid_choice': 'Tenant not found.' + } + ) + 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', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email'] + fields = [ + 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email', + ] class SiteImportForm(BootstrapMixin, BulkImportForm): @@ -103,18 +130,27 @@ class SiteImportForm(BootstrapMixin, BulkImportForm): class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput) + region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN') class Meta: - nullable_fields = ['tenant', 'asn'] + nullable_fields = ['region', 'tenant', 'asn'] class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Site q = forms.CharField(required=False, label='Search') - tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug', - null_option=(0, 'None')) + region = FilterTreeNodeMultipleChoiceField( + queryset=Region.objects.annotate(filter_count=Count('sites')), + to_field_name='slug', + required=False, + ) + tenant = FilterChoiceField( + queryset=Tenant.objects.annotate(filter_count=Count('sites')), + to_field_name='slug', + null_option=(0, 'None') + ) # diff --git a/netbox/dcim/migrations/0031_regions.py b/netbox/dcim/migrations/0031_regions.py new file mode 100644 index 000000000..d4fd4db5e --- /dev/null +++ b/netbox/dcim/migrations/0031_regions.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-28 17:14 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0030_interface_add_lag'), + ] + + operations = [ + migrations.CreateModel( + name='Region', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('lft', models.PositiveIntegerField(db_index=True, editable=False)), + ('rght', models.PositiveIntegerField(db_index=True, editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(db_index=True, editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.Region')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='site', + name='region', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sites', to='dcim.Region'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index f589c49a2..9beba5bde 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1,5 +1,7 @@ from collections import OrderedDict +from mptt.models import MPTTModel, TreeForeignKey + from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType @@ -200,6 +202,29 @@ RPC_CLIENT_CHOICES = [ ] +# +# Regions +# + +@python_2_unicode_compatible +class Region(MPTTModel): + """ + Sites can be grouped within geographic Regions. + """ + parent = TreeForeignKey('self', null=True, blank=True, related_name='children', db_index=True) + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(unique=True) + + class MPTTMeta: + order_insertion_by = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return "{}?region={}".format(reverse('dcim:site_list'), self.slug) + + # # Sites # @@ -218,7 +243,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel): """ 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) + region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL) + tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, 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) @@ -244,6 +270,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): return csv_format([ self.name, self.slug, + self.region.name if self.region else None, self.tenant.name if self.tenant else None, self.facility, self.asn, @@ -1248,8 +1275,8 @@ class Interface(models.Model): ) }) - # A LAG interface cannot have a parent LAG - if self.form_factor == IFACE_FF_LAG and self.lag is not None: + # A virtual interface cannot have a parent LAG + if self.form_factor in VIRTUAL_IFACE_TYPES and self.lag is not None: raise ValidationError({ 'lag': u"{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display()) }) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 0a891efea..a6f6dbdc2 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -6,10 +6,28 @@ from utilities.tables import BaseTable, ToggleColumn from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, Site, + RackGroup, Region, Site, ) +REGION_LINK = """ +{% if record.get_children %} + +{% else %} + +{% endif %} + {{ record.name }} + +""" + +SITE_REGION_LINK = """ +{% if record.region %} + {{ record.region }} +{% else %} + — +{% endif %} +""" + COLOR_LABEL = """ """ @@ -20,6 +38,12 @@ DEVICE_LINK = """ """ +REGION_ACTIONS = """ +{% if perms.dcim.change_region %} + +{% endif %} +""" + RACKGROUP_ACTIONS = """ {% if perms.dcim.change_rackgroup %} @@ -76,6 +100,27 @@ UTILIZATION_GRAPH = """ """ +# +# Regions +# + +class RegionTable(BaseTable): + pk = ToggleColumn() + # name = tables.LinkColumn(verbose_name='Name') + name = tables.TemplateColumn(template_code=REGION_LINK, orderable=False) + site_count = tables.Column(verbose_name='Sites') + slug = tables.Column(verbose_name='Slug') + actions = tables.TemplateColumn( + template_code=REGION_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = Region + fields = ('pk', 'name', 'site_count', 'slug', 'actions') + + # # Sites # @@ -84,6 +129,7 @@ class SiteTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name') facility = tables.Column(verbose_name='Facility') + region = tables.TemplateColumn(template_code=SITE_REGION_LINK, verbose_name='Region') 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') @@ -94,8 +140,10 @@ class SiteTable(BaseTable): class Meta(BaseTable.Meta): model = Site - fields = ('pk', 'name', 'facility', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count', - 'vlan_count', 'circuit_count') + fields = ( + 'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count', + 'vlan_count', 'circuit_count', + ) # diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index 672a4fb6f..f4d03c633 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/netbox/dcim/tests/test_apis.py @@ -17,6 +17,7 @@ class SiteTest(APITestCase): 'id', 'name', 'slug', + 'region', 'tenant', 'facility', 'asn', diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 1b337ad6e..7fde6e9b3 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -8,6 +8,12 @@ from . import views urlpatterns = [ + # Regions + url(r'^regions/$', views.RegionListView.as_view(), name='region_list'), + url(r'^regions/add/$', views.RegionEditView.as_view(), name='region_add'), + url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), + url(r'^regions/(?P\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'), + # Sites url(r'^sites/$', views.SiteListView.as_view(), name='site_list'), url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 243d97e92..02d9acd10 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -26,7 +26,7 @@ from .models import ( CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Site, + RackReservation, RackRole, Region, Site, ) @@ -129,12 +129,37 @@ class ComponentDeleteView(ObjectDeleteView): return obj.device.get_absolute_url() +# +# Regions +# + +class RegionListView(ObjectListView): + queryset = Region.objects.annotate(site_count=Count('sites')) + table = tables.RegionTable + template_name = 'dcim/region_list.html' + + +class RegionEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_region' + model = Region + form_class = forms.RegionForm + + def get_return_url(self, obj): + return reverse('dcim:region_list') + + +class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_region' + cls = Region + default_return_url = 'dcim:region_list' + + # # Sites # class SiteListView(ObjectListView): - queryset = Site.objects.select_related('tenant') + queryset = Site.objects.select_related('region', 'tenant') filter = filters.SiteFilter filter_form = forms.SiteFilterForm table = tables.SiteTable @@ -143,7 +168,7 @@ class SiteListView(ObjectListView): def site(request, slug): - site = get_object_or_404(Site, slug=slug) + site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug) stats = { 'rack_count': Rack.objects.filter(site=site).count(), 'device_count': Device.objects.filter(rack__site=site).count(), @@ -263,7 +288,7 @@ class RackListView(ObjectListView): def rack(request, pk): - rack = get_object_or_404(Rack, pk=pk) + rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk) nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True)\ .select_related('device_type__manufacturer') @@ -638,7 +663,9 @@ class DeviceListView(ObjectListView): def device(request, pk): - device = get_object_or_404(Device, pk=pk) + device = get_object_or_404(Device.objects.select_related( + 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' + ), pk=pk) console_ports = natsorted( ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name') ) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 6eef522ec..71e261dce 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -393,7 +393,9 @@ class PrefixListView(ObjectListView): def prefix(request, pk): - prefix = get_object_or_404(Prefix.objects.select_related('site', 'vlan', 'role'), pk=pk) + prefix = get_object_or_404(Prefix.objects.select_related( + 'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role' + ), pk=pk) try: aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix)) @@ -731,7 +733,7 @@ class VLANListView(ObjectListView): def vlan(request, pk): - vlan = get_object_or_404(VLAN.objects.select_related('site', 'role'), pk=pk) + vlan = get_object_or_404(VLAN.objects.select_related('site__region', 'tenant__group', 'role'), pk=pk) prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role') prefix_table = tables.PrefixBriefTable(list(prefixes)) prefix_table.exclude = ('vlan',) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c6ff21b91..db38f3de3 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -104,6 +104,7 @@ INSTALLED_APPS = ( 'django.contrib.humanize', 'debug_toolbar', 'django_tables2', + 'mptt', 'rest_framework', 'rest_framework_swagger', 'circuits', diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 4e63cf337..90bb3ad62 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -37,6 +37,11 @@
  • Import Sites
  • {% endif %}
  • +
  • Regions
  • + {% if perms.dcim.add_region %} +
  • Add a Region
  • + {% endif %} +
  • Tenants
  • {% if perms.tenancy.add_tenant %}
  • Add a Tenant
  • diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index ab54b45a5..f311ccb73 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -66,6 +66,10 @@ Tenant {% if circuit.tenant %} + {% if circuit.tenant.group %} + {{ circuit.tenant.group.name }} + + {% endif %} {{ circuit.tenant }} {% else %} None diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index ba0f8b5fe..948ccfb9a 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -27,6 +27,10 @@ Site + {% if termination.site.region %} + {{ termination.site.region }} + + {% endif %} {{ termination.site }} @@ -34,7 +38,8 @@ Termination {% if termination.interface %} - {{ termination.interface.device }} {{ termination.interface }} + {{ termination.interface.device }} + {{ termination.interface }} {% else %} Not defined {% endif %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 99e06177b..081397774 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -14,19 +14,13 @@ Device - - - - @@ -34,7 +28,11 @@ + + + + @@ -91,6 +95,10 @@
    Tenant - {% if device.tenant %} - {{ device.tenant }} - {% else %} - None - {% endif %} -
    Site + {% if device.site.region %} + {{ device.site.region }} + + {% endif %} {{ device.site }}
    Rack {% if device.rack %} - {{ device.rack.name }}{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %} + {% if device.rack.group %} + {{ device.rack.group.name }} + + {% endif %} + {{ device.rack.name }}{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %} {% else %} None {% endif %} @@ -57,6 +55,20 @@ {% endif %}
    Tenant + {% if device.tenant %} + {% if device.tenant.group %} + {{ device.tenant.group.name }} + + {% endif %} + {{ device.tenant }} + {% else %} + None + {% endif %} +
    Device Type @@ -393,7 +405,7 @@ {% endif %} {% endif %} {% if interfaces or device.device_type.is_network_device %} - {% if perms.dcim.delete_interface %} + {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
    {% csrf_token %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 37cddf213..d6529c2a4 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -64,6 +64,10 @@
    Site + {% if rack.site.region %} + {{ rack.site.region }} + + {% endif %} {{ rack.site }}
    Tenant {% if rack.tenant %} + {% if rack.tenant.group %} + {{ rack.tenant.group.name }} + + {% endif %} {{ rack.tenant }} {% else %} None diff --git a/netbox/templates/dcim/region_list.html b/netbox/templates/dcim/region_list.html new file mode 100644 index 000000000..b54201a34 --- /dev/null +++ b/netbox/templates/dcim/region_list.html @@ -0,0 +1,21 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}Regions{% endblock %} + +{% block content %} +
    + {% if perms.dcim.add_region %} + + + Add a region + + {% endif %} +
    +

    {{ block.title }}

    +
    +
    + {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:region_bulk_delete' %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index b6f9c8847..772474155 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -9,7 +9,12 @@
    @@ -55,10 +60,28 @@ Site
    + + + + +
    Region + {% if site.region %} + {% for region in site.region.get_ancestors %} + {{ region }} + + {% endfor %} + {{ site.region }} + {% else %} + None + {% endif %} +
    Tenant {% if site.tenant %} + {% if site.tenant.group %} + {{ site.tenant.group.name }} + + {% endif %} {{ site.tenant }} {% else %} None @@ -85,6 +108,13 @@ {% endif %}
    + +
    +
    + Contact Info +
    + + + + + + @@ -71,7 +76,7 @@
    Physical Address diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index d1f211adb..98f16ad25 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.region %} {% render_field form.tenant %} {% render_field form.facility %} {% render_field form.asn %} diff --git a/netbox/templates/dcim/site_import.html b/netbox/templates/dcim/site_import.html index 3018cc2f1..a7ac47ab5 100644 --- a/netbox/templates/dcim/site_import.html +++ b/netbox/templates/dcim/site_import.html @@ -38,6 +38,11 @@
    URL-friendly name ash4-south
    RegionName of region (optional)North America
    Tenant Name of tenant (optional)

    Example

    -
    ASH-4 South,ash4-south,Pied Piper,Equinix DC6,65000,Hank Hill,+1-214-555-1234,hhill@example.com
    +
    ASH-4 South,ash4-south,North America,Pied Piper,Equinix DC6,65000,Hank Hill,+1-214-555-1234,hhill@example.com
    {% endblock %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 4ad5dba05..9187b81b4 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -30,8 +30,16 @@
    Tenant {% if prefix.tenant %} + {% if prefix.tenant.group %} + {{ prefix.tenant.group.name }} + + {% endif %} {{ prefix.tenant }} {% elif prefix.vrf.tenant %} + {% if prefix.vrf.tenant.group %} + {{ prefix.vrf.tenant.group.name }} + + {% endif %} {{ prefix.vrf.tenant }} {% else %} @@ -53,6 +61,10 @@ Site {% if prefix.site %} + {% if prefix.site.region %} + {{ prefix.site.region }} + + {% endif %} {{ prefix.site }} {% else %} None @@ -63,6 +75,10 @@ VLAN {% if prefix.vlan %} + {% if prefix.vlan.group %} + {{ prefix.vlan.group.name }} + + {% endif %} {{ prefix.vlan.display_name }} {% else %} None @@ -79,7 +95,7 @@ Role {% if prefix.role %} - {{ prefix.role }} + {{ prefix.role }} {% else %} None {% endif %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 1b6ddb5dd..6c1fb07d2 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -57,6 +57,10 @@ Site {% if vlan.site %} + {% if vlan.site.region %} + {{ vlan.site.region }} + + {% endif %} {{ vlan.site }} {% else %} None @@ -85,6 +89,10 @@ Tenant {% if vlan.tenant %} + {% if vlan.tenant.group %} + {{ vlan.tenant.group.name }} + + {% endif %} {{ vlan.tenant }} {% else %} None @@ -101,7 +109,7 @@ Role {% if vlan.role %} - {{ vlan.role }} + {{ vlan.role }} {% else %} None {% endif %} diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 6eb11c208..76ce1796c 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -2,6 +2,8 @@ import csv import itertools import re +from mptt.forms import TreeNodeMultipleChoiceField + from django import forms from django.conf import settings from django.core.urlresolvers import reverse_lazy @@ -365,7 +367,7 @@ class SlugField(forms.SlugField): self.widget.attrs['slug-source'] = slug_source -class FilterChoiceField(forms.ModelMultipleChoiceField): +class FilterChoiceFieldMixin(object): iterator = forms.models.ModelChoiceIterator def __init__(self, null_option=None, *args, **kwargs): @@ -374,12 +376,13 @@ class FilterChoiceField(forms.ModelMultipleChoiceField): kwargs['required'] = False if 'widget' not in kwargs: kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) - super(FilterChoiceField, self).__init__(*args, **kwargs) + super(FilterChoiceFieldMixin, self).__init__(*args, **kwargs) def label_from_instance(self, obj): + label = super(FilterChoiceFieldMixin, self).label_from_instance(obj) if hasattr(obj, 'filter_count'): - return u'{} ({})'.format(obj, obj.filter_count) - return force_text(obj) + return u'{} ({})'.format(label, obj.filter_count) + return label def _get_choices(self): if hasattr(self, '_choices'): @@ -391,6 +394,14 @@ class FilterChoiceField(forms.ModelMultipleChoiceField): choices = property(_get_choices, forms.ChoiceField._set_choices) +class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField): + pass + + +class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField): + pass + + class LaxURLField(forms.URLField): """ Custom URLField which allows any valid URL scheme diff --git a/requirements.txt b/requirements.txt index caa678f4c..2d1c81ffa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ cryptography>=1.4 Django>=1.10 django-debug-toolbar>=1.6 django-filter==0.15.3 +django-mptt==0.8.7 django-rest-swagger==0.3.10 django-tables2>=1.2.5 djangorestframework>=3.5.0