Browse Source

Merged develop

Jeremy Stretch 8 years ago
parent
commit
c0152940f9

+ 4 - 0
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

+ 11 - 3
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,

+ 12 - 1
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']

+ 29 - 1
netbox/dcim/api/serializers.py

@@ -6,7 +6,7 @@ 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
@@ -14,10 +14,38 @@ 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:

+ 1 - 0
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

+ 11 - 1
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
@@ -26,6 +26,16 @@ from . import serializers
 
 
 #
+# Regions
+#
+
+class RegionViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+    queryset = Region.objects.all()
+    serializer_class = serializers.RegionSerializer
+    write_serializer_class = serializers.WritableRegionSerializer
+
+
+#
 # Sites
 #
 

+ 12 - 1
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(),

+ 46 - 10
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
 )
 
@@ -64,17 +66,32 @@ class DeviceComponentForm(BootstrapMixin, forms.Form):
 
 
 #
+# 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')
+    )
 
 
 #

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

+ 30 - 3
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
@@ -201,6 +203,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())
             })

+ 51 - 3
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 %}
+    <span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="fa fa-caret-right"></i></a>
+{% else %}
+    <span style="padding-left: {{ record.get_ancestors|length }}9px">
+{% endif %}
+    {{ record.name }}
+</span>
+"""
+
+SITE_REGION_LINK = """
+{% if record.region %}
+    <a href="{% url 'dcim:site_list' %}?region={{ record.region.slug }}">{{ record.region }}</a>
+{% else %}
+    &mdash;
+{% endif %}
+"""
+
 COLOR_LABEL = """
 <label class="label" style="background-color: #{{ record.color }}">{{ record }}</label>
 """
@@ -20,6 +38,12 @@ DEVICE_LINK = """
 </a>
 """
 
+REGION_ACTIONS = """
+{% if perms.dcim.change_region %}
+    <a href="{% url 'dcim:region_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% endif %}
+"""
+
 RACKGROUP_ACTIONS = """
 {% if perms.dcim.change_rackgroup %}
     <a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -77,6 +101,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',
+        )
 
 
 #

+ 1 - 0
netbox/dcim/tests/test_apis.py

@@ -17,6 +17,7 @@ class SiteTest(APITestCase):
         'id',
         'name',
         'slug',
+        'region',
         'tenant',
         'facility',
         'asn',

+ 6 - 0
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<pk>\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'),

+ 32 - 5
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,
 )
 
 
@@ -130,11 +130,36 @@ class ComponentDeleteView(ObjectDeleteView):
 
 
 #
+# 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')
     )

+ 4 - 2
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',)

+ 1 - 0
netbox/netbox/settings.py

@@ -104,6 +104,7 @@ INSTALLED_APPS = (
     'django.contrib.humanize',
     'debug_toolbar',
     'django_tables2',
+    'mptt',
     'rest_framework',
     'rest_framework_swagger',
     'circuits',

+ 5 - 0
netbox/templates/_base.html

@@ -37,6 +37,11 @@
                                 <li><a href="{% url 'dcim:site_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Sites</a></li>
                             {% endif %}
                             <li class="divider"></li>
+                            <li><a href="{% url 'dcim:region_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Regions</a></li>
+                            {% if perms.dcim.add_region %}
+                                <li><a href="{% url 'dcim:region_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Region</a></li>
+                            {% endif %}
+                            <li class="divider"></li>
                             <li><a href="{% url 'tenancy:tenant_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Tenants</a></li>
                             {% if perms.tenancy.add_tenant %}
                                 <li><a href="{% url 'tenancy:tenant_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Tenant</a></li>

+ 4 - 0
netbox/templates/circuits/circuit.html

@@ -66,6 +66,10 @@
                     <td>Tenant</td>
                     <td>
                         {% if circuit.tenant %}
+                            {% if circuit.tenant.group %}
+                                <a href="{{ circuit.tenant.group.get_absolute_url }}">{{ circuit.tenant.group.name }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
                             <a href="{{ circuit.tenant.get_absolute_url }}">{{ circuit.tenant }}</a>
                         {% else %}
                             <span class="text-muted">None</span>

+ 6 - 1
netbox/templates/circuits/inc/circuit_termination.html

@@ -27,6 +27,10 @@
             <tr>
                 <td>Site</td>
                 <td>
+                    {% if termination.site.region %}
+                        <a href="{{ termination.site.region.get_absolute_url }}">{{ termination.site.region }}</a>
+                        <i class="fa fa-angle-right"></i>
+                    {% endif %}
                     <a href="{% url 'dcim:site' slug=termination.site.slug %}">{{ termination.site }}</a>
                 </td>
             </tr>
@@ -34,7 +38,8 @@
                 <td>Termination</td>
                 <td>
                     {% if termination.interface %}
-                        <span><a href="{% url 'dcim:device' pk=termination.interface.device.pk %}">{{ termination.interface.device }}</a> {{ termination.interface }}</span>
+                        <a href="{% url 'dcim:device' pk=termination.interface.device.pk %}">{{ termination.interface.device }}</a>
+                        <i class="fa fa-angle-right"></i> {{ termination.interface }}
                     {% else %}
                         <span class="text-muted">Not defined</span>
                     {% endif %}

+ 24 - 12
netbox/templates/dcim/device.html

@@ -15,18 +15,12 @@
             </div>
             <table class="table table-hover panel-body attr-table">
                 <tr>
-                    <td>Tenant</td>
-                    <td>
-                        {% if device.tenant %}
-                            <a href="{{ device.tenant.get_absolute_url }}">{{ device.tenant }}</a>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
                     <td>Site</td>
                     <td>
+                        {% if device.site.region %}
+                            <a href="{{ device.site.region.get_absolute_url }}">{{ device.site.region }}</a>
+                            <i class="fa fa-angle-right"></i>
+                        {% endif %}
                         <a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a>
                     </td>
                 </tr>
@@ -34,7 +28,11 @@
                     <td>Rack</td>
                     <td>
                         {% if device.rack %}
-                            <span><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack.name }}</a>{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %}</span>
+                            {% if device.rack.group %}
+                                <a href="{{ device.rack.group.get_absolute_url }}">{{ device.rack.group.name }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
+                            <a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack.name }}</a>{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %}
                         {% else %}
                             <span class="text-muted">None</span>
                         {% endif %}
@@ -58,6 +56,20 @@
                     </td>
                 </tr>
                 <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if device.tenant %}
+                            {% if device.tenant.group %}
+                                <a href="{{ device.tenant.group.get_absolute_url }}">{{ device.tenant.group.name }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
+                            <a href="{{ device.tenant.get_absolute_url }}">{{ device.tenant }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
                     <td>Device Type</td>
                     <td>
                         <span><a href="{% url 'dcim:devicetype' pk=device.device_type.pk %}">{{ device.device_type.full_name }}</a> ({{ device.device_type.u_height }}U)</span>
@@ -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 %}
                 <form method="post">
                 {% csrf_token %}
                 <input type="hidden" name="device" value="{{ device.pk }}" />

+ 8 - 0
netbox/templates/dcim/rack.html

@@ -64,6 +64,10 @@
                 <tr>
                     <td>Site</td>
                     <td>
+                        {% if rack.site.region %}
+                            <a href="{{ rack.site.region.get_absolute_url }}">{{ rack.site.region }}</a>
+                            <i class="fa fa-angle-right"></i>
+                        {% endif %}
                         <a href="{% url 'dcim:site' slug=rack.site.slug %}">{{ rack.site }}</a>
                     </td>
                 </tr>
@@ -91,6 +95,10 @@
                     <td>Tenant</td>
                     <td>
                         {% if rack.tenant %}
+                            {% if rack.tenant.group %}
+                                <a href="{{ rack.tenant.group.get_absolute_url }}">{{ rack.tenant.group.name }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
                             <a href="{{ rack.tenant.get_absolute_url }}">{{ rack.tenant }}</a>
                         {% else %}
                             <span class="text-muted">None</span>

+ 21 - 0
netbox/templates/dcim/region_list.html

@@ -0,0 +1,21 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block title %}Regions{% endblock %}
+
+{% block content %}
+<div class="pull-right">
+    {% if perms.dcim.add_region %}
+        <a href="{% url 'dcim:region_add' %}" class="btn btn-primary">
+            <span class="fa fa-plus" aria-hidden="true"></span>
+            Add a region
+        </a>
+    {% endif %}
+</div>
+<h1>{{ block.title }}</h1>
+<div class="row">
+	<div class="col-md-12">
+        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:region_bulk_delete' %}
+    </div>
+</div>
+{% endblock %}

+ 31 - 1
netbox/templates/dcim/site.html

@@ -9,7 +9,12 @@
 <div class="row">
     <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
-            <li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
+            {% if site.region %}
+                {% for region in site.region.get_ancestors %}
+                    <li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
+                {% endfor %}
+                <li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
+            {% endif %}
             <li>{{ site }}</li>
         </ol>
     </div>
@@ -56,9 +61,27 @@
             </div>
             <table class="table table-hover panel-body attr-table">
                 <tr>
+                    <td>Region</td>
+                    <td>
+                        {% if site.region %}
+                            {% for region in site.region.get_ancestors %}
+                                <a href="{{ region.get_absolute_url }}">{{ region }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endfor %}
+                            <a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
                     <td>Tenant</td>
                     <td>
                         {% if site.tenant %}
+                            {% if site.tenant.group %}
+                                <a href="{{ site.tenant.group.get_absolute_url }}">{{ site.tenant.group.name }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
                             <a href="{{ site.tenant.get_absolute_url }}">{{ site.tenant }}</a>
                         {% else %}
                             <span class="text-muted">None</span>
@@ -85,6 +108,13 @@
                         {% endif %}
                     </td>
                 </tr>
+            </table>
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Contact Info</strong>
+            </div>
+            <table class="table table-hover panel-body attr-table">
                 <tr>
                     <td>Physical Address</td>
                     <td>

+ 1 - 0
netbox/templates/dcim/site_edit.html

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

+ 6 - 1
netbox/templates/dcim/site_import.html

@@ -39,6 +39,11 @@
 					<td>ash4-south</td>
 				</tr>
 				<tr>
+					<td>Region</td>
+					<td>Name of region (optional)</td>
+					<td>North America</td>
+				</tr>
+				<tr>
 					<td>Tenant</td>
 					<td>Name of tenant (optional)</td>
 					<td>Pied Piper</td>
@@ -71,7 +76,7 @@
 			</tbody>
 		</table>
 		<h4>Example</h4>
-		<pre>ASH-4 South,ash4-south,Pied Piper,Equinix DC6,65000,Hank Hill,+1-214-555-1234,hhill@example.com</pre>
+		<pre>ASH-4 South,ash4-south,North America,Pied Piper,Equinix DC6,65000,Hank Hill,+1-214-555-1234,hhill@example.com</pre>
 	</div>
 </div>
 {% endblock %}

+ 17 - 1
netbox/templates/ipam/prefix.html

@@ -30,8 +30,16 @@
                     <td>Tenant</td>
                     <td>
                         {% if prefix.tenant %}
+                            {% if prefix.tenant.group %}
+                                <a href="{{ prefix.tenant.group.get_absolute_url }}">{{ prefix.tenant.group.name }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
                             <a href="{{ prefix.tenant.get_absolute_url }}">{{ prefix.tenant }}</a>
                         {% elif prefix.vrf.tenant %}
+                            {% if prefix.vrf.tenant.group %}
+                                <a href="{{ prefix.vrf.tenant.group.get_absolute_url }}">{{ prefix.vrf.tenant.group.name }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
                             <a href="{{ prefix.vrf.tenant.get_absolute_url }}">{{ prefix.vrf.tenant }}</a>
                             <label class="label label-info">Inherited</label>
                         {% else %}
@@ -53,6 +61,10 @@
                     <td>Site</td>
                     <td>
                         {% if prefix.site %}
+                            {% if prefix.site.region %}
+                                <a href="{{ prefix.site.region.get_absolute_url }}">{{ prefix.site.region }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
                             <a href="{% url 'dcim:site' slug=prefix.site.slug %}">{{ prefix.site }}</a>
                         {% else %}
                             <span class="text-muted">None</span>
@@ -63,6 +75,10 @@
                     <td>VLAN</td>
                     <td>
                         {% if prefix.vlan %}
+                            {% if prefix.vlan.group %}
+                                <a href="{{ prefix.vlan.group.get_absolute_url }}">{{ prefix.vlan.group.name }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
                             <a href="{% url 'ipam:vlan' pk=prefix.vlan.pk %}">{{ prefix.vlan.display_name }}</a>
                         {% else %}
                             <span class="text-muted">None</span>
@@ -79,7 +95,7 @@
                     <td>Role</td>
                     <td>
                         {% if prefix.role %}
-                            <span>{{ prefix.role }}</span>
+                            <a href="{% url 'ipam:prefix_list' %}?role={{ prefix.role.slug }}">{{ prefix.role }}</a>
                         {% else %}
                             <span class="text-muted">None</span>
                         {% endif %}

+ 9 - 1
netbox/templates/ipam/vlan.html

@@ -57,6 +57,10 @@
                     <td>Site</td>
                     <td>
                         {% if vlan.site %}
+                            {% if vlan.site.region %}
+                                <a href="{{ vlan.site.region.get_absolute_url }}">{{ vlan.site.region }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
                             <a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a>
                         {% else %}
                             <span class="text-muted">None</span>
@@ -85,6 +89,10 @@
                     <td>Tenant</td>
                     <td>
                         {% if vlan.tenant %}
+                            {% if vlan.tenant.group %}
+                                <a href="{{ vlan.tenant.group.get_absolute_url }}">{{ vlan.tenant.group.name }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
                             <a href="{{ vlan.tenant.get_absolute_url }}">{{ vlan.tenant }}</a>
                         {% else %}
                             <span class="text-muted">None</span>
@@ -101,7 +109,7 @@
                     <td>Role</td>
                     <td>
                         {% if vlan.role %}
-                            <span>{{ vlan.role }}</span>
+                            <a href="{% url 'ipam:vlan_list' %}?role={{ vlan.role.slug }}">{{ vlan.role }}</a>
                         {% else %}
                             <span class="text-muted">None</span>
                         {% endif %}

+ 15 - 4
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

+ 1 - 0
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