diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index e13e06b62..7b6d545ee 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -1,5 +1,14 @@ # NetBox v2.8 +## v2.8.7 (FUTURE) + +### Bug Fixes + +* [#4821](https://github.com/netbox-community/netbox/issues/4821) - Restrict group options by selected site when bulk editing VLANs +* [#4835](https://github.com/netbox-community/netbox/issues/4835) - Support passing multiple initial values for multiple choice fields + +--- + ## v2.8.7 (2020-07-02) ### Enhancements diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 853ebc46a..e9e2b6c85 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -17,6 +17,7 @@ from extras.models import ( from extras.reports import get_report, get_reports, run_report from extras.scripts import get_script, get_scripts, run_script from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet +from utilities.metadata import ContentTypeMetadata from utilities.utils import copy_safe_request from . import serializers @@ -90,6 +91,7 @@ class CustomFieldModelViewSet(ModelViewSet): # class GraphViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata queryset = Graph.objects.all() serializer_class = serializers.GraphSerializer filterset_class = filters.GraphFilterSet @@ -100,6 +102,7 @@ class GraphViewSet(ModelViewSet): # class ExportTemplateViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata queryset = ExportTemplate.objects.all() serializer_class = serializers.ExportTemplateSerializer filterset_class = filters.ExportTemplateFilterSet @@ -122,6 +125,7 @@ class TagViewSet(ModelViewSet): # class ImageAttachmentViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata queryset = ImageAttachment.objects.all() serializer_class = serializers.ImageAttachmentSerializer @@ -325,6 +329,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet): """ Retrieve a list of recent changes. """ + metadata_class = ContentTypeMetadata queryset = ObjectChange.objects.prefetch_related('user') serializer_class = serializers.ObjectChangeSerializer filterset_class = filters.ObjectChangeFilterSet diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 596d353bd..537a8367a 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1102,7 +1102,12 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False + required=False, + widget=APISelect( + filter_for={ + 'group': 'site_id' + } + ) ) group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 494ae2f29..583a2f85a 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -611,21 +611,24 @@ class DynamicModelChoiceMixin: filter = django_filters.ModelChoiceFilter widget = APISelect - def _get_initial_value(self, initial_data, field_name): - return initial_data.get(field_name) + def filter_queryset(self, data): + field_name = getattr(self, 'to_field_name') or 'pk' + # If multiple values have been provided, use only the last. + if type(data) in (list, tuple): + data = data[-1] + filter = self.filter( + field_name=field_name + ) + return filter.filter(self.queryset, data) def get_bound_field(self, form, field_name): bound_field = BoundField(form, self, field_name) - # Override initial() to allow passing multiple values - bound_field.initial = self._get_initial_value(form.initial, field_name) - # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. data = bound_field.value() if data: - filter = self.filter(field_name=self.to_field_name or 'pk', queryset=self.queryset) - self.queryset = filter.filter(self.queryset, data) + self.queryset = self.filter_queryset(data) else: self.queryset = self.queryset.none() @@ -655,11 +658,16 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip filter = django_filters.ModelMultipleChoiceFilter widget = APISelectMultiple - def _get_initial_value(self, initial_data, field_name): - # If a QueryDict has been passed as initial form data, get *all* listed values - if hasattr(initial_data, 'getlist'): - return initial_data.getlist(field_name) - return initial_data.get(field_name) + def filter_queryset(self, data): + field_name = getattr(self, 'to_field_name') or 'pk' + # Normalize data to a list + if type(data) not in (list, tuple): + data = [data] + filter = self.filter( + field_name=field_name, + lookup_expr='in' + ) + return filter.filter(self.queryset, data) class LaxURLField(forms.URLField): diff --git a/netbox/utilities/tests/test_utils.py b/netbox/utilities/tests/test_utils.py index 5d9a98ad5..0a0c3ad2c 100644 --- a/netbox/utilities/tests/test_utils.py +++ b/netbox/utilities/tests/test_utils.py @@ -1,15 +1,13 @@ +from django.http import QueryDict from django.test import TestCase -from utilities.utils import deepmerge, dict_to_filter_params +from utilities.utils import deepmerge, dict_to_filter_params, normalize_querydict class DictToFilterParamsTest(TestCase): """ Validate the operation of dict_to_filter_params(). """ - def setUp(self): - return - def test_dict_to_filter_params(self): input = { @@ -39,13 +37,21 @@ class DictToFilterParamsTest(TestCase): self.assertNotEqual(dict_to_filter_params(input), output) +class NormalizeQueryDictTest(TestCase): + """ + Validate normalize_querydict() utility function. + """ + def test_normalize_querydict(self): + self.assertDictEqual( + normalize_querydict(QueryDict('foo=1&bar=2&bar=3&baz=')), + {'foo': '1', 'bar': ['2', '3'], 'baz': ''} + ) + + class DeepMergeTest(TestCase): """ Validate the behavior of the deepmerge() utility. """ - def setUp(self): - return - def test_deepmerge(self): dict1 = { diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index cb3db2465..c3f34daed 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -152,6 +152,24 @@ def dict_to_filter_params(d, prefix=''): return params +def normalize_querydict(querydict): + """ + Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example, + + QueryDict('foo=1&bar=2&bar=3&baz=') + + becomes: + + {'foo': '1', 'bar': ['2', '3'], 'baz': ''} + + This function is necessary because QueryDict does not provide any built-in mechanism which preserves multiple + values. + """ + return { + k: v if len(v) > 1 else v[0] for k, v in querydict.lists() + } + + def deepmerge(original, new): """ Deep merge two dictionaries (new into original) and return a new dict diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 2cfcb04db..ccb3c65ff 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -31,7 +31,7 @@ from extras.querysets import CustomFieldQueryset from utilities.exceptions import AbortTransaction from utilities.forms import BootstrapMixin, BulkRenameForm, CSVDataField, TableConfigForm, restrict_form_fields from utilities.permissions import get_permission_for_model, resolve_permission -from utilities.utils import csv_format, prepare_cloned_fields +from utilities.utils import csv_format, normalize_querydict, prepare_cloned_fields from .error_handlers import handle_protectederror from .forms import ConfirmationForm, ImportForm from .paginator import EnhancedPaginator, get_paginate_count @@ -392,8 +392,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def get(self, request, *args, **kwargs): obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs) - # Parse initial data manually to avoid setting field values as lists - initial_data = {k: request.GET[k] for k in request.GET} + initial_data = normalize_querydict(request.GET) form = self.model_form(instance=obj, initial=initial_data) restrict_form_fields(form, request.user)