Просмотр исходного кода

Merge remote-tracking branch 'netbox-community/develop' into 3589-interface-tagged-vlans

Saria Hajjar 6 лет назад
Родитель
Сommit
fa55571503

+ 4 - 0
docs/installation/4-ldap.md

@@ -80,6 +80,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
 ```
 ```
 
 
 # User Groups for Permissions
 # User Groups for Permissions
+
 !!! info
 !!! info
     When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`. You will also need to modify the import line to use `NestedGroupOfNamesType` instead of `GroupOfNamesType` .
     When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`. You will also need to modify the import line to use `NestedGroupOfNamesType` instead of `GroupOfNamesType` .
 
 
@@ -117,6 +118,9 @@ AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600
 * `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions.
 * `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions.
 * `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions.
 * `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions.
 
 
+!!! warning
+    Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory.
+
 # Troubleshooting LDAP
 # Troubleshooting LDAP
 
 
 `supervisorctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/supervisor/`.
 `supervisorctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/supervisor/`.

+ 17 - 1
docs/release-notes/version-2.6.md

@@ -1,10 +1,25 @@
-# v2.6.10 (FUTURE)
+# v2.6.11 (2020-01-03)
+
+## Bug Fixes
+
+* [#3831](https://github.com/netbox-community/netbox/issues/3831) - Fix API-driven filter field rendering (#3812 regression)
+* [#3833](https://github.com/netbox-community/netbox/issues/3833) - Add missing region filters for multiple objects
+
+---
+
+# v2.6.10 (2020-01-02)
 
 
 ## Enhancements
 ## Enhancements
 
 
+* [#2233](https://github.com/netbox-community/netbox/issues/2233) - Add ability to move inventory items between devices
+* [#2892](https://github.com/netbox-community/netbox/issues/2892) - Extend admin UI to allow deleting old report results
+* [#3062](https://github.com/netbox-community/netbox/issues/3062) - Add `assigned_to_interface` filter for IP addresses
+* [#3461](https://github.com/netbox-community/netbox/issues/3461) - Fail gracefully on custom link rendering exception
 * [#3705](https://github.com/netbox-community/netbox/issues/3705) - Provide request context when executing custom scripts
 * [#3705](https://github.com/netbox-community/netbox/issues/3705) - Provide request context when executing custom scripts
 * [#3762](https://github.com/netbox-community/netbox/issues/3762) - Add date/time picker widgets
 * [#3762](https://github.com/netbox-community/netbox/issues/3762) - Add date/time picker widgets
 * [#3788](https://github.com/netbox-community/netbox/issues/3788) - Enable partial search for inventory items
 * [#3788](https://github.com/netbox-community/netbox/issues/3788) - Enable partial search for inventory items
+* [#3812](https://github.com/netbox-community/netbox/issues/3812) - Optimize size of pages containing a dynamic selection field
+* [#3827](https://github.com/netbox-community/netbox/issues/3827) - Allow filtering console/power/interface connections by device ID
 
 
 ## Bug Fixes
 ## Bug Fixes
 
 
@@ -15,6 +30,7 @@
 * [#3780](https://github.com/netbox-community/netbox/issues/3780) - Fix AttributeError exception in API docs
 * [#3780](https://github.com/netbox-community/netbox/issues/3780) - Fix AttributeError exception in API docs
 * [#3809](https://github.com/netbox-community/netbox/issues/3809) - Filter platform by manufacturer when editing devices
 * [#3809](https://github.com/netbox-community/netbox/issues/3809) - Filter platform by manufacturer when editing devices
 * [#3811](https://github.com/netbox-community/netbox/issues/3811) - Fix filtering of racks by group on device list
 * [#3811](https://github.com/netbox-community/netbox/issues/3811) - Fix filtering of racks by group on device list
+* [#3822](https://github.com/netbox-community/netbox/issues/3822) - Fix exception when editing a device bay (regression from #3596)
 
 
 ---
 ---
 
 

+ 11 - 0
netbox/circuits/filters.py

@@ -18,6 +18,17 @@ class ProviderFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='circuits__terminations__site__region__in',
+        label='Region (ID)',
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='circuits__terminations__site__region__in',
+        to_field_name='slug',
+        label='Region (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         field_name='circuits__terminations__site',
         field_name='circuits__terminations__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),

+ 15 - 0
netbox/circuits/forms.py

@@ -104,6 +104,18 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
+    region = FilterChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/regions/",
+            value_field="slug",
+            filter_for={
+                'site': 'region'
+            }
+        )
+    )
     site = FilterChoiceField(
     site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
@@ -302,6 +314,9 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/regions/",
             api_url="/api/dcim/regions/",
             value_field="slug",
             value_field="slug",
+            filter_for={
+                'site': 'region'
+            }
         )
         )
     )
     )
     site = FilterChoiceField(
     site = FilterChoiceField(

+ 102 - 16
netbox/dcim/filters.py

@@ -93,6 +93,17 @@ class SiteFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet
 
 
 
 
 class RackGroupFilter(NameSlugSearchFilterSet):
 class RackGroupFilter(NameSlugSearchFilterSet):
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region__in',
+        label='Region (ID)',
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region__in',
+        to_field_name='slug',
+        label='Region (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
@@ -125,6 +136,17 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region__in',
+        label='Region (ID)',
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region__in',
+        to_field_name='slug',
+        label='Region (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
@@ -831,6 +853,28 @@ class InventoryItemFilter(DeviceComponentFilterSet):
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='device__site__region__in',
+        label='Region (ID)',
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='device__site__region__in',
+        to_field_name='slug',
+        label='Region (slug)',
+    )
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__site',
+        queryset=Site.objects.all(),
+        label='Site (ID)',
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__site__slug',
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        label='Site name (slug)',
+    )
     device_id = django_filters.ModelChoiceFilter(
     device_id = django_filters.ModelChoiceFilter(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         label='Device (ID)',
         label='Device (ID)',
@@ -880,6 +924,17 @@ class VirtualChassisFilter(django_filters.FilterSet):
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='master__site__region__in',
+        label='Region (ID)',
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='master__site__region__in',
+        to_field_name='slug',
+        label='Region (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         field_name='master__site',
         field_name='master__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
@@ -935,7 +990,7 @@ class CableFilter(django_filters.FilterSet):
     device_id = MultiValueNumberFilter(
     device_id = MultiValueNumberFilter(
         method='filter_device'
         method='filter_device'
     )
     )
-    device = MultiValueNumberFilter(
+    device = MultiValueCharFilter(
         method='filter_device',
         method='filter_device',
         field_name='device__name'
         field_name='device__name'
     )
     )
@@ -978,9 +1033,12 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
         method='filter_site',
         method='filter_site',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
-    device = django_filters.CharFilter(
+    device_id = MultiValueNumberFilter(
+        method='filter_device'
+    )
+    device = MultiValueCharFilter(
         method='filter_device',
         method='filter_device',
-        label='Device',
+        field_name='device__name'
     )
     )
 
 
     class Meta:
     class Meta:
@@ -993,11 +1051,11 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
         return queryset.filter(connected_endpoint__device__site__slug=value)
         return queryset.filter(connected_endpoint__device__site__slug=value)
 
 
     def filter_device(self, queryset, name, value):
     def filter_device(self, queryset, name, value):
-        if not value.strip():
+        if not value:
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
-            Q(device__name__icontains=value) |
-            Q(connected_endpoint__device__name__icontains=value)
+            Q(**{'{}__in'.format(name): value}) |
+            Q(**{'connected_endpoint__{}__in'.format(name): value})
         )
         )
 
 
 
 
@@ -1006,9 +1064,12 @@ class PowerConnectionFilter(django_filters.FilterSet):
         method='filter_site',
         method='filter_site',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
-    device = django_filters.CharFilter(
+    device_id = MultiValueNumberFilter(
+        method='filter_device'
+    )
+    device = MultiValueCharFilter(
         method='filter_device',
         method='filter_device',
-        label='Device',
+        field_name='device__name'
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1021,11 +1082,11 @@ class PowerConnectionFilter(django_filters.FilterSet):
         return queryset.filter(_connected_poweroutlet__device__site__slug=value)
         return queryset.filter(_connected_poweroutlet__device__site__slug=value)
 
 
     def filter_device(self, queryset, name, value):
     def filter_device(self, queryset, name, value):
-        if not value.strip():
+        if not value:
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
-            Q(device__name__icontains=value) |
-            Q(_connected_poweroutlet__device__name__icontains=value)
+            Q(**{'{}__in'.format(name): value}) |
+            Q(**{'_connected_poweroutlet__{}__in'.format(name): value})
         )
         )
 
 
 
 
@@ -1034,9 +1095,12 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
         method='filter_site',
         method='filter_site',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
-    device = django_filters.CharFilter(
+    device_id = MultiValueNumberFilter(
+        method='filter_device'
+    )
+    device = MultiValueCharFilter(
         method='filter_device',
         method='filter_device',
-        label='Device',
+        field_name='device__name'
     )
     )
 
 
     class Meta:
     class Meta:
@@ -1052,11 +1116,11 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
         )
         )
 
 
     def filter_device(self, queryset, name, value):
     def filter_device(self, queryset, name, value):
-        if not value.strip():
+        if not value:
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
-            Q(device__name__icontains=value) |
-            Q(_connected_interface__device__name__icontains=value)
+            Q(**{'{}__in'.format(name): value}) |
+            Q(**{'_connected_interface__{}__in'.format(name): value})
         )
         )
 
 
 
 
@@ -1069,6 +1133,17 @@ class PowerPanelFilter(django_filters.FilterSet):
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region__in',
+        label='Region (ID)',
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region__in',
+        to_field_name='slug',
+        label='Region (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
@@ -1107,6 +1182,17 @@ class PowerFeedFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='power_panel__site__region__in',
+        label='Region (ID)',
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='power_panel__site__region__in',
+        to_field_name='slug',
+        label='Region (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         field_name='power_panel__site',
         field_name='power_panel__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),

+ 158 - 31
netbox/dcim/forms.py

@@ -375,6 +375,18 @@ class RackGroupCSVForm(forms.ModelForm):
 
 
 
 
 class RackGroupFilterForm(BootstrapMixin, forms.Form):
 class RackGroupFilterForm(BootstrapMixin, forms.Form):
+    region = FilterChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/regions/",
+            value_field="slug",
+            filter_for={
+                'site': 'region'
+            }
+        )
+    )
     site = FilterChoiceField(
     site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
@@ -646,11 +658,23 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
 
 
 class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
 class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = Rack
     model = Rack
-    field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
+    field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
+    region = FilterChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/regions/",
+            value_field="slug",
+            filter_for={
+                'site': 'region'
+            }
+        )
+    )
     site = FilterChoiceField(
     site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
@@ -662,16 +686,15 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
             }
             }
         )
         )
     )
     )
-    group_id = ChainedModelChoiceField(
-        label='Rack group',
-        queryset=RackGroup.objects.prefetch_related('site'),
-        chains=(
-            ('site', 'site'),
+    group_id = FilterChoiceField(
+        queryset=RackGroup.objects.prefetch_related(
+            'site'
         ),
         ),
-        required=False,
+        label='Rack group',
+        null_label='-- None --',
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/rack-groups/",
             api_url="/api/dcim/rack-groups/",
-            null_option=True,
+            null_option=True
         )
         )
     )
     )
     status = forms.MultipleChoiceField(
     status = forms.MultipleChoiceField(
@@ -3122,9 +3145,13 @@ class CableFilterForm(BootstrapMixin, forms.Form):
         required=False,
         required=False,
         widget=ColorSelect()
         widget=ColorSelect()
     )
     )
-    device = forms.CharField(
+    device_id = FilterChoiceField(
+        queryset=Device.objects.all(),
         required=False,
         required=False,
-        label='Device name'
+        label='Device',
+        widget=APISelectMultiple(
+            api_url='/api/dcim/devices/',
+        )
     )
     )
 
 
 
 
@@ -3189,38 +3216,59 @@ class DeviceBayBulkRenameForm(BulkRenameForm):
 #
 #
 
 
 class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
 class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
-    site = forms.ModelChoiceField(
+    site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False,
-        to_field_name='slug'
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/",
+            value_field="slug",
+        )
     )
     )
-    device = forms.CharField(
+    device_id = FilterChoiceField(
+        queryset=Device.objects.all(),
         required=False,
         required=False,
-        label='Device name'
+        label='Device',
+        widget=APISelectMultiple(
+            api_url='/api/dcim/devices/',
+        )
     )
     )
 
 
 
 
 class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
 class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
-    site = forms.ModelChoiceField(
+    site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False,
-        to_field_name='slug'
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/",
+            value_field="slug",
+        )
     )
     )
-    device = forms.CharField(
+    device_id = FilterChoiceField(
+        queryset=Device.objects.all(),
         required=False,
         required=False,
-        label='Device name'
+        label='Device',
+        widget=APISelectMultiple(
+            api_url='/api/dcim/devices/',
+        )
     )
     )
 
 
 
 
 class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
 class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
-    site = forms.ModelChoiceField(
+    site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False,
-        to_field_name='slug'
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/",
+            value_field="slug",
+        )
     )
     )
-    device = forms.CharField(
+    device_id = FilterChoiceField(
+        queryset=Device.objects.all(),
         required=False,
         required=False,
-        label='Device name'
+        label='Device',
+        widget=APISelectMultiple(
+            api_url='/api/dcim/devices/',
+        )
     )
     )
 
 
 
 
@@ -3236,9 +3284,12 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
         fields = [
         fields = [
-            'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
+            'name', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
+            'device': APISelect(
+                api_url="/api/dcim/devices/"
+            ),
             'manufacturer': APISelect(
             'manufacturer': APISelect(
                 api_url="/api/dcim/manufacturers/"
                 api_url="/api/dcim/manufacturers/"
             )
             )
@@ -3274,9 +3325,19 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):
         queryset=InventoryItem.objects.all(),
         queryset=InventoryItem.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/dcim/devices/"
+        )
+    )
     manufacturer = forms.ModelChoiceField(
     manufacturer = forms.ModelChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
-        required=False
+        required=False,
+        widget=APISelect(
+            api_url="/api/dcim/manufacturers/"
+        )
     )
     )
     part_id = forms.CharField(
     part_id = forms.CharField(
         max_length=50,
         max_length=50,
@@ -3300,18 +3361,48 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    device = forms.CharField(
+    region = FilterChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
         required=False,
         required=False,
-        label='Device name'
+        widget=APISelectMultiple(
+            api_url="/api/dcim/regions/",
+            value_field="slug",
+            filter_for={
+                'site': 'region'
+            }
+        )
+    )
+    site = FilterChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/",
+            value_field="slug",
+            filter_for={
+                'device_id': 'site'
+            }
+        )
+    )
+    device_id = FilterChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        label='Device',
+        widget=APISelect(
+            api_url='/api/dcim/devices/',
+        )
     )
     )
     manufacturer = FilterChoiceField(
     manufacturer = FilterChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --'
+        widget=APISelect(
+            api_url="/api/dcim/manufacturers/",
+            value_field="slug",
+        )
     )
     )
     discovered = forms.NullBooleanField(
     discovered = forms.NullBooleanField(
         required=False,
         required=False,
-        widget=forms.Select(
+        widget=StaticSelect2(
             choices=BOOLEAN_WITH_BLANK_CHOICES
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
         )
     )
     )
@@ -3458,6 +3549,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
+    region = FilterChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/regions/",
+            value_field="slug",
+            filter_for={
+                'site': 'region'
+            }
+        )
+    )
     site = FilterChoiceField(
     site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
@@ -3563,6 +3666,18 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
+    region = FilterChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/regions/",
+            value_field="slug",
+            filter_for={
+                'site': 'region'
+            }
+        )
+    )
     site = FilterChoiceField(
     site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
@@ -3783,6 +3898,18 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
+    region = FilterChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/regions/",
+            value_field="slug",
+            filter_for={
+                'site': 'region'
+            }
+        )
+    )
     site = FilterChoiceField(
     site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',

+ 1 - 1
netbox/dcim/models.py

@@ -2597,7 +2597,7 @@ class DeviceBay(ComponentModel):
         # Check that the installed device is not already installed elsewhere
         # Check that the installed device is not already installed elsewhere
         if self.installed_device:
         if self.installed_device:
             current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
             current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
-            if current_bay:
+            if current_bay and current_bay != self:
                 raise ValidationError({
                 raise ValidationError({
                     'installed_device': "Cannot install the specified device; device is already installed in {}".format(
                     'installed_device': "Cannot install the specified device; device is already installed in {}".format(
                         current_bay
                         current_bay

+ 34 - 1
netbox/extras/admin.py

@@ -3,7 +3,10 @@ from django.contrib import admin
 
 
 from netbox.admin import admin_site
 from netbox.admin import admin_site
 from utilities.forms import LaxURLField
 from utilities.forms import LaxURLField
-from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, TopologyMap, Webhook
+from .models import (
+    CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, ReportResult, TopologyMap, Webhook,
+)
+from .reports import get_report
 
 
 
 
 def order_content_types(field):
 def order_content_types(field):
@@ -166,6 +169,36 @@ class ExportTemplateAdmin(admin.ModelAdmin):
     form = ExportTemplateForm
     form = ExportTemplateForm
 
 
 
 
+#
+# Reports
+#
+
+@admin.register(ReportResult, site=admin_site)
+class ReportResultAdmin(admin.ModelAdmin):
+    list_display = [
+        'report', 'active', 'created', 'user', 'passing',
+    ]
+    fields = [
+        'report', 'user', 'passing', 'data',
+    ]
+    list_filter = [
+        'failed',
+    ]
+    readonly_fields = fields
+
+    def has_add_permission(self, request):
+        return False
+
+    def active(self, obj):
+        module, report_name = obj.report.split('.')
+        return True if get_report(module, report_name) else False
+    active.boolean = True
+
+    def passing(self, obj):
+        return not obj.failed
+    passing.boolean = True
+
+
 #
 #
 # Topology maps
 # Topology maps
 #
 #

+ 4 - 2
netbox/extras/forms.py

@@ -52,7 +52,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
             else:
             else:
                 initial = None
                 initial = None
             field = forms.NullBooleanField(
             field = forms.NullBooleanField(
-                required=cf.required, initial=initial, widget=forms.Select(choices=choices)
+                required=cf.required, initial=initial, widget=StaticSelect2(choices=choices)
             )
             )
 
 
         # Date
         # Date
@@ -71,7 +71,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
                     default_choice = cf.choices.get(value=initial).pk
                     default_choice = cf.choices.get(value=initial).pk
                 except ObjectDoesNotExist:
                 except ObjectDoesNotExist:
                     pass
                     pass
-            field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required, initial=default_choice)
+            field = forms.TypedChoiceField(
+                choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2()
+            )
 
 
         # URL
         # URL
         elif cf.type == CF_TYPE_URL:
         elif cf.type == CF_TYPE_URL:

+ 7 - 0
netbox/extras/models.py

@@ -915,6 +915,13 @@ class ReportResult(models.Model):
     class Meta:
     class Meta:
         ordering = ['report']
         ordering = ['report']
 
 
+    def __str__(self):
+        return "{} {} at {}".format(
+            self.report,
+            "passed" if not self.failed else "failed",
+            self.created
+        )
+
 
 
 #
 #
 # Change logging
 # Change logging

+ 22 - 14
netbox/extras/templatetags/custom_links.py

@@ -46,12 +46,17 @@ def custom_links(obj):
 
 
         # Add non-grouped links
         # Add non-grouped links
         else:
         else:
-            text_rendered = render_jinja2(cl.text, context)
-            if text_rendered:
-                link_target = ' target="_blank"' if cl.new_window else ''
-                template_code += LINK_BUTTON.format(
-                    cl.url, link_target, cl.button_class, text_rendered
-                )
+            try:
+                text_rendered = render_jinja2(cl.text, context)
+                if text_rendered:
+                    link_rendered = render_jinja2(cl.url, context)
+                    link_target = ' target="_blank"' if cl.new_window else ''
+                    template_code += LINK_BUTTON.format(
+                        link_rendered, link_target, cl.button_class, text_rendered
+                    )
+            except Exception as e:
+                template_code += '<a class="btn btn-sm btn-default" disabled="disabled" title="{}">' \
+                                 '<i class="fa fa-warning"></i> {}</a>\n'.format(e, cl.name)
 
 
     # Add grouped links to template
     # Add grouped links to template
     for group, links in group_names.items():
     for group, links in group_names.items():
@@ -59,11 +64,17 @@ def custom_links(obj):
         links_rendered = []
         links_rendered = []
 
 
         for cl in links:
         for cl in links:
-            text_rendered = render_jinja2(cl.text, context)
-            if text_rendered:
-                link_target = ' target="_blank"' if cl.new_window else ''
+            try:
+                text_rendered = render_jinja2(cl.text, context)
+                if text_rendered:
+                    link_target = ' target="_blank"' if cl.new_window else ''
+                    links_rendered.append(
+                        GROUP_LINK.format(cl.url, link_target, cl.text)
+                    )
+            except Exception as e:
                 links_rendered.append(
                 links_rendered.append(
-                    GROUP_LINK.format(cl.url, link_target, cl.text)
+                    '<li><a disabled="disabled" title="{}"><span class="text-muted">'
+                    '<i class="fa fa-warning"></i> {}</span></a></li>'.format(e, cl.name)
                 )
                 )
 
 
         if links_rendered:
         if links_rendered:
@@ -71,7 +82,4 @@ def custom_links(obj):
                 links[0].button_class, group, ''.join(links_rendered)
                 links[0].button_class, group, ''.join(links_rendered)
             )
             )
 
 
-    # Render template
-    rendered = render_jinja2(template_code, context)
-
-    return mark_safe(rendered)
+    return mark_safe(template_code)

+ 42 - 2
netbox/ipam/filters.py

@@ -4,10 +4,10 @@ from django.core.exceptions import ValidationError
 from django.db.models import Q
 from django.db.models import Q
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
 
 
-from dcim.models import Site, Device, Interface
+from dcim.models import Device, Interface, Region, Site
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
-from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
+from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from .constants import *
 from .constants import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@@ -149,6 +149,17 @@ class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS
         to_field_name='rd',
         to_field_name='rd',
         label='VRF (RD)',
         label='VRF (RD)',
     )
     )
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region__in',
+        label='Region (ID)',
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region__in',
+        to_field_name='slug',
+        label='Region (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
@@ -309,6 +320,10 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         label='Interface (ID)',
         label='Interface (ID)',
     )
     )
+    assigned_to_interface = django_filters.BooleanFilter(
+        method='_assigned_to_interface',
+        label='Is assigned to an interface',
+    )
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=IPADDRESS_STATUS_CHOICES,
         choices=IPADDRESS_STATUS_CHOICES,
         null_value=None
         null_value=None
@@ -366,8 +381,22 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
         except Device.DoesNotExist:
         except Device.DoesNotExist:
             return queryset.none()
             return queryset.none()
 
 
+    def _assigned_to_interface(self, queryset, name, value):
+        return queryset.exclude(interface__isnull=value)
+
 
 
 class VLANGroupFilter(NameSlugSearchFilterSet):
 class VLANGroupFilter(NameSlugSearchFilterSet):
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region__in',
+        label='Region (ID)',
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region__in',
+        to_field_name='slug',
+        label='Region (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
@@ -393,6 +422,17 @@ class VLANFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region__in',
+        label='Region (ID)',
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region__in',
+        to_field_name='slug',
+        label='Region (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',

+ 50 - 5
netbox/ipam/forms.py

@@ -3,7 +3,7 @@ from django.core.exceptions import MultipleObjectsReturned
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
-from dcim.models import Site, Rack, Device, Interface
+from dcim.models import Device, Interface, Rack, Region, Site
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -492,8 +492,8 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
 class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
 class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = Prefix
     model = Prefix
     field_order = [
     field_order = [
-        'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'status', 'site', 'role', 'tenant_group', 'tenant',
-        'is_pool', 'expand',
+        'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'status', 'region', 'site', 'role', 'tenant_group',
+        'tenant', 'is_pool', 'expand',
     ]
     ]
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
@@ -534,6 +534,18 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
         required=False,
         required=False,
         widget=StaticSelect2Multiple()
         widget=StaticSelect2Multiple()
     )
     )
+    region = FilterChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/regions/",
+            value_field="slug",
+            filter_for={
+                'site': 'region'
+            }
+        )
+    )
     site = FilterChoiceField(
     site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
@@ -938,7 +950,8 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
 class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
 class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = IPAddress
     model = IPAddress
     field_order = [
     field_order = [
-        'q', 'parent', 'family', 'mask_length', 'vrf_id', 'status', 'role', 'tenant_group', 'tenant',
+        'q', 'parent', 'family', 'mask_length', 'vrf_id', 'status', 'role', 'assigned_to_interface', 'tenant_group',
+        'tenant',
     ]
     ]
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
@@ -984,6 +997,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
         required=False,
         required=False,
         widget=StaticSelect2Multiple()
         widget=StaticSelect2Multiple()
     )
     )
+    assigned_to_interface = forms.NullBooleanField(
+        required=False,
+        label='Assigned to an interface',
+        widget=StaticSelect2(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
 
 
 
 
 #
 #
@@ -1026,6 +1046,18 @@ class VLANGroupCSVForm(forms.ModelForm):
 
 
 
 
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
+    region = FilterChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/regions/",
+            value_field="slug",
+            filter_for={
+                'site': 'region',
+            }
+        )
+    )
     site = FilterChoiceField(
     site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
@@ -1207,11 +1239,24 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
 
 
 class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
 class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = VLAN
     model = VLAN
-    field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
+    field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
+    region = FilterChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/regions/",
+            value_field="slug",
+            filter_for={
+                'site': 'region',
+                'group_id': 'region'
+            }
+        )
+    )
     site = FilterChoiceField(
     site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',

+ 1 - 1
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '2.6.10-dev'
+VERSION = '2.6.12-dev'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

+ 7 - 3
netbox/project-static/js/forms.js

@@ -103,14 +103,16 @@ $(document).ready(function() {
         placeholder: "---------",
         placeholder: "---------",
         theme: "bootstrap",
         theme: "bootstrap",
         templateResult: colorPickerClassCopy,
         templateResult: colorPickerClassCopy,
-        templateSelection: colorPickerClassCopy
+        templateSelection: colorPickerClassCopy,
+        width: "off"
     });
     });
 
 
     // Static choice selection
     // Static choice selection
     $('.netbox-select2-static').select2({
     $('.netbox-select2-static').select2({
         allowClear: true,
         allowClear: true,
         placeholder: "---------",
         placeholder: "---------",
-        theme: "bootstrap"
+        theme: "bootstrap",
+        width: "off"
     });
     });
 
 
     // API backed selection
     // API backed selection
@@ -120,6 +122,7 @@ $(document).ready(function() {
         allowClear: true,
         allowClear: true,
         placeholder: "---------",
         placeholder: "---------",
         theme: "bootstrap",
         theme: "bootstrap",
+        width: "off",
         ajax: {
         ajax: {
             delay: 500,
             delay: 500,
 
 
@@ -299,7 +302,8 @@ $(document).ready(function() {
         multiple: true,
         multiple: true,
         allowClear: true,
         allowClear: true,
         placeholder: "Tags",
         placeholder: "Tags",
-
+        theme: "bootstrap",
+        width: "off",
         ajax: {
         ajax: {
             delay: 250,
             delay: 250,
             url: netbox_api_path + "extras/tags/",
             url: netbox_api_path + "extras/tags/",

+ 2 - 0
netbox/utilities/forms.py

@@ -285,6 +285,8 @@ class APISelect(SelectWithDisabled):
         name of the query param and the value if the query param's value.
         name of the query param and the value if the query param's value.
     :param null_option: If true, include the static null option in the selection list.
     :param null_option: If true, include the static null option in the selection list.
     """
     """
+    # Only preload the selected option(s); new options are dynamically displayed and added via the API
+    template_name = 'widgets/select_api.html'
 
 
     def __init__(
     def __init__(
         self,
         self,

+ 9 - 0
netbox/utilities/templates/widgets/select_api.html

@@ -0,0 +1,9 @@
+<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
+{% for group_name, group_choices, group_index in widget.optgroups %}
+  {% if group_name %}<optgroup label="{{ group_name }}">{% endif %}
+  {% for option in group_choices %}
+    {% if option.attrs.selected or option.value == "null" %}{% include option.template_name with widget=option %}{% endif %}
+  {% endfor %}
+  {% if group_name %}</optgroup>{% endif %}
+{% endfor %}
+</select>

+ 21 - 10
netbox/virtualization/filters.py

@@ -36,6 +36,27 @@ class ClusterFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region__in',
+        label='Region (ID)',
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='site__region__in',
+        to_field_name='slug',
+        label='Region (slug)',
+    )
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Site.objects.all(),
+        label='Site (ID)',
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        field_name='site__slug',
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        label='Site (slug)',
+    )
     group_id = django_filters.ModelMultipleChoiceFilter(
     group_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         label='Parent group (ID)',
         label='Parent group (ID)',
@@ -56,16 +77,6 @@ class ClusterFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Cluster type (slug)',
         label='Cluster type (slug)',
     )
     )
-    site_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Site.objects.all(),
-        label='Site (ID)',
-    )
-    site = django_filters.ModelMultipleChoiceFilter(
-        field_name='site__slug',
-        queryset=Site.objects.all(),
-        to_field_name='slug',
-        label='Site (slug)',
-    )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:

+ 25 - 11
netbox/virtualization/forms.py

@@ -173,33 +173,45 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
 class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Cluster
     model = Cluster
     q = forms.CharField(required=False, label='Search')
     q = forms.CharField(required=False, label='Search')
-    type = FilterChoiceField(
-        queryset=ClusterType.objects.all(),
+    region = FilterChoiceField(
+        queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
-            api_url="/api/virtualization/cluster-types/",
-            value_field='slug',
+            api_url="/api/dcim/regions/",
+            value_field="slug",
+            filter_for={
+                'site': 'region'
+            }
         )
         )
     )
     )
-    group = FilterChoiceField(
-        queryset=ClusterGroup.objects.all(),
+    site = FilterChoiceField(
+        queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         null_label='-- None --',
         null_label='-- None --',
         required=False,
         required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
-            api_url="/api/virtualization/cluster-groups/",
+            api_url="/api/dcim/sites/",
             value_field='slug',
             value_field='slug',
             null_option=True,
             null_option=True,
         )
         )
     )
     )
-    site = FilterChoiceField(
-        queryset=Site.objects.all(),
+    type = FilterChoiceField(
+        queryset=ClusterType.objects.all(),
+        to_field_name='slug',
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/virtualization/cluster-types/",
+            value_field='slug',
+        )
+    )
+    group = FilterChoiceField(
+        queryset=ClusterGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         null_label='-- None --',
         null_label='-- None --',
         required=False,
         required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
-            api_url="/api/dcim/sites/",
+            api_url="/api/virtualization/cluster-groups/",
             value_field='slug',
             value_field='slug',
             null_option=True,
             null_option=True,
         )
         )
@@ -563,7 +575,9 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url='/api/dcim/regions/',
             api_url='/api/dcim/regions/',
             value_field="slug",
             value_field="slug",
-            null_option=True,
+            filter_for={
+                'site': 'region'
+            }
         )
         )
     )
     )
     site = FilterChoiceField(
     site = FilterChoiceField(