Преглед изворни кода

Merge branch 'develop' into develop-2.10

Jeremy Stretch пре 5 година
родитељ
комит
23cce55246

+ 0 - 23
.github/lock.yml

@@ -1,23 +0,0 @@
-# Configuration for Lock (https://github.com/apps/lock)
-
-# Number of days of inactivity before a closed issue or pull request is locked
-daysUntilLock: 90
-
-# Skip issues and pull requests created before a given timestamp. Timestamp must
-# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
-skipCreatedBefore: false
-
-# Issues and pull requests with these labels will be ignored. Set to `[]` to disable
-exemptLabels: []
-
-# Label to add before locking, such as `outdated`. Set to `false` to disable
-lockLabel: false
-
-# Comment to post before locking. Set to `false` to disable
-lockComment: false
-
-# Assign `resolved` as the reason for locking. Set to `false` to disable
-setLockReason: true
-
-# Limit to only `issues` or `pulls`
-# only: issues

+ 21 - 0
.github/workflows/lock.yml

@@ -0,0 +1,21 @@
+# lock-threads (https://github.com/marketplace/actions/lock-threads)
+name: 'Lock threads'
+
+on:
+  schedule:
+    - cron: '0 3 * * *'
+
+jobs:
+  lock:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: dessant/lock-threads@v2
+        with:
+          github-token: ${{ github.token }}
+          issue-lock-inactive-days: '90'
+          issue-exclude-created-before: ''
+          issue-exclude-labels: ''
+          issue-lock-labels: ''
+          issue-lock-comment: ''
+          issue-lock-reason: 'resolved'
+          process-only: 'issues'

+ 3 - 2
docs/administration/replicating-netbox.md

@@ -73,8 +73,9 @@ tar -xf netbox_media.tar.gz
 
 ## Cache Invalidation
 
-If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache by performing this command:
+If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache on the original instance by issuing the `invalidate all` management command (within the Python virtual environment):
 
 ```no-highlight
-python3 manage.py invalidate all
+# source /opt/netbox/venv/bin/activate
+(venv) # python3 manage.py invalidate all
 ```

+ 14 - 0
docs/release-notes/version-2.9.md

@@ -1,5 +1,19 @@
 # NetBox v2.9
 
+## v2.9.9 (FUTURE)
+
+### Enhancements
+
+* [#5304](https://github.com/netbox-community/netbox/issues/5304) - Return server error messages as JSON when handling REST API requests
+* [#5310](https://github.com/netbox-community/netbox/issues/5310) - Link to rack groups within rack list table
+
+### Bug Fixes
+
+* [#5271](https://github.com/netbox-community/netbox/issues/5271) - Fix auto-population of region field when editing a device
+
+
+---
+
 ## v2.9.8 (2020-10-30)
 
 ### Enhancements

+ 12 - 2
netbox/circuits/forms.py

@@ -303,14 +303,24 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
 #
 
 class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
-        queryset=Site.objects.all()
+        queryset=Site.objects.all(),
+        query_params={
+            'region_id': '$region'
+        }
     )
 
     class Meta:
         model = CircuitTermination
         fields = [
-            'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
+            'term_side', 'region', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
         ]
         help_texts = {
             'port_speed': "Physical circuit speed",

+ 199 - 66
netbox/dcim/forms.py

@@ -352,8 +352,18 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
 #
 
 class RackGroupForm(BootstrapMixin, forms.ModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
-        queryset=Site.objects.all()
+        queryset=Site.objects.all(),
+        query_params={
+            'region_id': '$region'
+        }
     )
     parent = DynamicModelChoiceField(
         queryset=RackGroup.objects.all(),
@@ -367,7 +377,7 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = RackGroup
         fields = (
-            'site', 'parent', 'name', 'slug', 'description',
+            'region', 'site', 'parent', 'name', 'slug', 'description',
         )
 
 
@@ -447,14 +457,17 @@ class RackRoleCSVForm(CSVModelForm):
 #
 
 class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    site = DynamicModelChoiceField(
-        queryset=Site.objects.all()
-    )
-    group = DynamicModelChoiceField(
-        queryset=RackGroup.objects.all(),
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
         required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
         query_params={
-            'site_id': '$site'
+            'region_id': '$region'
         }
     )
     role = DynamicModelChoiceField(
@@ -470,8 +483,9 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     class Meta:
         model = Rack
         fields = [
-            'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag',
-            'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'tags',
+            'region', 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
+            'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
+            'comments', 'tags',
         ]
         help_texts = {
             'site': "The site at which the rack exists",
@@ -548,9 +562,19 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         queryset=Rack.objects.all(),
         widget=forms.MultipleHiddenInput
     )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$region'
+        }
     )
     group = DynamicModelChoiceField(
         queryset=RackGroup.objects.all(),
@@ -691,9 +715,19 @@ class RackElevationFilterForm(RackFilterForm):
 #
 
 class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$region'
+        }
     )
     rack_group = DynamicModelChoiceField(
         queryset=RackGroup.objects.all(),
@@ -707,7 +741,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         display_field='display_name',
         query_params={
             'site_id': '$site',
-            'group_id': 'rack',
+            'group_id': '$rack',
         }
     )
     units = NumericArrayField(
@@ -809,15 +843,23 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor
 
 class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
     model = RackReservation
-    field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant']
+    field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant']
     q = forms.CharField(
         required=False,
         label='Search'
     )
+    region = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False
+    )
     site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         to_field_name='slug',
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     group_id = DynamicModelMultipleChoiceField(
         queryset=RackGroup.objects.prefetch_related('site'),
@@ -1672,7 +1714,10 @@ class PlatformCSVForm(CSVModelForm):
 class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
-        required=False
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
     )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
@@ -1686,6 +1731,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         display_field='display_name',
         query_params={
             'site_id': '$site'
+        },
+        initial_params={
+            'racks': '$rack'
         }
     )
     rack = DynamicModelChoiceField(
@@ -1711,7 +1759,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     )
     manufacturer = DynamicModelChoiceField(
         queryset=Manufacturer.objects.all(),
-        required=False
+        required=False,
+        initial_params={
+            'device_types': '$device_type'
+        }
     )
     device_type = DynamicModelChoiceField(
         queryset=DeviceType.objects.all(),
@@ -1733,7 +1784,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     cluster_group = DynamicModelChoiceField(
         queryset=ClusterGroup.objects.all(),
         required=False,
-        null_option='None'
+        null_option='None',
+        initial_params={
+            'clusters': '$cluster'
+        }
     )
     cluster = DynamicModelChoiceField(
         queryset=Cluster.objects.all(),
@@ -1772,27 +1826,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         }
 
     def __init__(self, *args, **kwargs):
-
-        # Initialize helper selectors
-        instance = kwargs.get('instance')
-        if 'initial' not in kwargs:
-            kwargs['initial'] = {}
-        # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
-        if instance and hasattr(instance, 'device_type'):
-            kwargs['initial']['manufacturer'] = instance.device_type.manufacturer
-        if instance and instance.cluster is not None:
-            kwargs['initial']['cluster_group'] = instance.cluster.group
-
-        if 'device_type' in kwargs['initial'] and 'manufacturer' not in kwargs['initial']:
-            device_type_id = kwargs['initial']['device_type']
-            manufacturer_id = DeviceType.objects.filter(pk=device_type_id).values_list('manufacturer__pk', flat=True).first()
-            kwargs['initial']['manufacturer'] = manufacturer_id
-
-        if 'cluster' in kwargs['initial'] and 'cluster_group' not in kwargs['initial']:
-            cluster_id = kwargs['initial']['cluster']
-            cluster_group_id = Cluster.objects.filter(pk=cluster_id).values_list('group__pk', flat=True).first()
-            kwargs['initial']['cluster_group'] = cluster_group_id
-
         super().__init__(*args, **kwargs)
 
         if self.instance.pk:
@@ -3441,10 +3474,18 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
     """
     Base form for connecting a Cable to a Device component
     """
+    termination_b_region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        label='Region',
+        required=False
+    )
     termination_b_site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         label='Site',
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$termination_b_region'
+        }
     )
     termination_b_rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
@@ -3470,8 +3511,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = Cable
         fields = [
-            'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id', 'type', 'status',
-            'label', 'color', 'length', 'length_unit',
+            'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device',
+            'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit',
         ]
         widgets = {
             'status': StaticSelect2,
@@ -3568,10 +3609,18 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
         label='Provider',
         required=False
     )
+    termination_b_region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        label='Region',
+        required=False
+    )
     termination_b_site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         label='Site',
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$termination_b_region'
+        }
     )
     termination_b_circuit = DynamicModelChoiceField(
         queryset=Circuit.objects.all(),
@@ -3595,8 +3644,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = Cable
         fields = [
-            'termination_b_provider', 'termination_b_site', 'termination_b_circuit', 'termination_b_id', 'type',
-            'status', 'label', 'color', 'length', 'length_unit',
+            'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
+            'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit',
         ]
 
     def clean_termination_b_id(self):
@@ -3605,11 +3654,18 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
 
 
 class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
+    termination_b_region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        label='Region',
+        required=False
+    )
     termination_b_site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         label='Site',
         required=False,
-        display_field='cid'
+        query_params={
+            'region_id': '$termination_b_region'
+        }
     )
     termination_b_rackgroup = DynamicModelChoiceField(
         queryset=RackGroup.objects.all(),
@@ -3851,10 +3907,18 @@ class CableFilterForm(BootstrapMixin, forms.Form):
         required=False,
         label='Search'
     )
+    region = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False
+    )
     site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         to_field_name='slug',
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     tenant = DynamicModelMultipleChoiceField(
         queryset=Tenant.objects.all(),
@@ -3903,10 +3967,18 @@ class CableFilterForm(BootstrapMixin, forms.Form):
 #
 
 class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
+    region = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False
+    )
     site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         to_field_name='slug',
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
@@ -3919,10 +3991,18 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
 
 
 class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
+    region = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False
+    )
     site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         to_field_name='slug',
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
@@ -3935,10 +4015,18 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
 
 
 class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
+    region = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False
+    )
     site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         to_field_name='slug',
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
@@ -3962,9 +4050,19 @@ class DeviceSelectionForm(forms.Form):
 
 
 class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$region'
+        }
     )
     rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
@@ -3997,7 +4095,7 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
     class Meta:
         model = VirtualChassis
         fields = [
-            'name', 'domain', 'site', 'rack', 'members', 'initial_position', 'tags',
+            'name', 'domain', 'region', 'site', 'rack', 'members', 'initial_position', 'tags',
         ]
 
     def save(self, *args, **kwargs):
@@ -4094,9 +4192,19 @@ class DeviceVCMembershipForm(forms.ModelForm):
 
 
 class VCMemberSelectForm(BootstrapMixin, forms.Form):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$region'
+        }
     )
     rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
@@ -4195,8 +4303,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
 #
 
 class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
-        queryset=Site.objects.all()
+        queryset=Site.objects.all(),
+        query_params={
+            'region_id': '$region'
+        }
     )
     rack_group = DynamicModelChoiceField(
         queryset=RackGroup.objects.all(),
@@ -4213,7 +4331,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
     class Meta:
         model = PowerPanel
         fields = [
-            'site', 'rack_group', 'name', 'tags',
+            'region', 'site', 'rack_group', 'name', 'tags',
         ]
 
 
@@ -4248,9 +4366,19 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         queryset=PowerPanel.objects.all(),
         widget=forms.MultipleHiddenInput
     )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$region'
+        }
     )
     rack_group = DynamicModelChoiceField(
         queryset=RackGroup.objects.all(),
@@ -4302,9 +4430,22 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
 #
 
 class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites__powerpanel': '$power_panel'
+        }
+    )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        initial_params={
+            'powerpanel': '$power_panel'
+        },
+        query_params={
+            'region_id': '$region'
+        }
     )
     power_panel = DynamicModelChoiceField(
         queryset=PowerPanel.objects.all(),
@@ -4329,7 +4470,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
     class Meta:
         model = PowerFeed
         fields = [
-            'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
+            'region', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
             'max_utilization', 'comments', 'tags',
         ]
         widgets = {
@@ -4339,14 +4480,6 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
             'phase': StaticSelect2(),
         }
 
-    def __init__(self, *args, **kwargs):
-
-        super().__init__(*args, **kwargs)
-
-        # Initialize site field
-        if self.instance and hasattr(self.instance, 'power_panel'):
-            self.initial['site'] = self.instance.power_panel.site
-
 
 class PowerFeedCSVForm(CustomFieldModelCSVForm):
     site = CSVModelChoiceField(

+ 1100 - 0
netbox/dcim/tables.py

@@ -0,0 +1,1100 @@
+import django_tables2 as tables
+from django_tables2.utils import Accessor
+
+from tenancy.tables import COL_TENANT
+from utilities.tables import (
+    BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn,
+)
+from .models import (
+    Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
+    DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
+    InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
+    VirtualChassis,
+)
+
+MPTT_LINK = """
+{% if record.get_children %}
+    <span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="fa fa-caret-right"></i>
+{% else %}
+    <span style="padding-left: {{ record.get_ancestors|length }}9px">
+{% endif %}
+    <a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
+</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 = """
+{% load helpers %}
+<label class="label" style="color: {{ record.color|fgcolor }}; background-color: #{{ record.color }}">{{ record }}</label>
+"""
+
+DEVICE_LINK = """
+<a href="{% url 'dcim:device' pk=record.pk %}">
+    {{ record.name|default:'<span class="label label-info">Unnamed device</span>' }}
+</a>
+"""
+
+RACKGROUP_ELEVATIONS = """
+<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
+    <i class="fa fa-eye"></i>
+</a>
+"""
+
+RACK_DEVICE_COUNT = """
+<a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
+"""
+
+DEVICE_COUNT = """
+<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
+"""
+
+RACKRESERVATION_ACTIONS = """
+<a href="{% url 'dcim:rackreservation_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
+    <i class="fa fa-history"></i>
+</a>
+{% if perms.dcim.change_rackreservation %}
+    <a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% endif %}
+"""
+
+MANUFACTURER_ACTIONS = """
+<a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
+    <i class="fa fa-history"></i>
+</a>
+{% if perms.dcim.change_manufacturer %}
+    <a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% endif %}
+"""
+
+DEVICEROLE_DEVICE_COUNT = """
+<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
+"""
+
+DEVICEROLE_VM_COUNT = """
+<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
+"""
+
+DEVICEROLE_ACTIONS = """
+<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
+    <i class="fa fa-history"></i>
+</a>
+{% if perms.dcim.change_devicerole %}
+    <a href="{% url 'dcim:devicerole_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% endif %}
+"""
+
+PLATFORM_DEVICE_COUNT = """
+<a href="{% url 'dcim:device_list' %}?platform={{ record.slug }}">{{ value|default:0 }}</a>
+"""
+
+PLATFORM_VM_COUNT = """
+<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value|default:0 }}</a>
+"""
+
+STATUS_LABEL = """
+<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
+"""
+
+TYPE_LABEL = """
+<span class="label label-{{ record.get_type_class }}">{{ record.get_type_display }}</span>
+"""
+
+DEVICE_PRIMARY_IP = """
+{{ record.primary_ip6.address.ip|default:"" }}
+{% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
+{{ record.primary_ip4.address.ip|default:"" }}
+"""
+
+DEVICETYPE_INSTANCES_TEMPLATE = """
+<a href="{% url 'dcim:device_list' %}?manufacturer_id={{ record.manufacturer_id }}&device_type_id={{ record.pk }}">{{ record.instance_count }}</a>
+"""
+
+UTILIZATION_GRAPH = """
+{% load helpers %}
+{% utilization_graph value %}
+"""
+
+CABLE_TERMINATION_PARENT = """
+{% if value.device %}
+    <a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a>
+{% elif value.circuit %}
+    <a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a>
+{% elif value.power_panel %}
+    <a href="{{ value.power_panel.get_absolute_url }}">{{ value.power_panel }}</a>
+{% endif %}
+"""
+
+CABLE_LENGTH = """
+{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}&mdash;{% endif %}
+"""
+
+POWERPANEL_POWERFEED_COUNT = """
+<a href="{% url 'dcim:powerfeed_list' %}?power_panel_id={{ record.pk }}">{{ value }}</a>
+"""
+
+INTERFACE_IPADDRESSES = """
+{% for ip in record.ip_addresses.unrestricted %}
+    <a href="{{ ip.get_absolute_url }}">{{ ip }}</a><br />
+{% endfor %}
+"""
+
+INTERFACE_TAGGED_VLANS = """
+{% for vlan in record.tagged_vlans.unrestricted %}
+    <a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
+{% endfor %}
+"""
+
+CONNECTION_STATUS = """
+<span class="label label-{% if record.connection_status %}success{% else %}danger{% endif %}">{{ record.get_connection_status_display }}</span>
+"""
+
+
+#
+# Regions
+#
+
+class RegionTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.TemplateColumn(
+        template_code=MPTT_LINK,
+        orderable=False
+    )
+    site_count = tables.Column(
+        verbose_name='Sites'
+    )
+    actions = ButtonsColumn(Region)
+
+    class Meta(BaseTable.Meta):
+        model = Region
+        fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions')
+        default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
+
+
+#
+# Sites
+#
+
+class SiteTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn(
+        order_by=('_name',)
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    region = tables.TemplateColumn(
+        template_code=SITE_REGION_LINK
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
+    tags = TagColumn(
+        url_name='dcim:site_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = Site
+        fields = (
+            'pk', 'name', 'slug', 'status', 'facility', 'region', 'tenant', 'asn', 'time_zone', 'description',
+            'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
+            'contact_email', 'tags',
+        )
+        default_columns = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description')
+
+
+#
+# Rack groups
+#
+
+class RackGroupTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.TemplateColumn(
+        template_code=MPTT_LINK,
+        orderable=False
+    )
+    site = tables.LinkColumn(
+        viewname='dcim:site',
+        args=[Accessor('site__slug')],
+        verbose_name='Site'
+    )
+    rack_count = tables.Column(
+        verbose_name='Racks'
+    )
+    actions = ButtonsColumn(
+        model=RackGroup,
+        prepend_template=RACKGROUP_ELEVATIONS
+    )
+
+    class Meta(BaseTable.Meta):
+        model = RackGroup
+        fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions')
+        default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions')
+
+
+#
+# Rack roles
+#
+
+class RackRoleTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.Column(linkify=True)
+    rack_count = tables.Column(verbose_name='Racks')
+    color = tables.TemplateColumn(COLOR_LABEL)
+    actions = ButtonsColumn(RackRole)
+
+    class Meta(BaseTable.Meta):
+        model = RackRole
+        fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'actions')
+        default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
+
+
+#
+# Racks
+#
+
+class RackTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.Column(
+        order_by=('_name',),
+        linkify=True
+    )
+    group = tables.Column(
+        linkify=True
+    )
+    site = tables.Column(
+        linkify=True
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    role = ColoredLabelColumn()
+    u_height = tables.TemplateColumn(
+        template_code="{{ record.u_height }}U",
+        verbose_name='Height'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = Rack
+        fields = (
+            'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
+            'width', 'u_height',
+        )
+        default_columns = ('pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height')
+
+
+class RackDetailTable(RackTable):
+    device_count = tables.TemplateColumn(
+        template_code=RACK_DEVICE_COUNT,
+        verbose_name='Devices'
+    )
+    get_utilization = tables.TemplateColumn(
+        template_code=UTILIZATION_GRAPH,
+        orderable=False,
+        verbose_name='Space'
+    )
+    get_power_utilization = tables.TemplateColumn(
+        template_code=UTILIZATION_GRAPH,
+        orderable=False,
+        verbose_name='Power'
+    )
+    tags = TagColumn(
+        url_name='dcim:rack_list'
+    )
+
+    class Meta(RackTable.Meta):
+        fields = (
+            'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
+            'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
+        )
+        default_columns = (
+            'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
+            'get_utilization', 'get_power_utilization',
+        )
+
+
+#
+# Rack reservations
+#
+
+class RackReservationTable(BaseTable):
+    pk = ToggleColumn()
+    reservation = tables.Column(
+        accessor='pk',
+        linkify=True
+    )
+    site = tables.Column(
+        accessor=Accessor('rack__site'),
+        linkify=True
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
+    rack = tables.Column(
+        linkify=True
+    )
+    unit_list = tables.Column(
+        orderable=False,
+        verbose_name='Units'
+    )
+    tags = TagColumn(
+        url_name='dcim:rackreservation_list'
+    )
+    actions = ButtonsColumn(RackReservation)
+
+    class Meta(BaseTable.Meta):
+        model = RackReservation
+        fields = (
+            'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
+            'actions',
+        )
+        default_columns = (
+            'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions',
+        )
+
+
+#
+# Manufacturers
+#
+
+class ManufacturerTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn()
+    devicetype_count = tables.Column(
+        verbose_name='Device Types'
+    )
+    inventoryitem_count = tables.Column(
+        verbose_name='Inventory Items'
+    )
+    platform_count = tables.Column(
+        verbose_name='Platforms'
+    )
+    slug = tables.Column()
+    actions = ButtonsColumn(Manufacturer, pk_field='slug')
+
+    class Meta(BaseTable.Meta):
+        model = Manufacturer
+        fields = (
+            'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
+        )
+
+
+#
+# Device types
+#
+
+class DeviceTypeTable(BaseTable):
+    pk = ToggleColumn()
+    model = tables.Column(
+        linkify=True,
+        verbose_name='Device Type'
+    )
+    is_full_depth = BooleanColumn(
+        verbose_name='Full Depth'
+    )
+    instance_count = tables.TemplateColumn(
+        template_code=DEVICETYPE_INSTANCES_TEMPLATE,
+        verbose_name='Instances'
+    )
+    tags = TagColumn(
+        url_name='dcim:devicetype_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = DeviceType
+        fields = (
+            'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
+            'instance_count', 'tags',
+        )
+        default_columns = (
+            'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
+        )
+
+
+#
+# Device type components
+#
+
+class ComponentTemplateTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.Column(
+        order_by=('_name',)
+    )
+
+
+class ConsolePortTemplateTable(ComponentTemplateTable):
+    actions = ButtonsColumn(
+        model=ConsolePortTemplate,
+        buttons=('edit', 'delete'),
+        return_url_extra='%23tab_consoleports'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = ConsolePortTemplate
+        fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
+        empty_text = "None"
+
+
+class ConsoleServerPortTemplateTable(ComponentTemplateTable):
+    actions = ButtonsColumn(
+        model=ConsoleServerPortTemplate,
+        buttons=('edit', 'delete'),
+        return_url_extra='%23tab_consoleserverports'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = ConsoleServerPortTemplate
+        fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
+        empty_text = "None"
+
+
+class PowerPortTemplateTable(ComponentTemplateTable):
+    actions = ButtonsColumn(
+        model=PowerPortTemplate,
+        buttons=('edit', 'delete'),
+        return_url_extra='%23tab_powerports'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = PowerPortTemplate
+        fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions')
+        empty_text = "None"
+
+
+class PowerOutletTemplateTable(ComponentTemplateTable):
+    actions = ButtonsColumn(
+        model=PowerOutletTemplate,
+        buttons=('edit', 'delete'),
+        return_url_extra='%23tab_poweroutlets'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = PowerOutletTemplate
+        fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions')
+        empty_text = "None"
+
+
+class InterfaceTemplateTable(ComponentTemplateTable):
+    mgmt_only = BooleanColumn(
+        verbose_name='Management Only'
+    )
+    actions = ButtonsColumn(
+        model=InterfaceTemplate,
+        buttons=('edit', 'delete'),
+        return_url_extra='%23tab_interfaces'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = InterfaceTemplate
+        fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions')
+        empty_text = "None"
+
+
+class FrontPortTemplateTable(ComponentTemplateTable):
+    rear_port_position = tables.Column(
+        verbose_name='Position'
+    )
+    actions = ButtonsColumn(
+        model=FrontPortTemplate,
+        buttons=('edit', 'delete'),
+        return_url_extra='%23tab_frontports'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = FrontPortTemplate
+        fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'actions')
+        empty_text = "None"
+
+
+class RearPortTemplateTable(ComponentTemplateTable):
+    actions = ButtonsColumn(
+        model=RearPortTemplate,
+        buttons=('edit', 'delete'),
+        return_url_extra='%23tab_rearports'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = RearPortTemplate
+        fields = ('pk', 'name', 'label', 'type', 'positions', 'description', 'actions')
+        empty_text = "None"
+
+
+class DeviceBayTemplateTable(ComponentTemplateTable):
+    actions = ButtonsColumn(
+        model=DeviceBayTemplate,
+        buttons=('edit', 'delete'),
+        return_url_extra='%23tab_devicebays'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = DeviceBayTemplate
+        fields = ('pk', 'name', 'label', 'description', 'actions')
+        empty_text = "None"
+
+
+#
+# Device roles
+#
+
+class DeviceRoleTable(BaseTable):
+    pk = ToggleColumn()
+    device_count = tables.TemplateColumn(
+        template_code=DEVICEROLE_DEVICE_COUNT,
+        verbose_name='Devices'
+    )
+    vm_count = tables.TemplateColumn(
+        template_code=DEVICEROLE_VM_COUNT,
+        verbose_name='VMs'
+    )
+    color = tables.TemplateColumn(
+        template_code=COLOR_LABEL,
+        verbose_name='Label'
+    )
+    vm_role = BooleanColumn()
+    actions = ButtonsColumn(DeviceRole, pk_field='slug')
+
+    class Meta(BaseTable.Meta):
+        model = DeviceRole
+        fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions')
+        default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
+
+
+#
+# Platforms
+#
+
+class PlatformTable(BaseTable):
+    pk = ToggleColumn()
+    device_count = tables.TemplateColumn(
+        template_code=PLATFORM_DEVICE_COUNT,
+        verbose_name='Devices'
+    )
+    vm_count = tables.TemplateColumn(
+        template_code=PLATFORM_VM_COUNT,
+        verbose_name='VMs'
+    )
+    actions = ButtonsColumn(Platform, pk_field='slug')
+
+    class Meta(BaseTable.Meta):
+        model = Platform
+        fields = (
+            'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
+            'description', 'actions',
+        )
+        default_columns = (
+            'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions',
+        )
+
+
+#
+# Devices
+#
+
+class DeviceTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.TemplateColumn(
+        order_by=('_name',),
+        template_code=DEVICE_LINK
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
+    site = tables.Column(
+        linkify=True
+    )
+    rack = tables.Column(
+        linkify=True
+    )
+    device_role = ColoredLabelColumn(
+        verbose_name='Role'
+    )
+    device_type = tables.LinkColumn(
+        viewname='dcim:devicetype',
+        args=[Accessor('device_type__pk')],
+        verbose_name='Type',
+        text=lambda record: record.device_type.display_name
+    )
+    primary_ip = tables.TemplateColumn(
+        template_code=DEVICE_PRIMARY_IP,
+        orderable=False,
+        verbose_name='IP Address'
+    )
+    primary_ip4 = tables.Column(
+        linkify=True,
+        verbose_name='IPv4 Address'
+    )
+    primary_ip6 = tables.Column(
+        linkify=True,
+        verbose_name='IPv6 Address'
+    )
+    cluster = tables.LinkColumn(
+        viewname='virtualization:cluster',
+        args=[Accessor('cluster__pk')]
+    )
+    virtual_chassis = tables.LinkColumn(
+        viewname='dcim:virtualchassis',
+        args=[Accessor('virtual_chassis__pk')]
+    )
+    vc_position = tables.Column(
+        verbose_name='VC Position'
+    )
+    vc_priority = tables.Column(
+        verbose_name='VC Priority'
+    )
+    tags = TagColumn(
+        url_name='dcim:device_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = Device
+        fields = (
+            'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site',
+            'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis',
+            'vc_position', 'vc_priority', 'tags',
+        )
+        default_columns = (
+            'pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip',
+        )
+
+
+class DeviceImportTable(BaseTable):
+    name = tables.TemplateColumn(
+        template_code=DEVICE_LINK
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
+    site = tables.Column(
+        linkify=True
+    )
+    rack = tables.Column(
+        linkify=True
+    )
+    device_role = tables.Column(
+        verbose_name='Role'
+    )
+    device_type = tables.Column(
+        verbose_name='Type'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = Device
+        fields = ('name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
+        empty_text = False
+
+
+#
+# Device components
+#
+
+class DeviceComponentTable(BaseTable):
+    pk = ToggleColumn()
+    device = tables.Column(
+        linkify=True
+    )
+    name = tables.Column(
+        linkify=True,
+        order_by=('_name',)
+    )
+    cable = tables.Column(
+        linkify=True
+    )
+
+    class Meta(BaseTable.Meta):
+        order_by = ('device', 'name')
+
+
+class ConsolePortTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:consoleport_list'
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = ConsolePort
+        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags')
+        default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
+
+
+class ConsoleServerPortTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:consoleserverport_list'
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = ConsoleServerPort
+        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags')
+        default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
+
+
+class PowerPortTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:powerport_list'
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = PowerPort
+        fields = (
+            'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', 'tags',
+        )
+        default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
+
+
+class PowerOutletTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:poweroutlet_list'
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = PowerOutlet
+        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'tags')
+        default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
+
+
+class BaseInterfaceTable(BaseTable):
+    enabled = BooleanColumn()
+    ip_addresses = tables.TemplateColumn(
+        template_code=INTERFACE_IPADDRESSES,
+        orderable=False,
+        verbose_name='IP Addresses'
+    )
+    untagged_vlan = tables.Column(linkify=True)
+    tagged_vlans = tables.TemplateColumn(
+        template_code=INTERFACE_TAGGED_VLANS,
+        orderable=False,
+        verbose_name='Tagged VLANs'
+    )
+
+
+class InterfaceTable(DeviceComponentTable, BaseInterfaceTable):
+    tags = TagColumn(
+        url_name='dcim:interface_list'
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = Interface
+        fields = (
+            'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
+            'description', 'cable', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
+        )
+        default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
+
+
+class FrontPortTable(DeviceComponentTable):
+    rear_port_position = tables.Column(
+        verbose_name='Position'
+    )
+    tags = TagColumn(
+        url_name='dcim:frontport_list'
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = FrontPort
+        fields = (
+            'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags',
+        )
+        default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
+
+
+class RearPortTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:rearport_list'
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = RearPort
+        fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags')
+        default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
+
+
+class DeviceBayTable(DeviceComponentTable):
+    installed_device = tables.Column(
+        linkify=True
+    )
+    tags = TagColumn(
+        url_name='dcim:devicebay_list'
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = DeviceBay
+        fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description', 'tags')
+        default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
+
+
+class InventoryItemTable(DeviceComponentTable):
+    manufacturer = tables.Column(
+        linkify=True
+    )
+    discovered = BooleanColumn()
+    tags = TagColumn(
+        url_name='dcim:inventoryitem_list'
+    )
+    cable = None  # Override DeviceComponentTable
+
+    class Meta(DeviceComponentTable.Meta):
+        model = InventoryItem
+        fields = (
+            'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
+            'discovered', 'tags',
+        )
+        default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
+
+
+#
+# Cables
+#
+
+class CableTable(BaseTable):
+    pk = ToggleColumn()
+    id = tables.Column(
+        linkify=True,
+        verbose_name='ID'
+    )
+    termination_a_parent = tables.TemplateColumn(
+        template_code=CABLE_TERMINATION_PARENT,
+        accessor=Accessor('termination_a'),
+        orderable=False,
+        verbose_name='Side A'
+    )
+    termination_a = tables.LinkColumn(
+        accessor=Accessor('termination_a'),
+        orderable=False,
+        verbose_name='Termination A'
+    )
+    termination_b_parent = tables.TemplateColumn(
+        template_code=CABLE_TERMINATION_PARENT,
+        accessor=Accessor('termination_b'),
+        orderable=False,
+        verbose_name='Side B'
+    )
+    termination_b = tables.LinkColumn(
+        accessor=Accessor('termination_b'),
+        orderable=False,
+        verbose_name='Termination B'
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    length = tables.TemplateColumn(
+        template_code=CABLE_LENGTH,
+        order_by='_abs_length'
+    )
+    color = ColorColumn()
+    tags = TagColumn(
+        url_name='dcim:cable_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = Cable
+        fields = (
+            'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
+            'status', 'type', 'color', 'length', 'tags',
+        )
+        default_columns = (
+            'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
+            'status', 'type',
+        )
+
+
+#
+# Device connections
+#
+
+class ConsoleConnectionTable(BaseTable):
+    console_server = tables.LinkColumn(
+        viewname='dcim:device',
+        accessor=Accessor('connected_endpoint__device'),
+        args=[Accessor('connected_endpoint__device__pk')],
+        verbose_name='Console Server'
+    )
+    connected_endpoint = tables.Column(
+        linkify=True,
+        verbose_name='Port'
+    )
+    device = tables.Column(
+        linkify=True
+    )
+    name = tables.Column(
+        linkify=True,
+        verbose_name='Console Port'
+    )
+    connection_status = tables.TemplateColumn(
+        template_code=CONNECTION_STATUS,
+        verbose_name='Status'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = ConsolePort
+        fields = ('console_server', 'connected_endpoint', 'device', 'name', 'connection_status')
+
+
+class PowerConnectionTable(BaseTable):
+    pdu = tables.LinkColumn(
+        viewname='dcim:device',
+        accessor=Accessor('connected_endpoint__device'),
+        args=[Accessor('connected_endpoint__device__pk')],
+        order_by='_connected_poweroutlet__device',
+        verbose_name='PDU'
+    )
+    outlet = tables.Column(
+        accessor=Accessor('_connected_poweroutlet'),
+        linkify=True,
+        verbose_name='Outlet'
+    )
+    device = tables.Column(
+        linkify=True
+    )
+    name = tables.Column(
+        linkify=True,
+        verbose_name='Power Port'
+    )
+    connection_status = tables.TemplateColumn(
+        template_code=CONNECTION_STATUS,
+        verbose_name='Status'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = PowerPort
+        fields = ('pdu', 'outlet', 'device', 'name', 'connection_status')
+
+
+class InterfaceConnectionTable(BaseTable):
+    device_a = tables.LinkColumn(
+        viewname='dcim:device',
+        accessor=Accessor('device'),
+        args=[Accessor('device__pk')],
+        verbose_name='Device A'
+    )
+    interface_a = tables.LinkColumn(
+        viewname='dcim:interface',
+        accessor=Accessor('name'),
+        args=[Accessor('pk')],
+        verbose_name='Interface A'
+    )
+    device_b = tables.LinkColumn(
+        viewname='dcim:device',
+        accessor=Accessor('_connected_interface__device'),
+        args=[Accessor('_connected_interface__device__pk')],
+        verbose_name='Device B'
+    )
+    interface_b = tables.LinkColumn(
+        viewname='dcim:interface',
+        accessor=Accessor('_connected_interface'),
+        args=[Accessor('_connected_interface__pk')],
+        verbose_name='Interface B'
+    )
+    connection_status = tables.TemplateColumn(
+        template_code=CONNECTION_STATUS,
+        verbose_name='Status'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = Interface
+        fields = (
+            'device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status',
+        )
+
+
+#
+# Virtual chassis
+#
+
+class VirtualChassisTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.Column(
+        linkify=True
+    )
+    master = tables.Column(
+        linkify=True
+    )
+    member_count = tables.Column(
+        verbose_name='Members'
+    )
+    tags = TagColumn(
+        url_name='dcim:virtualchassis_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = VirtualChassis
+        fields = ('pk', 'name', 'domain', 'master', 'member_count', 'tags')
+        default_columns = ('pk', 'name', 'domain', 'master', 'member_count')
+
+
+#
+# Power panels
+#
+
+class PowerPanelTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn()
+    site = tables.LinkColumn(
+        viewname='dcim:site',
+        args=[Accessor('site__slug')]
+    )
+    powerfeed_count = tables.TemplateColumn(
+        template_code=POWERPANEL_POWERFEED_COUNT,
+        verbose_name='Feeds'
+    )
+    tags = TagColumn(
+        url_name='dcim:powerpanel_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = PowerPanel
+        fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count', 'tags')
+        default_columns = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count')
+
+
+#
+# Power feeds
+#
+
+class PowerFeedTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn()
+    power_panel = tables.Column(
+        linkify=True
+    )
+    rack = tables.Column(
+        linkify=True
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    type = tables.TemplateColumn(
+        template_code=TYPE_LABEL
+    )
+    max_utilization = tables.TemplateColumn(
+        template_code="{{ value }}%"
+    )
+    available_power = tables.Column(
+        verbose_name='Available power (VA)'
+    )
+    tags = TagColumn(
+        url_name='dcim:powerfeed_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = PowerFeed
+        fields = (
+            'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
+            'max_utilization', 'available_power', 'tags',
+        )
+        default_columns = (
+            'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
+        )

+ 8 - 5
netbox/dcim/tables/racks.py

@@ -70,12 +70,15 @@ class RackRoleTable(BaseTable):
 
 class RackTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn(
-        order_by=('_name',)
+    name = tables.Column(
+        order_by=('_name',),
+        linkify=True
     )
-    site = tables.LinkColumn(
-        viewname='dcim:site',
-        args=[Accessor('site__slug')]
+    group = tables.Column(
+        linkify=True
+    )
+    site = tables.Column(
+        linkify=True
     )
     tenant = tables.TemplateColumn(
         template_code=COL_TENANT

+ 0 - 17
netbox/dcim/tests/test_forms.py

@@ -100,23 +100,6 @@ class DeviceTestCase(TestCase):
         self.assertIn('face', form.errors)
         self.assertIn('position', form.errors)
 
-    def test_initial_data_population(self):
-        device_type = DeviceType.objects.first()
-        cluster = Cluster.objects.first()
-        test = DeviceForm(initial={
-            'device_type': device_type.pk,
-            'device_role': DeviceRole.objects.first().pk,
-            'status': DeviceStatusChoices.STATUS_ACTIVE,
-            'site': Site.objects.first().pk,
-            'cluster': cluster.pk,
-        })
-
-        # Check that the initial value for the manufacturer is set automatically when assigning the device type
-        self.assertEqual(test.initial['manufacturer'], device_type.manufacturer.pk)
-
-        # Check that the initial value for the cluster group is set automatically when assigning the cluster
-        self.assertEqual(test.initial['cluster_group'], cluster.group.pk)
-
 
 class LabelTestCase(TestCase):
 

+ 4 - 1
netbox/dcim/views.py

@@ -2089,8 +2089,11 @@ class CableCreateView(ObjectEditView):
         initial_data = {k: request.GET[k] for k in request.GET}
 
         # Set initial site and rack based on side A termination (if not already set)
+        termination_a_site = getattr(obj.termination_a.parent, 'site', None)
+        if termination_a_site and 'termination_b_region' not in initial_data:
+            initial_data['termination_b_region'] = termination_a_site.region
         if 'termination_b_site' not in initial_data:
-            initial_data['termination_b_site'] = getattr(obj.termination_a.parent, 'site', None)
+            initial_data['termination_b_site'] = termination_a_site
         if 'termination_b_rack' not in initial_data:
             initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None)
 

+ 75 - 19
netbox/ipam/forms.py

@@ -351,10 +351,20 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         label='VRF',
         display_field='display_name'
     )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         required=False,
-        null_option='None'
+        null_option='None',
+        query_params={
+            'region_id': '$region'
+        }
     )
     vlan_group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
@@ -363,6 +373,9 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         null_option='None',
         query_params={
             'site_id': '$site'
+        },
+        initial_params={
+            'vlans': '$vlan'
         }
     )
     vlan = DynamicModelChoiceField(
@@ -395,14 +408,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         }
 
     def __init__(self, *args, **kwargs):
-
-        # Initialize helper selectors
-        instance = kwargs.get('instance')
-        initial = kwargs.get('initial', {}).copy()
-        if instance and instance.vlan is not None:
-            initial['vlan_group'] = instance.vlan.group
-        kwargs['initial'] = initial
-
         super().__init__(*args, **kwargs)
 
         self.fields['vrf'].empty_label = 'Global'
@@ -472,9 +477,17 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         queryset=Prefix.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        to_field_name='slug'
+    )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
@@ -604,7 +617,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
     device = DynamicModelChoiceField(
         queryset=Device.objects.all(),
         required=False,
-        display_field='display_name'
+        display_field='display_name',
+        initial_params={
+            'interfaces': '$interface'
+        }
     )
     interface = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
@@ -615,7 +631,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
     )
     virtual_machine = DynamicModelChoiceField(
         queryset=VirtualMachine.objects.all(),
-        required=False
+        required=False,
+        initial_params={
+            'interfaces': '$vminterface'
+        }
     )
     vminterface = DynamicModelChoiceField(
         queryset=VMInterface.objects.all(),
@@ -631,10 +650,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
         label='VRF',
         display_field='display_name'
     )
+    nat_region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label='Region',
+        initial_params={
+            'sites': '$nat_site'
+        }
+    )
     nat_site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         required=False,
-        label='Site'
+        label='Site',
+        query_params={
+            'region_id': '$nat_region'
+        }
     )
     nat_rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
@@ -714,10 +744,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
         initial = kwargs.get('initial', {}).copy()
         if instance:
             if type(instance.assigned_object) is Interface:
-                initial['device'] = instance.assigned_object.device
                 initial['interface'] = instance.assigned_object
             elif type(instance.assigned_object) is VMInterface:
-                initial['virtual_machine'] = instance.assigned_object.virtual_machine
                 initial['vminterface'] = instance.assigned_object
             if instance.nat_inside:
                 nat_inside_parent = instance.nat_inside.assigned_object
@@ -1028,16 +1056,26 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
 #
 
 class VLANGroupForm(BootstrapMixin, forms.ModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$region'
+        }
     )
     slug = SlugField()
 
     class Meta:
         model = VLANGroup
         fields = [
-            'site', 'name', 'slug', 'description',
+            'region', 'site', 'name', 'slug', 'description',
         ]
 
 
@@ -1077,10 +1115,20 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
 #
 
 class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         required=False,
-        null_option='None'
+        null_option='None',
+        query_params={
+            'region_id': '$region'
+        }
     )
     group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
@@ -1169,9 +1217,17 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         queryset=VLAN.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        to_field_name='slug'
+    )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),

+ 1 - 0
netbox/templates/circuits/circuittermination_edit.html

@@ -40,6 +40,7 @@
                                 <p class="form-control-static">{{ form.term_side.value }}</p>
                             </div>
                         </div>
+                        {% render_field form.region %}
                         {% render_field form.site %}
                     </div>
                 </div>

+ 9 - 0
netbox/templates/dcim/cable_connect.html

@@ -32,6 +32,12 @@
                     <div class="panel-body">
                         {% if termination_a.device %}
                             {# Device component #}
+                            <div class="form-group">
+                                <label class="col-md-3 control-label required">Region</label>
+                                <div class="col-md-9">
+                                    <p class="form-control-static">{{ termination_a.device.site.region }}</p>
+                                </div>
+                            </div>
                             <div class="form-group">
                                 <label class="col-md-3 control-label required">Site</label>
                                 <div class="col-md-9">
@@ -111,6 +117,9 @@
                         {% if 'termination_b_provider' in form.fields %}
                             {% render_field form.termination_b_provider %}
                         {% endif %}
+                        {% if 'termination_b_region' in form.fields %}
+                            {% render_field form.termination_b_region %}
+                        {% endif %}
                         {% if 'termination_b_site' in form.fields %}
                             {% render_field form.termination_b_site %}
                         {% endif %}

+ 7 - 1
netbox/templates/dcim/powerfeed_edit.html

@@ -3,10 +3,16 @@
 
 {% block form %}
     <div class="panel panel-default">
-        <div class="panel-heading"><strong>Power Feed</strong></div>
+        <div class="panel-heading"><strong>Power Panel</strong></div>
         <div class="panel-body">
+            {% render_field form.region %}
             {% render_field form.site %}
             {% render_field form.power_panel %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Power Feed</strong></div>
+        <div class="panel-body">
             {% render_field form.rack %}
             {% render_field form.name %}
             {% render_field form.status %}

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

@@ -5,6 +5,7 @@
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Rack</strong></div>
         <div class="panel-body">
+            {% render_field form.region %}
             {% render_field form.site %}
             {% render_field form.name %}
             {% render_field form.facility_id %}

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

@@ -5,6 +5,7 @@
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Rack Reservation</strong></div>
         <div class="panel-body">
+            {% render_field form.region %}
             {% render_field form.site %}
             {% render_field form.rack_group %}
             {% render_field form.rack %}

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

@@ -13,6 +13,7 @@
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Member Devices</strong></div>
         <div class="panel-body">
+            {% render_field form.region %}
             {% render_field form.site %}
             {% render_field form.rack %}
             {% render_field form.members %}

+ 1 - 0
netbox/templates/ipam/ipaddress_edit.html

@@ -62,6 +62,7 @@
             </ul>
             <div class="tab-content">
                 <div class="tab-pane active" id="by_device">
+                    {% render_field form.nat_region %}
                     {% render_field form.nat_site %}
                     {% render_field form.nat_rack %}
                     {% render_field form.nat_device %}

+ 1 - 0
netbox/templates/ipam/prefix_edit.html

@@ -16,6 +16,7 @@
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Site/VLAN Assignment</strong></div>
         <div class="panel-body">
+            {% render_field form.region %}
             {% render_field form.site %}
             {% render_field form.vlan_group %}
             {% render_field form.vlan %}

+ 8 - 2
netbox/templates/ipam/vlan_edit.html

@@ -8,12 +8,18 @@
             {% render_field form.vid %}
             {% render_field form.name %}
             {% render_field form.status %}
-            {% render_field form.site %}
-            {% render_field form.group %}
             {% render_field form.role %}
             {% render_field form.description %}
         </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Assignment</strong></div>
+        <div class="panel-body">
+            {% render_field form.region %}
+            {% render_field form.site %}
+            {% render_field form.group %}
+        </div>
+    </div>
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Tenancy</strong></div>
         <div class="panel-body">

+ 1 - 0
netbox/templates/virtualization/cluster_edit.html

@@ -8,6 +8,7 @@
             {% render_field form.name %}
             {% render_field form.type %}
             {% render_field form.group %}
+            {% render_field form.region %}
             {% render_field form.site %}
         </div>
     </div>

+ 4 - 12
netbox/tenancy/forms.py

@@ -119,7 +119,10 @@ class TenancyForm(forms.Form):
     tenant_group = DynamicModelChoiceField(
         queryset=TenantGroup.objects.all(),
         required=False,
-        null_option='None'
+        null_option='None',
+        initial_params={
+            'tenants': '$tenant'
+        }
     )
     tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
@@ -129,17 +132,6 @@ class TenancyForm(forms.Form):
         }
     )
 
-    def __init__(self, *args, **kwargs):
-
-        # Initialize helper selector
-        instance = kwargs.get('instance')
-        if instance and instance.tenant is not None:
-            initial = kwargs.get('initial', {}).copy()
-            initial['tenant_group'] = instance.tenant.group
-            kwargs['initial'] = initial
-
-        super().__init__(*args, **kwargs)
-
 
 class TenancyFilterForm(forms.Form):
     tenant_group = DynamicModelMultipleChoiceField(

+ 14 - 2
netbox/utilities/forms/fields.py

@@ -241,6 +241,7 @@ class DynamicModelChoiceMixin:
     """
     :param display_field: The name of the attribute of an API response object to display in the selection list
     :param query_params: A dictionary of additional key/value pairs to attach to the API request
+    :param initial_params: A dictionary of child field references to use for selecting a parent field's initial value
     :param null_option: The string used to represent a null selection (if any)
     :param disabled_indicator: The name of the field which, if populated, will disable selection of the
         choice (optional)
@@ -249,10 +250,11 @@ class DynamicModelChoiceMixin:
     filter = django_filters.ModelChoiceFilter
     widget = widgets.APISelect
 
-    def __init__(self, display_field='name', query_params=None, null_option=None, disabled_indicator=None,
-                 brief_mode=True, *args, **kwargs):
+    def __init__(self, display_field='name', query_params=None, initial_params=None, null_option=None,
+                 disabled_indicator=None, brief_mode=True, *args, **kwargs):
         self.display_field = display_field
         self.query_params = query_params or {}
+        self.initial_params = initial_params or {}
         self.null_option = null_option
         self.disabled_indicator = disabled_indicator
         self.brief_mode = brief_mode
@@ -293,6 +295,16 @@ class DynamicModelChoiceMixin:
     def get_bound_field(self, form, field_name):
         bound_field = BoundField(form, self, field_name)
 
+        # Set initial value based on prescribed child fields (if not already set)
+        if not self.initial and self.initial_params:
+            filter_kwargs = {}
+            for kwarg, child_field in self.initial_params.items():
+                value = form.initial.get(child_field.lstrip('$'))
+                if value:
+                    filter_kwargs[kwarg] = value
+            if filter_kwargs:
+                self.initial = self.queryset.filter(**filter_kwargs).first()
+
         # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
         # will be populated on-demand via the APISelect widget.
         data = bound_field.value()

+ 5 - 1
netbox/utilities/middleware.py

@@ -7,7 +7,7 @@ from django.http import Http404, HttpResponseRedirect
 from django.urls import reverse
 
 from .api import is_api_request
-from .views import server_error
+from .views import server_error, rest_api_server_error
 
 
 class LoginRequiredMiddleware(object):
@@ -86,6 +86,10 @@ class ExceptionHandlingMiddleware(object):
         if isinstance(exception, Http404):
             return
 
+        # Handle exceptions that occur from REST API requests
+        if is_api_request(request):
+            return rest_api_server_error(request)
+
         # Determine the type of exception. If it's a common issue, return a custom error page with instructions.
         custom_template = None
         if isinstance(exception, ProgrammingError):

+ 19 - 4
netbox/utilities/views.py

@@ -13,7 +13,7 @@ from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, Obje
 from django.db import transaction, IntegrityError
 from django.db.models import ManyToManyField, ProtectedError
 from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
-from django.http import HttpResponse, HttpResponseServerError
+from django.http import HttpResponse, HttpResponseServerError, JsonResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.template import loader
 from django.template.exceptions import TemplateDoesNotExist
@@ -27,6 +27,7 @@ from django.views.decorators.csrf import requires_csrf_token
 from django.views.defaults import ERROR_500_TEMPLATE_NAME
 from django.views.generic import View
 from django_tables2 import RequestConfig
+from rest_framework import status
 
 from extras.models import CustomField, ExportTemplate
 from utilities.exceptions import AbortTransaction
@@ -1367,8 +1368,22 @@ def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
     type_, error, traceback = sys.exc_info()
 
     return HttpResponseServerError(template.render({
-        'python_version': platform.python_version(),
-        'netbox_version': settings.VERSION,
-        'exception': str(type_),
         'error': error,
+        'exception': str(type_),
+        'netbox_version': settings.VERSION,
+        'python_version': platform.python_version(),
     }))
+
+
+def rest_api_server_error(request, *args, **kwargs):
+    """
+    Handle exceptions and return a useful error message for REST API requests.
+    """
+    type_, error, traceback = sys.exc_info()
+    data = {
+        'error': str(error),
+        'exception': type_.__name__,
+        'netbox_version': settings.VERSION,
+        'python_version': platform.python_version(),
+    }
+    return JsonResponse(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

+ 25 - 12
netbox/virtualization/forms.py

@@ -79,9 +79,19 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         queryset=ClusterGroup.objects.all(),
         required=False
     )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$region'
+        }
     )
     comments = CommentField()
     tags = DynamicModelMultipleChoiceField(
@@ -92,7 +102,7 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     class Meta:
         model = Cluster
         fields = (
-            'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags',
+            'name', 'type', 'group', 'tenant', 'region', 'site', 'comments', 'tags',
         )
 
 
@@ -143,9 +153,17 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
         queryset=Tenant.objects.all(),
         required=False
     )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        to_field_name='slug'
+    )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     comments = CommentField(
         widget=SmallTextarea,
@@ -266,7 +284,10 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     cluster_group = DynamicModelChoiceField(
         queryset=ClusterGroup.objects.all(),
         required=False,
-        null_option='None'
+        null_option='None',
+        initial_params={
+            'clusters': '$cluster'
+        }
     )
     cluster = DynamicModelChoiceField(
         queryset=Cluster.objects.all(),
@@ -311,14 +332,6 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         }
 
     def __init__(self, *args, **kwargs):
-
-        # Initialize helper selector
-        instance = kwargs.get('instance')
-        if instance.pk and instance.cluster is not None:
-            initial = kwargs.get('initial', {}).copy()
-            initial['cluster_group'] = instance.cluster.group
-            kwargs['initial'] = initial
-
         super().__init__(*args, **kwargs)
 
         if self.instance.pk: