فهرست منبع

Merge pull request #5332 from netbox-community/develop

Release v2.9.9
Jeremy Stretch 5 سال پیش
والد
کامیت
8ba50d0cf2
37فایلهای تغییر یافته به همراه552 افزوده شده و 213 حذف شده
  1. 0 23
      .github/lock.yml
  2. 21 0
      .github/workflows/lock.yml
  3. 3 2
      docs/administration/replicating-netbox.md
  4. 20 0
      docs/release-notes/version-2.9.md
  5. 12 2
      netbox/circuits/forms.py
  6. 9 7
      netbox/dcim/api/views.py
  7. 199 66
      netbox/dcim/forms.py
  8. 8 5
      netbox/dcim/tables.py
  9. 0 17
      netbox/dcim/tests/test_forms.py
  10. 4 1
      netbox/dcim/views.py
  11. 2 2
      netbox/extras/querysets.py
  12. 13 2
      netbox/extras/scripts.py
  13. 1 1
      netbox/extras/templatetags/custom_links.py
  14. 1 1
      netbox/extras/tests/dummy_plugin/template_content.py
  15. 3 3
      netbox/extras/tests/test_api.py
  16. 35 0
      netbox/extras/tests/test_models.py
  17. 75 19
      netbox/ipam/forms.py
  18. 14 9
      netbox/netbox/authentication.py
  19. 21 15
      netbox/netbox/settings.py
  20. 1 0
      netbox/templates/circuits/circuittermination_edit.html
  21. 9 0
      netbox/templates/dcim/cable_connect.html
  22. 7 1
      netbox/templates/dcim/powerfeed_edit.html
  23. 1 0
      netbox/templates/dcim/rack_edit.html
  24. 1 0
      netbox/templates/dcim/rackreservation_edit.html
  25. 1 0
      netbox/templates/dcim/virtualchassis_add.html
  26. 1 0
      netbox/templates/ipam/ipaddress_edit.html
  27. 1 0
      netbox/templates/ipam/prefix_edit.html
  28. 8 2
      netbox/templates/ipam/vlan_edit.html
  29. 1 0
      netbox/templates/virtualization/cluster_edit.html
  30. 9 0
      netbox/templates/virtualization/vminterface.html
  31. 4 12
      netbox/tenancy/forms.py
  32. 2 3
      netbox/utilities/filters.py
  33. 14 2
      netbox/utilities/forms/fields.py
  34. 5 1
      netbox/utilities/middleware.py
  35. 2 1
      netbox/utilities/tests/test_filters.py
  36. 19 4
      netbox/utilities/views.py
  37. 25 12
      netbox/virtualization/forms.py

+ 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
 ## 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
 ```no-highlight
-python3 manage.py invalidate all
+# source /opt/netbox/venv/bin/activate
+(venv) # python3 manage.py invalidate all
 ```
 ```

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

@@ -1,5 +1,25 @@
 # NetBox v2.9
 # NetBox v2.9
 
 
+## v2.9.9 (2020-11-09)
+
+### 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
+* [#5327](https://github.com/netbox-community/netbox/issues/5327) - Be more strict when capturing anticipated ImportError exceptions
+
+### Bug Fixes
+
+* [#5271](https://github.com/netbox-community/netbox/issues/5271) - Fix auto-population of region field when editing a device
+* [#5314](https://github.com/netbox-community/netbox/issues/5314) - Fix config context rendering when multiple tags are assigned to an object
+* [#5316](https://github.com/netbox-community/netbox/issues/5316) - Dry running scripts should not trigger webhooks
+* [#5324](https://github.com/netbox-community/netbox/issues/5324) - Add missing template extension tags for plugins for VM interface view
+* [#5328](https://github.com/netbox-community/netbox/issues/5328) - Fix CreatedUpdatedFilterTest when running in non-UTC timezone
+* [#5331](https://github.com/netbox-community/netbox/issues/5331) - Fix filtering of sites by null region
+
+
+---
+
 ## v2.9.8 (2020-10-30)
 ## v2.9.8 (2020-10-30)
 
 
 ### Enhancements
 ### Enhancements

+ 12 - 2
netbox/circuits/forms.py

@@ -303,14 +303,24 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
 #
 #
 
 
 class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
 class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
-        queryset=Site.objects.all()
+        queryset=Site.objects.all(),
+        query_params={
+            'region_id': '$region'
+        }
     )
     )
 
 
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         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 = {
         help_texts = {
             'port_speed': "Physical circuit speed",
             'port_speed': "Physical circuit speed",

+ 9 - 7
netbox/dcim/api/views.py

@@ -396,9 +396,7 @@ class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
         if device.platform is None:
         if device.platform is None:
             raise ServiceUnavailable("No platform is configured for this device.")
             raise ServiceUnavailable("No platform is configured for this device.")
         if not device.platform.napalm_driver:
         if not device.platform.napalm_driver:
-            raise ServiceUnavailable("No NAPALM driver is configured for this device's platform {}.".format(
-                device.platform
-            ))
+            raise ServiceUnavailable(f"No NAPALM driver is configured for this device's platform: {device.platform}.")
 
 
         # Check for primary IP address from NetBox object
         # Check for primary IP address from NetBox object
         if device.primary_ip:
         if device.primary_ip:
@@ -407,21 +405,25 @@ class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
             # Raise exception for no IP address and no Name if device.name does not exist
             # Raise exception for no IP address and no Name if device.name does not exist
             if not device.name:
             if not device.name:
                 raise ServiceUnavailable(
                 raise ServiceUnavailable(
-                    "This device does not have a primary IP address or device name to lookup configured.")
+                    "This device does not have a primary IP address or device name to lookup configured."
+                )
             try:
             try:
                 # Attempt to complete a DNS name resolution if no primary_ip is set
                 # Attempt to complete a DNS name resolution if no primary_ip is set
                 host = socket.gethostbyname(device.name)
                 host = socket.gethostbyname(device.name)
             except socket.gaierror:
             except socket.gaierror:
                 # Name lookup failure
                 # Name lookup failure
                 raise ServiceUnavailable(
                 raise ServiceUnavailable(
-                    f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or setup name resolution.")
+                    f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or "
+                    f"setup name resolution.")
 
 
         # Check that NAPALM is installed
         # Check that NAPALM is installed
         try:
         try:
             import napalm
             import napalm
             from napalm.base.exceptions import ModuleImportError
             from napalm.base.exceptions import ModuleImportError
-        except ImportError:
-            raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
+        except ModuleNotFoundError as e:
+            if getattr(e, 'name') == 'napalm':
+                raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
+            raise e
 
 
         # Validate the configured driver
         # Validate the configured driver
         try:
         try:

+ 199 - 66
netbox/dcim/forms.py

@@ -352,8 +352,18 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
 #
 #
 
 
 class RackGroupForm(BootstrapMixin, forms.ModelForm):
 class RackGroupForm(BootstrapMixin, forms.ModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
-        queryset=Site.objects.all()
+        queryset=Site.objects.all(),
+        query_params={
+            'region_id': '$region'
+        }
     )
     )
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=RackGroup.objects.all(),
         queryset=RackGroup.objects.all(),
@@ -367,7 +377,7 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = RackGroup
         model = RackGroup
         fields = (
         fields = (
-            'site', 'parent', 'name', 'slug', 'description',
+            'region', 'site', 'parent', 'name', 'slug', 'description',
         )
         )
 
 
 
 
@@ -447,14 +457,17 @@ class RackRoleCSVForm(CSVModelForm):
 #
 #
 
 
 class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 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,
         required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
         query_params={
         query_params={
-            'site_id': '$site'
+            'region_id': '$region'
         }
         }
     )
     )
     role = DynamicModelChoiceField(
     role = DynamicModelChoiceField(
@@ -470,8 +483,9 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     class Meta:
     class Meta:
         model = Rack
         model = Rack
         fields = [
         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 = {
         help_texts = {
             'site': "The site at which the rack exists",
             'site': "The site at which the rack exists",
@@ -548,9 +562,19 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
     )
     )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$region'
+        }
     )
     )
     group = DynamicModelChoiceField(
     group = DynamicModelChoiceField(
         queryset=RackGroup.objects.all(),
         queryset=RackGroup.objects.all(),
@@ -691,9 +715,19 @@ class RackElevationFilterForm(RackFilterForm):
 #
 #
 
 
 class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
 class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$region'
+        }
     )
     )
     rack_group = DynamicModelChoiceField(
     rack_group = DynamicModelChoiceField(
         queryset=RackGroup.objects.all(),
         queryset=RackGroup.objects.all(),
@@ -707,7 +741,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
         display_field='display_name',
         display_field='display_name',
         query_params={
         query_params={
             'site_id': '$site',
             'site_id': '$site',
-            'group_id': 'rack',
+            'group_id': '$rack',
         }
         }
     )
     )
     units = NumericArrayField(
     units = NumericArrayField(
@@ -809,15 +843,23 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor
 
 
 class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
 class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
     model = RackReservation
     model = RackReservation
-    field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant']
+    field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant']
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
+    region = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False
+    )
     site = DynamicModelMultipleChoiceField(
     site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     )
     group_id = DynamicModelMultipleChoiceField(
     group_id = DynamicModelMultipleChoiceField(
         queryset=RackGroup.objects.prefetch_related('site'),
         queryset=RackGroup.objects.prefetch_related('site'),
@@ -1672,7 +1714,10 @@ class PlatformCSVForm(CSVModelForm):
 class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     region = DynamicModelChoiceField(
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        required=False
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
     )
     )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
@@ -1686,6 +1731,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         display_field='display_name',
         display_field='display_name',
         query_params={
         query_params={
             'site_id': '$site'
             'site_id': '$site'
+        },
+        initial_params={
+            'racks': '$rack'
         }
         }
     )
     )
     rack = DynamicModelChoiceField(
     rack = DynamicModelChoiceField(
@@ -1711,7 +1759,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     )
     )
     manufacturer = DynamicModelChoiceField(
     manufacturer = DynamicModelChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
-        required=False
+        required=False,
+        initial_params={
+            'device_types': '$device_type'
+        }
     )
     )
     device_type = DynamicModelChoiceField(
     device_type = DynamicModelChoiceField(
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),
@@ -1733,7 +1784,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     cluster_group = DynamicModelChoiceField(
     cluster_group = DynamicModelChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         required=False,
         required=False,
-        null_option='None'
+        null_option='None',
+        initial_params={
+            'clusters': '$cluster'
+        }
     )
     )
     cluster = DynamicModelChoiceField(
     cluster = DynamicModelChoiceField(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
@@ -1772,27 +1826,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     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)
         super().__init__(*args, **kwargs)
 
 
         if self.instance.pk:
         if self.instance.pk:
@@ -3426,10 +3459,18 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
     """
     """
     Base form for connecting a Cable to a Device component
     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(
     termination_b_site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site',
         label='Site',
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$termination_b_region'
+        }
     )
     )
     termination_b_rack = DynamicModelChoiceField(
     termination_b_rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
@@ -3455,8 +3496,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = Cable
         model = Cable
         fields = [
         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 = {
         widgets = {
             'status': StaticSelect2,
             'status': StaticSelect2,
@@ -3553,10 +3594,18 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
         label='Provider',
         label='Provider',
         required=False
         required=False
     )
     )
+    termination_b_region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        label='Region',
+        required=False
+    )
     termination_b_site = DynamicModelChoiceField(
     termination_b_site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site',
         label='Site',
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$termination_b_region'
+        }
     )
     )
     termination_b_circuit = DynamicModelChoiceField(
     termination_b_circuit = DynamicModelChoiceField(
         queryset=Circuit.objects.all(),
         queryset=Circuit.objects.all(),
@@ -3580,8 +3629,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = Cable
         model = Cable
         fields = [
         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):
     def clean_termination_b_id(self):
@@ -3590,11 +3639,18 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
 
 
 
 
 class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
 class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
+    termination_b_region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        label='Region',
+        required=False
+    )
     termination_b_site = DynamicModelChoiceField(
     termination_b_site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site',
         label='Site',
         required=False,
         required=False,
-        display_field='cid'
+        query_params={
+            'region_id': '$termination_b_region'
+        }
     )
     )
     termination_b_rackgroup = DynamicModelChoiceField(
     termination_b_rackgroup = DynamicModelChoiceField(
         queryset=RackGroup.objects.all(),
         queryset=RackGroup.objects.all(),
@@ -3836,10 +3892,18 @@ class CableFilterForm(BootstrapMixin, forms.Form):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
+    region = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False
+    )
     site = DynamicModelMultipleChoiceField(
     site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     )
     tenant = DynamicModelMultipleChoiceField(
     tenant = DynamicModelMultipleChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
@@ -3888,10 +3952,18 @@ class CableFilterForm(BootstrapMixin, forms.Form):
 #
 #
 
 
 class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
 class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
+    region = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False
+    )
     site = DynamicModelMultipleChoiceField(
     site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     )
     device_id = DynamicModelMultipleChoiceField(
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
@@ -3904,10 +3976,18 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
 
 
 
 
 class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
 class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
+    region = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False
+    )
     site = DynamicModelMultipleChoiceField(
     site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     )
     device_id = DynamicModelMultipleChoiceField(
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
@@ -3920,10 +4000,18 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
 
 
 
 
 class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
 class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
+    region = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False
+    )
     site = DynamicModelMultipleChoiceField(
     site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     )
     device_id = DynamicModelMultipleChoiceField(
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
@@ -3947,9 +4035,19 @@ class DeviceSelectionForm(forms.Form):
 
 
 
 
 class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
 class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$region'
+        }
     )
     )
     rack = DynamicModelChoiceField(
     rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
@@ -3982,7 +4080,7 @@ class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = VirtualChassis
         model = VirtualChassis
         fields = [
         fields = [
-            'name', 'domain', 'site', 'rack', 'members', 'initial_position', 'tags',
+            'name', 'domain', 'region', 'site', 'rack', 'members', 'initial_position', 'tags',
         ]
         ]
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
@@ -4079,9 +4177,19 @@ class DeviceVCMembershipForm(forms.ModelForm):
 
 
 
 
 class VCMemberSelectForm(BootstrapMixin, forms.Form):
 class VCMemberSelectForm(BootstrapMixin, forms.Form):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$region'
+        }
     )
     )
     rack = DynamicModelChoiceField(
     rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
@@ -4180,8 +4288,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
 #
 #
 
 
 class PowerPanelForm(BootstrapMixin, forms.ModelForm):
 class PowerPanelForm(BootstrapMixin, forms.ModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
-        queryset=Site.objects.all()
+        queryset=Site.objects.all(),
+        query_params={
+            'region_id': '$region'
+        }
     )
     )
     rack_group = DynamicModelChoiceField(
     rack_group = DynamicModelChoiceField(
         queryset=RackGroup.objects.all(),
         queryset=RackGroup.objects.all(),
@@ -4198,7 +4316,7 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = PowerPanel
         model = PowerPanel
         fields = [
         fields = [
-            'site', 'rack_group', 'name', 'tags',
+            'region', 'site', 'rack_group', 'name', 'tags',
         ]
         ]
 
 
 
 
@@ -4233,9 +4351,19 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         queryset=PowerPanel.objects.all(),
         queryset=PowerPanel.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
     )
     )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$region'
+        }
     )
     )
     rack_group = DynamicModelChoiceField(
     rack_group = DynamicModelChoiceField(
         queryset=RackGroup.objects.all(),
         queryset=RackGroup.objects.all(),
@@ -4287,9 +4415,22 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
 #
 #
 
 
 class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
 class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites__powerpanel': '$power_panel'
+        }
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        initial_params={
+            'powerpanel': '$power_panel'
+        },
+        query_params={
+            'region_id': '$region'
+        }
     )
     )
     power_panel = DynamicModelChoiceField(
     power_panel = DynamicModelChoiceField(
         queryset=PowerPanel.objects.all(),
         queryset=PowerPanel.objects.all(),
@@ -4314,7 +4455,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
     class Meta:
     class Meta:
         model = PowerFeed
         model = PowerFeed
         fields = [
         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',
             'max_utilization', 'comments', 'tags',
         ]
         ]
         widgets = {
         widgets = {
@@ -4324,14 +4465,6 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
             'phase': StaticSelect2(),
             '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):
 class PowerFeedCSVForm(CustomFieldModelCSVForm):
     site = CSVModelChoiceField(
     site = CSVModelChoiceField(

+ 8 - 5
netbox/dcim/tables.py

@@ -262,12 +262,15 @@ class RackRoleTable(BaseTable):
 
 
 class RackTable(BaseTable):
 class RackTable(BaseTable):
     pk = ToggleColumn()
     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(
     tenant = tables.TemplateColumn(
         template_code=COL_TENANT
         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('face', form.errors)
         self.assertIn('position', 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):
 class LabelTestCase(TestCase):
 
 

+ 4 - 1
netbox/dcim/views.py

@@ -2043,8 +2043,11 @@ class CableCreateView(ObjectEditView):
         initial_data = {k: request.GET[k] for k in request.GET}
         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)
         # 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:
         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:
         if 'termination_b_rack' not in initial_data:
             initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None)
             initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None)
 
 

+ 2 - 2
netbox/extras/querysets.py

@@ -60,7 +60,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
             Q(tenants=obj.tenant) | Q(tenants=None),
             Q(tenants=obj.tenant) | Q(tenants=None),
             Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None),
             Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None),
             is_active=True,
             is_active=True,
-        ).order_by('weight', 'name')
+        ).order_by('weight', 'name').distinct()
 
 
         if aggregate_data:
         if aggregate_data:
             return queryset.aggregate(
             return queryset.aggregate(
@@ -95,7 +95,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
                     _data=EmptyGroupByJSONBAgg('data', ordering=['weight', 'name'])
                     _data=EmptyGroupByJSONBAgg('data', ordering=['weight', 'name'])
                 ).values("_data")
                 ).values("_data")
             )
             )
-        )
+        ).distinct()
 
 
     def _get_config_context_filters(self):
     def _get_config_context_filters(self):
         # Construct the set of Q objects for the specific object types
         # Construct the set of Q objects for the specific object types

+ 13 - 2
netbox/extras/scripts.py

@@ -441,8 +441,11 @@ def run_script(data, request, commit=True, *args, **kwargs):
             f"with NetBox v2.10."
             f"with NetBox v2.10."
         )
         )
 
 
-    with change_logging(request):
-
+    def _run_script():
+        """
+        Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
+        the change_logging context manager (which is bypassed if commit == False).
+        """
         try:
         try:
             with transaction.atomic():
             with transaction.atomic():
                 script.output = script.run(**kwargs)
                 script.output = script.run(**kwargs)
@@ -469,6 +472,14 @@ def run_script(data, request, commit=True, *args, **kwargs):
 
 
         logger.info(f"Script completed in {job_result.duration}")
         logger.info(f"Script completed in {job_result.duration}")
 
 
+    # Execute the script. If commit is True, wrap it with the change_logging context manager to ensure we process
+    # change logging, webhooks, etc.
+    if commit:
+        with change_logging(request):
+            _run_script()
+    else:
+        _run_script()
+
     # Delete any previous terminal state results
     # Delete any previous terminal state results
     JobResult.objects.filter(
     JobResult.objects.filter(
         obj_type=job_result.obj_type,
         obj_type=job_result.obj_type,

+ 1 - 1
netbox/extras/templatetags/custom_links.py

@@ -16,7 +16,7 @@ GROUP_BUTTON = '<div class="btn-group">\n' \
                '{} <span class="caret"></span>\n' \
                '{} <span class="caret"></span>\n' \
                '</button>\n' \
                '</button>\n' \
                '<ul class="dropdown-menu pull-right">\n' \
                '<ul class="dropdown-menu pull-right">\n' \
-               '{}</ul></div>'
+               '{}</ul></div>\n'
 GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n'
 GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n'
 
 
 
 

+ 1 - 1
netbox/extras/tests/dummy_plugin/template_content.py

@@ -13,7 +13,7 @@ class SiteContent(PluginTemplateExtension):
     def full_width_page(self):
     def full_width_page(self):
         return "SITE CONTENT - FULL WIDTH PAGE"
         return "SITE CONTENT - FULL WIDTH PAGE"
 
 
-    def full_buttons(self):
+    def buttons(self):
         return "SITE CONTENT - BUTTONS"
         return "SITE CONTENT - BUTTONS"
 
 
 
 

+ 3 - 3
netbox/extras/tests/test_api.py

@@ -3,7 +3,7 @@ from unittest import skipIf
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
-from django.utils import timezone
+from django.utils.timezone import make_aware
 from django_rq.queues import get_connection
 from django_rq.queues import get_connection
 from rest_framework import status
 from rest_framework import status
 from rq import Worker
 from rq import Worker
@@ -369,8 +369,8 @@ class CreatedUpdatedFilterTest(APITestCase):
 
 
         # change the created and last_updated of one
         # change the created and last_updated of one
         Rack.objects.filter(pk=self.rack2.pk).update(
         Rack.objects.filter(pk=self.rack2.pk).update(
-            last_updated=datetime.datetime(2001, 2, 3, 1, 2, 3, 4, tzinfo=timezone.utc),
-            created=datetime.datetime(2001, 2, 3)
+            last_updated=make_aware(datetime.datetime(2001, 2, 3, 1, 2, 3, 4)),
+            created=make_aware(datetime.datetime(2001, 2, 3))
         )
         )
 
 
     def test_get_rack_created(self):
     def test_get_rack_created(self):

+ 35 - 0
netbox/extras/tests/test_models.py

@@ -75,6 +75,7 @@ class ConfigContextTest(TestCase):
         self.tenantgroup = TenantGroup.objects.create(name="Tenant Group")
         self.tenantgroup = TenantGroup.objects.create(name="Tenant Group")
         self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup)
         self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup)
         self.tag = Tag.objects.create(name="Tag", slug="tag")
         self.tag = Tag.objects.create(name="Tag", slug="tag")
+        self.tag2 = Tag.objects.create(name="Tag2", slug="tag2")
 
 
         self.device = Device.objects.create(
         self.device = Device.objects.create(
             name='Device 1',
             name='Device 1',
@@ -328,3 +329,37 @@ class ConfigContextTest(TestCase):
 
 
         annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
         annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
         self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
         self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
+
+    def test_multiple_tags_return_distinct_objects(self):
+        """
+        Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
+        This is combatted by by appending distinct() to the config context querysets. This test creates a config
+        context assigned to two tags and ensures objects related by those same two tags result in only a single
+        config context record being returned.
+
+        See https://github.com/netbox-community/netbox/issues/5314
+        """
+        tag_context = ConfigContext.objects.create(
+            name="tag",
+            weight=100,
+            data={
+                "tag": 1
+            }
+        )
+        tag_context.tags.add(self.tag)
+        tag_context.tags.add(self.tag2)
+
+        device = Device.objects.create(
+            name="Device 3",
+            site=self.site,
+            tenant=self.tenant,
+            platform=self.platform,
+            device_role=self.devicerole,
+            device_type=self.devicetype
+        )
+        device.tags.add(self.tag)
+        device.tags.add(self.tag2)
+
+        annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
+        self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
+        self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())

+ 75 - 19
netbox/ipam/forms.py

@@ -253,10 +253,20 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         label='VRF',
         label='VRF',
         display_field='display_name'
         display_field='display_name'
     )
     )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
-        null_option='None'
+        null_option='None',
+        query_params={
+            'region_id': '$region'
+        }
     )
     )
     vlan_group = DynamicModelChoiceField(
     vlan_group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
@@ -265,6 +275,9 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         null_option='None',
         null_option='None',
         query_params={
         query_params={
             'site_id': '$site'
             'site_id': '$site'
+        },
+        initial_params={
+            'vlans': '$vlan'
         }
         }
     )
     )
     vlan = DynamicModelChoiceField(
     vlan = DynamicModelChoiceField(
@@ -297,14 +310,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     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)
         super().__init__(*args, **kwargs)
 
 
         self.fields['vrf'].empty_label = 'Global'
         self.fields['vrf'].empty_label = 'Global'
@@ -374,9 +379,17 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         queryset=Prefix.objects.all(),
         queryset=Prefix.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        to_field_name='slug'
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     )
     vrf = DynamicModelChoiceField(
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
@@ -501,7 +514,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
     device = DynamicModelChoiceField(
     device = DynamicModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,
-        display_field='display_name'
+        display_field='display_name',
+        initial_params={
+            'interfaces': '$interface'
+        }
     )
     )
     interface = DynamicModelChoiceField(
     interface = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
@@ -512,7 +528,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
     )
     )
     virtual_machine = DynamicModelChoiceField(
     virtual_machine = DynamicModelChoiceField(
         queryset=VirtualMachine.objects.all(),
         queryset=VirtualMachine.objects.all(),
-        required=False
+        required=False,
+        initial_params={
+            'interfaces': '$vminterface'
+        }
     )
     )
     vminterface = DynamicModelChoiceField(
     vminterface = DynamicModelChoiceField(
         queryset=VMInterface.objects.all(),
         queryset=VMInterface.objects.all(),
@@ -528,10 +547,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
         label='VRF',
         label='VRF',
         display_field='display_name'
         display_field='display_name'
     )
     )
+    nat_region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label='Region',
+        initial_params={
+            'sites': '$nat_site'
+        }
+    )
     nat_site = DynamicModelChoiceField(
     nat_site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
-        label='Site'
+        label='Site',
+        query_params={
+            'region_id': '$nat_region'
+        }
     )
     )
     nat_rack = DynamicModelChoiceField(
     nat_rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
@@ -611,10 +641,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
         initial = kwargs.get('initial', {}).copy()
         initial = kwargs.get('initial', {}).copy()
         if instance:
         if instance:
             if type(instance.assigned_object) is Interface:
             if type(instance.assigned_object) is Interface:
-                initial['device'] = instance.assigned_object.device
                 initial['interface'] = instance.assigned_object
                 initial['interface'] = instance.assigned_object
             elif type(instance.assigned_object) is VMInterface:
             elif type(instance.assigned_object) is VMInterface:
-                initial['virtual_machine'] = instance.assigned_object.virtual_machine
                 initial['vminterface'] = instance.assigned_object
                 initial['vminterface'] = instance.assigned_object
             if instance.nat_inside:
             if instance.nat_inside:
                 nat_inside_parent = instance.nat_inside.assigned_object
                 nat_inside_parent = instance.nat_inside.assigned_object
@@ -925,16 +953,26 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
 #
 #
 
 
 class VLANGroupForm(BootstrapMixin, forms.ModelForm):
 class VLANGroupForm(BootstrapMixin, forms.ModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$region'
+        }
     )
     )
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
         fields = [
         fields = [
-            'site', 'name', 'slug', 'description',
+            'region', 'site', 'name', 'slug', 'description',
         ]
         ]
 
 
 
 
@@ -974,10 +1012,20 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
 #
 #
 
 
 class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
-        null_option='None'
+        null_option='None',
+        query_params={
+            'region_id': '$region'
+        }
     )
     )
     group = DynamicModelChoiceField(
     group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
@@ -1066,9 +1114,17 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        to_field_name='slug'
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     )
     group = DynamicModelChoiceField(
     group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),

+ 14 - 9
netbox/netbox/authentication.py

@@ -137,19 +137,24 @@ class LDAPBackend:
 
 
     def __new__(cls, *args, **kwargs):
     def __new__(cls, *args, **kwargs):
         try:
         try:
-            import ldap
             from django_auth_ldap.backend import LDAPBackend as LDAPBackend_, LDAPSettings
             from django_auth_ldap.backend import LDAPBackend as LDAPBackend_, LDAPSettings
-        except ImportError:
-            raise ImproperlyConfigured(
-                "LDAP authentication has been configured, but django-auth-ldap is not installed."
-            )
+            import ldap
+        except ModuleNotFoundError as e:
+            if getattr(e, 'name') == 'django_auth_ldap':
+                raise ImproperlyConfigured(
+                    "LDAP authentication has been configured, but django-auth-ldap is not installed."
+                )
+            raise e
 
 
         try:
         try:
             from netbox import ldap_config
             from netbox import ldap_config
-        except ImportError:
-            raise ImproperlyConfigured(
-                "ldap_config.py does not exist"
-            )
+        except ModuleNotFoundError as e:
+            if getattr(e, 'name') == 'ldap_config':
+                raise ImproperlyConfigured(
+                    "LDAP configuration file not found: Check that ldap_config.py has been created alongside "
+                    "configuration.py."
+                )
+            raise e
 
 
         try:
         try:
             getattr(ldap_config, 'AUTH_LDAP_SERVER_URI')
             getattr(ldap_config, 'AUTH_LDAP_SERVER_URI')

+ 21 - 15
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '2.9.8'
+VERSION = '2.9.9'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()
@@ -38,10 +38,12 @@ if platform.python_version_tuple() < ('3', '6'):
 # Import configuration parameters
 # Import configuration parameters
 try:
 try:
     from netbox import configuration
     from netbox import configuration
-except ImportError:
-    raise ImproperlyConfigured(
-        "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
-    )
+except ModuleNotFoundError as e:
+    if getattr(e, 'name') == 'configuration':
+        raise ImproperlyConfigured(
+            "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
+        )
+    raise
 
 
 # Enforce required configuration parameters
 # Enforce required configuration parameters
 for parameter in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS']:
 for parameter in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS']:
@@ -183,11 +185,13 @@ if STORAGE_BACKEND is not None:
 
 
         try:
         try:
             import storages.utils
             import storages.utils
-        except ImportError:
-            raise ImproperlyConfigured(
-                "STORAGE_BACKEND is set to {} but django-storages is not present. It can be installed by running 'pip "
-                "install django-storages'.".format(STORAGE_BACKEND)
-            )
+        except ModuleNotFoundError as e:
+            if getattr(e, 'name') == 'storages':
+                raise ImproperlyConfigured(
+                    f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storages is not present. It can be "
+                    f"installed by running 'pip install django-storages'."
+                )
+            raise e
 
 
         # Monkey-patch django-storages to fetch settings from STORAGE_CONFIG
         # Monkey-patch django-storages to fetch settings from STORAGE_CONFIG
         def _setting(name, default=None):
         def _setting(name, default=None):
@@ -596,11 +600,13 @@ for plugin_name in PLUGINS:
     # Import plugin module
     # Import plugin module
     try:
     try:
         plugin = importlib.import_module(plugin_name)
         plugin = importlib.import_module(plugin_name)
-    except ImportError:
-        raise ImproperlyConfigured(
-            "Unable to import plugin {}: Module not found. Check that the plugin module has been installed within the "
-            "correct Python environment.".format(plugin_name)
-        )
+    except ModuleNotFoundError as e:
+        if getattr(e, 'name') == plugin_name:
+            raise ImproperlyConfigured(
+                "Unable to import plugin {}: Module not found. Check that the plugin module has been installed within the "
+                "correct Python environment.".format(plugin_name)
+            )
+        raise e
 
 
     # Determine plugin config and add to INSTALLED_APPS.
     # Determine plugin config and add to INSTALLED_APPS.
     try:
     try:

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

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

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

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

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

@@ -3,10 +3,16 @@
 
 
 {% block form %}
 {% block form %}
     <div class="panel panel-default">
     <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">
         <div class="panel-body">
+            {% render_field form.region %}
             {% render_field form.site %}
             {% render_field form.site %}
             {% render_field form.power_panel %}
             {% 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.rack %}
             {% render_field form.name %}
             {% render_field form.name %}
             {% render_field form.status %}
             {% render_field form.status %}

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

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

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

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

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

@@ -62,6 +62,7 @@
             </ul>
             </ul>
             <div class="tab-content">
             <div class="tab-content">
                 <div class="tab-pane active" id="by_device">
                 <div class="tab-pane active" id="by_device">
+                    {% render_field form.nat_region %}
                     {% render_field form.nat_site %}
                     {% render_field form.nat_site %}
                     {% render_field form.nat_rack %}
                     {% render_field form.nat_rack %}
                     {% render_field form.nat_device %}
                     {% 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 panel-default">
         <div class="panel-heading"><strong>Site/VLAN Assignment</strong></div>
         <div class="panel-heading"><strong>Site/VLAN Assignment</strong></div>
         <div class="panel-body">
         <div class="panel-body">
+            {% render_field form.region %}
             {% render_field form.site %}
             {% render_field form.site %}
             {% render_field form.vlan_group %}
             {% render_field form.vlan_group %}
             {% render_field form.vlan %}
             {% render_field form.vlan %}

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

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

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

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

+ 9 - 0
netbox/templates/virtualization/vminterface.html

@@ -1,5 +1,6 @@
 {% extends 'base.html' %}
 {% extends 'base.html' %}
 {% load helpers %}
 {% load helpers %}
+{% load plugins %}
 
 
 {% block header %}
 {% block header %}
     <div class="row noprint">
     <div class="row noprint">
@@ -12,6 +13,7 @@
         </div>
         </div>
     </div>
     </div>
     <div class="pull-right noprint">
     <div class="pull-right noprint">
+        {% plugin_buttons vminterface %}
         {% if perms.virtualization.change_vminterface %}
         {% if perms.virtualization.change_vminterface %}
             <a href="{% url 'virtualization:vminterface_edit' pk=vminterface.pk %}" class="btn btn-warning">
             <a href="{% url 'virtualization:vminterface_edit' pk=vminterface.pk %}" class="btn btn-warning">
                 <span class="fa fa-pencil" aria-hidden="true"></span> Edit
                 <span class="fa fa-pencil" aria-hidden="true"></span> Edit
@@ -82,9 +84,11 @@
                 </tr>
                 </tr>
             </table>
             </table>
         </div>
         </div>
+          {% plugin_left_page vminterface %}
     </div>
     </div>
     <div class="col-md-6">
     <div class="col-md-6">
         {% include 'extras/inc/tags_panel.html' with tags=vminterface.tags.all %}
         {% include 'extras/inc/tags_panel.html' with tags=vminterface.tags.all %}
+          {% plugin_right_page vminterface %}
     </div>
     </div>
 </div>
 </div>
 <div class="row">
 <div class="row">
@@ -97,4 +101,9 @@
         {% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
         {% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
     </div>
     </div>
 </div>
 </div>
+    <div class="row">
+        <div class="col-md-12">
+            {% plugin_full_width_page vminterface %}
+        </div>
+    </div>
 {% endblock %}
 {% endblock %}

+ 4 - 12
netbox/tenancy/forms.py

@@ -119,7 +119,10 @@ class TenancyForm(forms.Form):
     tenant_group = DynamicModelChoiceField(
     tenant_group = DynamicModelChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         required=False,
         required=False,
-        null_option='None'
+        null_option='None',
+        initial_params={
+            'tenants': '$tenant'
+        }
     )
     )
     tenant = DynamicModelChoiceField(
     tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         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):
 class TenancyFilterForm(forms.Form):
     tenant_group = DynamicModelMultipleChoiceField(
     tenant_group = DynamicModelMultipleChoiceField(

+ 2 - 3
netbox/utilities/filters.py

@@ -68,11 +68,10 @@ class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
     """
     """
     Filters for a set of Models, including all descendant models within a Tree.  Example: [<Region: R1>,<Region: R2>]
     Filters for a set of Models, including all descendant models within a Tree.  Example: [<Region: R1>,<Region: R2>]
     """
     """
-
     def get_filter_predicate(self, v):
     def get_filter_predicate(self, v):
-        # null value filtering
+        # Null value filtering
         if v is None:
         if v is None:
-            return {self.field_name.replace('in', 'isnull'): True}
+            return {f"{self.field_name}__isnull": True}
         return super().get_filter_predicate(v)
         return super().get_filter_predicate(v)
 
 
     def filter(self, qs, value):
     def filter(self, qs, value):

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

@@ -248,6 +248,7 @@ class DynamicModelChoiceMixin:
     """
     """
     :param display_field: The name of the attribute of an API response object to display in the selection list
     :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 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 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
     :param disabled_indicator: The name of the field which, if populated, will disable selection of the
         choice (optional)
         choice (optional)
@@ -256,10 +257,11 @@ class DynamicModelChoiceMixin:
     filter = django_filters.ModelChoiceFilter
     filter = django_filters.ModelChoiceFilter
     widget = widgets.APISelect
     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.display_field = display_field
         self.query_params = query_params or {}
         self.query_params = query_params or {}
+        self.initial_params = initial_params or {}
         self.null_option = null_option
         self.null_option = null_option
         self.disabled_indicator = disabled_indicator
         self.disabled_indicator = disabled_indicator
         self.brief_mode = brief_mode
         self.brief_mode = brief_mode
@@ -300,6 +302,16 @@ class DynamicModelChoiceMixin:
     def get_bound_field(self, form, field_name):
     def get_bound_field(self, form, field_name):
         bound_field = BoundField(form, self, 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
         # 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.
         # will be populated on-demand via the APISelect widget.
         data = bound_field.value()
         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 django.urls import reverse
 
 
 from .api import is_api_request
 from .api import is_api_request
-from .views import server_error
+from .views import server_error, rest_api_server_error
 
 
 
 
 class LoginRequiredMiddleware(object):
 class LoginRequiredMiddleware(object):
@@ -86,6 +86,10 @@ class ExceptionHandlingMiddleware(object):
         if isinstance(exception, Http404):
         if isinstance(exception, Http404):
             return
             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.
         # Determine the type of exception. If it's a common issue, return a custom error page with instructions.
         custom_template = None
         custom_template = None
         if isinstance(exception, ProgrammingError):
         if isinstance(exception, ProgrammingError):

+ 2 - 1
netbox/utilities/tests/test_filters.py

@@ -23,7 +23,8 @@ class TreeNodeMultipleChoiceFilterTest(TestCase):
     class SiteFilterSet(django_filters.FilterSet):
     class SiteFilterSet(django_filters.FilterSet):
         region = TreeNodeMultipleChoiceFilter(
         region = TreeNodeMultipleChoiceFilter(
             queryset=Region.objects.all(),
             queryset=Region.objects.all(),
-            field_name='region__in',
+            field_name='region',
+            lookup_expr='in',
             to_field_name='slug',
             to_field_name='slug',
         )
         )
 
 

+ 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 import transaction, IntegrityError
 from django.db.models import ManyToManyField, ProtectedError
 from django.db.models import ManyToManyField, ProtectedError
 from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
 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.shortcuts import get_object_or_404, redirect, render
 from django.template import loader
 from django.template import loader
 from django.template.exceptions import TemplateDoesNotExist
 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.defaults import ERROR_500_TEMPLATE_NAME
 from django.views.generic import View
 from django.views.generic import View
 from django_tables2 import RequestConfig
 from django_tables2 import RequestConfig
+from rest_framework import status
 
 
 from extras.models import CustomField, CustomFieldValue, ExportTemplate
 from extras.models import CustomField, CustomFieldValue, ExportTemplate
 from extras.querysets import CustomFieldQueryset
 from extras.querysets import CustomFieldQueryset
@@ -1423,8 +1424,22 @@ def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
     type_, error, traceback = sys.exc_info()
     type_, error, traceback = sys.exc_info()
 
 
     return HttpResponseServerError(template.render({
     return HttpResponseServerError(template.render({
-        'python_version': platform.python_version(),
-        'netbox_version': settings.VERSION,
-        'exception': str(type_),
         'error': error,
         '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(),
         queryset=ClusterGroup.objects.all(),
         required=False
         required=False
     )
     )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region_id': '$region'
+        }
     )
     )
     comments = CommentField()
     comments = CommentField()
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
@@ -92,7 +102,7 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
         fields = (
         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(),
         queryset=Tenant.objects.all(),
         required=False
         required=False
     )
     )
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        to_field_name='slug'
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'region': '$region'
+        }
     )
     )
     comments = CommentField(
     comments = CommentField(
         widget=SmallTextarea,
         widget=SmallTextarea,
@@ -266,7 +284,10 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     cluster_group = DynamicModelChoiceField(
     cluster_group = DynamicModelChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         required=False,
         required=False,
-        null_option='None'
+        null_option='None',
+        initial_params={
+            'clusters': '$cluster'
+        }
     )
     )
     cluster = DynamicModelChoiceField(
     cluster = DynamicModelChoiceField(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
@@ -311,14 +332,6 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     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)
         super().__init__(*args, **kwargs)
 
 
         if self.instance.pk:
         if self.instance.pk: