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

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
+
 !!! 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` .
 
@@ -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_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
 
 `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
 
+* [#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
 * [#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
+* [#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
 
@@ -15,6 +30,7 @@
 * [#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
 * [#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',
         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(
         field_name='circuits__terminations__site',
         queryset=Site.objects.all(),

+ 15 - 0
netbox/circuits/forms.py

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

+ 102 - 16
netbox/dcim/filters.py

@@ -93,6 +93,17 @@ class SiteFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet
 
 
 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(
         queryset=Site.objects.all(),
         label='Site (ID)',
@@ -125,6 +136,17 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet
         method='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)',
@@ -831,6 +853,28 @@ class InventoryItemFilter(DeviceComponentFilterSet):
         method='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(
         queryset=Device.objects.all(),
         label='Device (ID)',
@@ -880,6 +924,17 @@ class VirtualChassisFilter(django_filters.FilterSet):
         method='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(
         field_name='master__site',
         queryset=Site.objects.all(),
@@ -935,7 +990,7 @@ class CableFilter(django_filters.FilterSet):
     device_id = MultiValueNumberFilter(
         method='filter_device'
     )
-    device = MultiValueNumberFilter(
+    device = MultiValueCharFilter(
         method='filter_device',
         field_name='device__name'
     )
@@ -978,9 +1033,12 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
         method='filter_site',
         label='Site (slug)',
     )
-    device = django_filters.CharFilter(
+    device_id = MultiValueNumberFilter(
+        method='filter_device'
+    )
+    device = MultiValueCharFilter(
         method='filter_device',
-        label='Device',
+        field_name='device__name'
     )
 
     class Meta:
@@ -993,11 +1051,11 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
         return queryset.filter(connected_endpoint__device__site__slug=value)
 
     def filter_device(self, queryset, name, value):
-        if not value.strip():
+        if not value:
             return queryset
         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',
         label='Site (slug)',
     )
-    device = django_filters.CharFilter(
+    device_id = MultiValueNumberFilter(
+        method='filter_device'
+    )
+    device = MultiValueCharFilter(
         method='filter_device',
-        label='Device',
+        field_name='device__name'
     )
 
     class Meta:
@@ -1021,11 +1082,11 @@ class PowerConnectionFilter(django_filters.FilterSet):
         return queryset.filter(_connected_poweroutlet__device__site__slug=value)
 
     def filter_device(self, queryset, name, value):
-        if not value.strip():
+        if not value:
             return queryset
         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',
         label='Site (slug)',
     )
-    device = django_filters.CharFilter(
+    device_id = MultiValueNumberFilter(
+        method='filter_device'
+    )
+    device = MultiValueCharFilter(
         method='filter_device',
-        label='Device',
+        field_name='device__name'
     )
 
     class Meta:
@@ -1052,11 +1116,11 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
         )
 
     def filter_device(self, queryset, name, value):
-        if not value.strip():
+        if not value:
             return queryset
         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',
         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)',
@@ -1107,6 +1182,17 @@ class PowerFeedFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
         method='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(
         field_name='power_panel__site',
         queryset=Site.objects.all(),

+ 158 - 31
netbox/dcim/forms.py

@@ -375,6 +375,18 @@ class RackGroupCSVForm(forms.ModelForm):
 
 
 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(
         queryset=Site.objects.all(),
         to_field_name='slug',
@@ -646,11 +658,23 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
 
 class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     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(
         required=False,
         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(
         queryset=Site.objects.all(),
         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(
             api_url="/api/dcim/rack-groups/",
-            null_option=True,
+            null_option=True
         )
     )
     status = forms.MultipleChoiceField(
@@ -3122,9 +3145,13 @@ class CableFilterForm(BootstrapMixin, forms.Form):
         required=False,
         widget=ColorSelect()
     )
-    device = forms.CharField(
+    device_id = FilterChoiceField(
+        queryset=Device.objects.all(),
         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):
-    site = forms.ModelChoiceField(
+    site = FilterChoiceField(
         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,
-        label='Device name'
+        label='Device',
+        widget=APISelectMultiple(
+            api_url='/api/dcim/devices/',
+        )
     )
 
 
 class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
-    site = forms.ModelChoiceField(
+    site = FilterChoiceField(
         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,
-        label='Device name'
+        label='Device',
+        widget=APISelectMultiple(
+            api_url='/api/dcim/devices/',
+        )
     )
 
 
 class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
-    site = forms.ModelChoiceField(
+    site = FilterChoiceField(
         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,
-        label='Device name'
+        label='Device',
+        widget=APISelectMultiple(
+            api_url='/api/dcim/devices/',
+        )
     )
 
 
@@ -3236,9 +3284,12 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = InventoryItem
         fields = [
-            'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
+            'name', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
         ]
         widgets = {
+            'device': APISelect(
+                api_url="/api/dcim/devices/"
+            ),
             'manufacturer': APISelect(
                 api_url="/api/dcim/manufacturers/"
             )
@@ -3274,9 +3325,19 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):
         queryset=InventoryItem.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/dcim/devices/"
+        )
+    )
     manufacturer = forms.ModelChoiceField(
         queryset=Manufacturer.objects.all(),
-        required=False
+        required=False,
+        widget=APISelect(
+            api_url="/api/dcim/manufacturers/"
+        )
     )
     part_id = forms.CharField(
         max_length=50,
@@ -3300,18 +3361,48 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
         required=False,
         label='Search'
     )
-    device = forms.CharField(
+    region = FilterChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
         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(
         queryset=Manufacturer.objects.all(),
         to_field_name='slug',
-        null_label='-- None --'
+        widget=APISelect(
+            api_url="/api/dcim/manufacturers/",
+            value_field="slug",
+        )
     )
     discovered = forms.NullBooleanField(
         required=False,
-        widget=forms.Select(
+        widget=StaticSelect2(
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
     )
@@ -3458,6 +3549,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         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(
         queryset=Site.objects.all(),
         to_field_name='slug',
@@ -3563,6 +3666,18 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         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(
         queryset=Site.objects.all(),
         to_field_name='slug',
@@ -3783,6 +3898,18 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         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(
         queryset=Site.objects.all(),
         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
         if self.installed_device:
             current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
-            if current_bay:
+            if current_bay and current_bay != self:
                 raise ValidationError({
                     'installed_device': "Cannot install the specified device; device is already installed in {}".format(
                         current_bay

+ 34 - 1
netbox/extras/admin.py

@@ -3,7 +3,10 @@ from django.contrib import admin
 
 from netbox.admin import admin_site
 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):
@@ -166,6 +169,36 @@ class ExportTemplateAdmin(admin.ModelAdmin):
     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
 #

+ 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:
                 initial = None
             field = forms.NullBooleanField(
-                required=cf.required, initial=initial, widget=forms.Select(choices=choices)
+                required=cf.required, initial=initial, widget=StaticSelect2(choices=choices)
             )
 
         # 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
                 except ObjectDoesNotExist:
                     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
         elif cf.type == CF_TYPE_URL:

+ 7 - 0
netbox/extras/models.py

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

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

@@ -46,12 +46,17 @@ def custom_links(obj):
 
         # Add non-grouped links
         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
     for group, links in group_names.items():
@@ -59,11 +64,17 @@ def custom_links(obj):
         links_rendered = []
 
         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(
-                    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:
@@ -71,7 +82,4 @@ def custom_links(obj):
                 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 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 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 .constants import *
 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',
         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(
         queryset=Site.objects.all(),
         label='Site (ID)',
@@ -309,6 +320,10 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
         queryset=Interface.objects.all(),
         label='Interface (ID)',
     )
+    assigned_to_interface = django_filters.BooleanFilter(
+        method='_assigned_to_interface',
+        label='Is assigned to an interface',
+    )
     status = django_filters.MultipleChoiceFilter(
         choices=IPADDRESS_STATUS_CHOICES,
         null_value=None
@@ -366,8 +381,22 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
         except Device.DoesNotExist:
             return queryset.none()
 
+    def _assigned_to_interface(self, queryset, name, value):
+        return queryset.exclude(interface__isnull=value)
+
 
 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(
         queryset=Site.objects.all(),
         label='Site (ID)',
@@ -393,6 +422,17 @@ class VLANFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet
         method='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)',

+ 50 - 5
netbox/ipam/forms.py

@@ -3,7 +3,7 @@ from django.core.exceptions import MultipleObjectsReturned
 from django.core.validators import MaxValueValidator, MinValueValidator
 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 tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
@@ -492,8 +492,8 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
 class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = Prefix
     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(
         required=False,
@@ -534,6 +534,18 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
         required=False,
         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(
         queryset=Site.objects.all(),
         to_field_name='slug',
@@ -938,7 +950,8 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
 class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = IPAddress
     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(
         required=False,
@@ -984,6 +997,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
         required=False,
         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):
+    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(
         queryset=Site.objects.all(),
         to_field_name='slug',
@@ -1207,11 +1239,24 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
 
 class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     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(
         required=False,
         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(
         queryset=Site.objects.all(),
         to_field_name='slug',

+ 1 - 1
netbox/netbox/settings.py

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

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

@@ -103,14 +103,16 @@ $(document).ready(function() {
         placeholder: "---------",
         theme: "bootstrap",
         templateResult: colorPickerClassCopy,
-        templateSelection: colorPickerClassCopy
+        templateSelection: colorPickerClassCopy,
+        width: "off"
     });
 
     // Static choice selection
     $('.netbox-select2-static').select2({
         allowClear: true,
         placeholder: "---------",
-        theme: "bootstrap"
+        theme: "bootstrap",
+        width: "off"
     });
 
     // API backed selection
@@ -120,6 +122,7 @@ $(document).ready(function() {
         allowClear: true,
         placeholder: "---------",
         theme: "bootstrap",
+        width: "off",
         ajax: {
             delay: 500,
 
@@ -299,7 +302,8 @@ $(document).ready(function() {
         multiple: true,
         allowClear: true,
         placeholder: "Tags",
-
+        theme: "bootstrap",
+        width: "off",
         ajax: {
             delay: 250,
             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.
     :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__(
         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',
         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(
         queryset=ClusterGroup.objects.all(),
         label='Parent group (ID)',
@@ -56,16 +77,6 @@ class ClusterFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
         to_field_name='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()
 
     class Meta:

+ 25 - 11
netbox/virtualization/forms.py

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