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.
 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
 # Racks

+ 11 - 3
netbox/circuits/views.py

@@ -119,9 +119,17 @@ class CircuitListView(ObjectListView):
 
 
 def circuit(request, pk):
 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', {
     return render(request, 'circuits/circuit.html', {
         'circuit': circuit,
         'circuit': circuit,

+ 12 - 1
netbox/dcim/admin.py

@@ -1,13 +1,24 @@
 from django.contrib import admin
 from django.contrib import admin
 from django.db.models import Count
 from django.db.models import Count
 
 
+from mptt.admin import MPTTModelAdmin
+
 from .models import (
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform,
     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)
 @admin.register(Site)
 class SiteAdmin(admin.ModelAdmin):
 class SiteAdmin(admin.ModelAdmin):
     list_display = ['name', 'slug', 'facility', 'asn']
     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,
     DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface,
     InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
     InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
     PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
     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 extras.api.serializers import CustomFieldModelSerializer
 from tenancy.api.serializers import NestedTenantSerializer
 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
 # Sites
 #
 #
 
 
 class SiteSerializer(CustomFieldModelSerializer):
 class SiteSerializer(CustomFieldModelSerializer):
+    region = NestedRegionSerializer()
     tenant = NestedTenantSerializer()
     tenant = NestedTenantSerializer()
 
 
     class Meta:
     class Meta:

+ 1 - 0
netbox/dcim/api/urls.py

@@ -10,6 +10,7 @@ from . import views
 router = routers.DefaultRouter()
 router = routers.DefaultRouter()
 
 
 # Sites
 # Sites
+router.register(r'regions', views.RegionViewSet)
 router.register(r'sites', views.SiteViewSet)
 router.register(r'sites', views.SiteViewSet)
 
 
 # Racks
 # Racks

+ 11 - 1
netbox/dcim/api/views.py

@@ -13,7 +13,7 @@ from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module,
     Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
     Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
-    RackRole, Site,
+    RackRole, Region, Site,
 )
 )
 from dcim import filters
 from dcim import filters
 from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer
 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
 # Sites
 #
 #
 
 

+ 12 - 1
netbox/dcim/filters.py

@@ -10,7 +10,7 @@ from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, InterfaceTemplate,
     DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, InterfaceTemplate,
     Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
     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',
         action='search',
         label='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(
     tenant_id = NullableModelMultipleChoiceFilter(
         name='tenant',
         name='tenant',
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),

+ 46 - 10
netbox/dcim/forms.py

@@ -1,5 +1,7 @@
 import re
 import re
 
 
+from mptt.forms import TreeNodeChoiceField
+
 from django import forms
 from django import forms
 from django.contrib.postgres.forms.array import SimpleArrayField
 from django.contrib.postgres.forms.array import SimpleArrayField
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
@@ -11,7 +13,7 @@ from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField,
     APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField,
     CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled,
     CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled,
-    SmallTextarea, SlugField,
+    SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField,
 )
 )
 
 
 from .formfields import MACAddressFormField
 from .formfields import MACAddressFormField
@@ -20,7 +22,7 @@ from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
     Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
     Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
     Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
     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
     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
 # Sites
 #
 #
 
 
 class SiteForm(BootstrapMixin, CustomFieldForm):
 class SiteForm(BootstrapMixin, CustomFieldForm):
+    region = TreeNodeChoiceField(queryset=Region.objects.all())
     slug = SlugField()
     slug = SlugField()
     comments = CommentField()
     comments = CommentField()
 
 
     class Meta:
     class Meta:
         model = Site
         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 = {
         widgets = {
             'physical_address': SmallTextarea(attrs={'rows': 3}),
             'physical_address': SmallTextarea(attrs={'rows': 3}),
             'shipping_address': SmallTextarea(attrs={'rows': 3}),
             'shipping_address': SmallTextarea(attrs={'rows': 3}),
@@ -89,12 +106,22 @@ class SiteForm(BootstrapMixin, CustomFieldForm):
 
 
 
 
 class SiteFromCSVForm(forms.ModelForm):
 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:
     class Meta:
         model = Site
         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):
 class SiteImportForm(BootstrapMixin, BulkImportForm):
@@ -103,18 +130,27 @@ class SiteImportForm(BootstrapMixin, BulkImportForm):
 
 
 class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
     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)
     tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN')
     asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN')
 
 
     class Meta:
     class Meta:
-        nullable_fields = ['tenant', 'asn']
+        nullable_fields = ['region', 'tenant', 'asn']
 
 
 
 
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Site
     model = Site
     q = forms.CharField(required=False, label='Search')
     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 collections import OrderedDict
 
 
+from mptt.models import MPTTModel, TreeForeignKey
+
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 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
 # Sites
 #
 #
 
 
@@ -218,7 +243,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     """
     """
     name = models.CharField(max_length=50, unique=True)
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(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)
     facility = models.CharField(max_length=50, blank=True)
     asn = ASNField(blank=True, null=True, verbose_name='ASN')
     asn = ASNField(blank=True, null=True, verbose_name='ASN')
     physical_address = models.CharField(max_length=200, blank=True)
     physical_address = models.CharField(max_length=200, blank=True)
@@ -244,6 +270,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
         return csv_format([
         return csv_format([
             self.name,
             self.name,
             self.slug,
             self.slug,
+            self.region.name if self.region else None,
             self.tenant.name if self.tenant else None,
             self.tenant.name if self.tenant else None,
             self.facility,
             self.facility,
             self.asn,
             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({
             raise ValidationError({
                 'lag': u"{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display())
                 '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 (
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
     ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
     Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
     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 = """
 COLOR_LABEL = """
 <label class="label" style="background-color: #{{ record.color }}">{{ record }}</label>
 <label class="label" style="background-color: #{{ record.color }}">{{ record }}</label>
 """
 """
@@ -20,6 +38,12 @@ DEVICE_LINK = """
 </a>
 </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 = """
 RACKGROUP_ACTIONS = """
 {% if perms.dcim.change_rackgroup %}
 {% 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>
     <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
 # Sites
 #
 #
 
 
@@ -84,6 +129,7 @@ class SiteTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
     name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
     facility = tables.Column(verbose_name='Facility')
     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')
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     asn = tables.Column(verbose_name='ASN')
     asn = tables.Column(verbose_name='ASN')
     rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
     rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
@@ -94,8 +140,10 @@ class SiteTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Site
         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',
         'id',
         'name',
         'name',
         'slug',
         'slug',
+        'region',
         'tenant',
         'tenant',
         'facility',
         'facility',
         'asn',
         'asn',

+ 6 - 0
netbox/dcim/urls.py

@@ -8,6 +8,12 @@ from . import views
 
 
 urlpatterns = [
 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
     # Sites
     url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
     url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
     url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'),
     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,
     CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
     DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
     DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
     Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
     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
 # Sites
 #
 #
 
 
 class SiteListView(ObjectListView):
 class SiteListView(ObjectListView):
-    queryset = Site.objects.select_related('tenant')
+    queryset = Site.objects.select_related('region', 'tenant')
     filter = filters.SiteFilter
     filter = filters.SiteFilter
     filter_form = forms.SiteFilterForm
     filter_form = forms.SiteFilterForm
     table = tables.SiteTable
     table = tables.SiteTable
@@ -143,7 +168,7 @@ class SiteListView(ObjectListView):
 
 
 def site(request, slug):
 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 = {
     stats = {
         'rack_count': Rack.objects.filter(site=site).count(),
         'rack_count': Rack.objects.filter(site=site).count(),
         'device_count': Device.objects.filter(rack__site=site).count(),
         'device_count': Device.objects.filter(rack__site=site).count(),
@@ -263,7 +288,7 @@ class RackListView(ObjectListView):
 
 
 def rack(request, pk):
 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)\
     nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True)\
         .select_related('device_type__manufacturer')
         .select_related('device_type__manufacturer')
@@ -638,7 +663,9 @@ class DeviceListView(ObjectListView):
 
 
 def device(request, pk):
 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(
     console_ports = natsorted(
         ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name')
         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):
 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:
     try:
         aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix))
         aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix))
@@ -731,7 +733,7 @@ class VLANListView(ObjectListView):
 
 
 def vlan(request, pk):
 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')
     prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
     prefix_table = tables.PrefixBriefTable(list(prefixes))
     prefix_table = tables.PrefixBriefTable(list(prefixes))
     prefix_table.exclude = ('vlan',)
     prefix_table.exclude = ('vlan',)

+ 1 - 0
netbox/netbox/settings.py

@@ -104,6 +104,7 @@ INSTALLED_APPS = (
     'django.contrib.humanize',
     'django.contrib.humanize',
     'debug_toolbar',
     'debug_toolbar',
     'django_tables2',
     'django_tables2',
+    'mptt',
     'rest_framework',
     'rest_framework',
     'rest_framework_swagger',
     'rest_framework_swagger',
     'circuits',
     '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>
                                 <li><a href="{% url 'dcim:site_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Sites</a></li>
                             {% endif %}
                             {% endif %}
                             <li class="divider"></li>
                             <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>
                             <li><a href="{% url 'tenancy:tenant_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Tenants</a></li>
                             {% if perms.tenancy.add_tenant %}
                             {% 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>
                                 <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>Tenant</td>
                     <td>
                     <td>
                         {% if circuit.tenant %}
                         {% 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>
                             <a href="{{ circuit.tenant.get_absolute_url }}">{{ circuit.tenant }}</a>
                         {% else %}
                         {% else %}
                             <span class="text-muted">None</span>
                             <span class="text-muted">None</span>

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

@@ -27,6 +27,10 @@
             <tr>
             <tr>
                 <td>Site</td>
                 <td>Site</td>
                 <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>
                     <a href="{% url 'dcim:site' slug=termination.site.slug %}">{{ termination.site }}</a>
                 </td>
                 </td>
             </tr>
             </tr>
@@ -34,7 +38,8 @@
                 <td>Termination</td>
                 <td>Termination</td>
                 <td>
                 <td>
                     {% if termination.interface %}
                     {% 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 %}
                     {% else %}
                         <span class="text-muted">Not defined</span>
                         <span class="text-muted">Not defined</span>
                     {% endif %}
                     {% endif %}

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

@@ -15,18 +15,12 @@
             </div>
             </div>
             <table class="table table-hover panel-body attr-table">
             <table class="table table-hover panel-body attr-table">
                 <tr>
                 <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>Site</td>
                     <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>
                         <a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a>
                     </td>
                     </td>
                 </tr>
                 </tr>
@@ -34,7 +28,11 @@
                     <td>Rack</td>
                     <td>Rack</td>
                     <td>
                     <td>
                         {% if device.rack %}
                         {% 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 %}
                         {% else %}
                             <span class="text-muted">None</span>
                             <span class="text-muted">None</span>
                         {% endif %}
                         {% endif %}
@@ -58,6 +56,20 @@
                     </td>
                     </td>
                 </tr>
                 </tr>
                 <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>Device Type</td>
                     <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>
                         <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 %}
         {% endif %}
         {% endif %}
         {% if interfaces or device.device_type.is_network_device %}
         {% 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">
                 <form method="post">
                 {% csrf_token %}
                 {% csrf_token %}
                 <input type="hidden" name="device" value="{{ device.pk }}" />
                 <input type="hidden" name="device" value="{{ device.pk }}" />

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

@@ -64,6 +64,10 @@
                 <tr>
                 <tr>
                     <td>Site</td>
                     <td>Site</td>
                     <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>
                         <a href="{% url 'dcim:site' slug=rack.site.slug %}">{{ rack.site }}</a>
                     </td>
                     </td>
                 </tr>
                 </tr>
@@ -91,6 +95,10 @@
                     <td>Tenant</td>
                     <td>Tenant</td>
                     <td>
                     <td>
                         {% if rack.tenant %}
                         {% 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>
                             <a href="{{ rack.tenant.get_absolute_url }}">{{ rack.tenant }}</a>
                         {% else %}
                         {% else %}
                             <span class="text-muted">None</span>
                             <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="row">
     <div class="col-sm-8 col-md-9">
     <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
         <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>
             <li>{{ site }}</li>
         </ol>
         </ol>
     </div>
     </div>
@@ -56,9 +61,27 @@
             </div>
             </div>
             <table class="table table-hover panel-body attr-table">
             <table class="table table-hover panel-body attr-table">
                 <tr>
                 <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>Tenant</td>
                     <td>
                     <td>
                         {% if site.tenant %}
                         {% 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>
                             <a href="{{ site.tenant.get_absolute_url }}">{{ site.tenant }}</a>
                         {% else %}
                         {% else %}
                             <span class="text-muted">None</span>
                             <span class="text-muted">None</span>
@@ -85,6 +108,13 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </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>
                 <tr>
                     <td>Physical Address</td>
                     <td>Physical Address</td>
                     <td>
                     <td>

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

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

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

@@ -39,6 +39,11 @@
 					<td>ash4-south</td>
 					<td>ash4-south</td>
 				</tr>
 				</tr>
 				<tr>
 				<tr>
+					<td>Region</td>
+					<td>Name of region (optional)</td>
+					<td>North America</td>
+				</tr>
+				<tr>
 					<td>Tenant</td>
 					<td>Tenant</td>
 					<td>Name of tenant (optional)</td>
 					<td>Name of tenant (optional)</td>
 					<td>Pied Piper</td>
 					<td>Pied Piper</td>
@@ -71,7 +76,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<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>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

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

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

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

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

+ 15 - 4
netbox/utilities/forms.py

@@ -2,6 +2,8 @@ import csv
 import itertools
 import itertools
 import re
 import re
 
 
+from mptt.forms import TreeNodeMultipleChoiceField
+
 from django import forms
 from django import forms
 from django.conf import settings
 from django.conf import settings
 from django.core.urlresolvers import reverse_lazy
 from django.core.urlresolvers import reverse_lazy
@@ -365,7 +367,7 @@ class SlugField(forms.SlugField):
         self.widget.attrs['slug-source'] = slug_source
         self.widget.attrs['slug-source'] = slug_source
 
 
 
 
-class FilterChoiceField(forms.ModelMultipleChoiceField):
+class FilterChoiceFieldMixin(object):
     iterator = forms.models.ModelChoiceIterator
     iterator = forms.models.ModelChoiceIterator
 
 
     def __init__(self, null_option=None, *args, **kwargs):
     def __init__(self, null_option=None, *args, **kwargs):
@@ -374,12 +376,13 @@ class FilterChoiceField(forms.ModelMultipleChoiceField):
             kwargs['required'] = False
             kwargs['required'] = False
         if 'widget' not in kwargs:
         if 'widget' not in kwargs:
             kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6})
             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):
     def label_from_instance(self, obj):
+        label = super(FilterChoiceFieldMixin, self).label_from_instance(obj)
         if hasattr(obj, 'filter_count'):
         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):
     def _get_choices(self):
         if hasattr(self, '_choices'):
         if hasattr(self, '_choices'):
@@ -391,6 +394,14 @@ class FilterChoiceField(forms.ModelMultipleChoiceField):
     choices = property(_get_choices, forms.ChoiceField._set_choices)
     choices = property(_get_choices, forms.ChoiceField._set_choices)
 
 
 
 
+class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField):
+    pass
+
+
+class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField):
+    pass
+
+
 class LaxURLField(forms.URLField):
 class LaxURLField(forms.URLField):
     """
     """
     Custom URLField which allows any valid URL scheme
     Custom URLField which allows any valid URL scheme

+ 1 - 0
requirements.txt

@@ -3,6 +3,7 @@ cryptography>=1.4
 Django>=1.10
 Django>=1.10
 django-debug-toolbar>=1.6
 django-debug-toolbar>=1.6
 django-filter==0.15.3
 django-filter==0.15.3
+django-mptt==0.8.7
 django-rest-swagger==0.3.10
 django-rest-swagger==0.3.10
 django-tables2>=1.2.5
 django-tables2>=1.2.5
 djangorestframework>=3.5.0
 djangorestframework>=3.5.0