Parcourir la source

Introduced NullableModelMultipleChoiceField to allow null filtering without causing introspection issues during database migrations

Jeremy Stretch il y a 8 ans
Parent
commit
0444ac7db9
4 fichiers modifiés avec 74 ajouts et 54 suppressions
  1. 12 41
      netbox/ipam/filters.py
  2. 1 1
      netbox/ipam/forms.py
  3. 61 11
      netbox/utilities/filters.py
  4. 0 1
      netbox/utilities/forms.py

+ 12 - 41
netbox/ipam/filters.py

@@ -86,17 +86,17 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         action='search_by_parent',
         label='Parent prefix',
     )
-    vrf = NullableModelMultipleChoiceFilter(
-        name='vrf',
-        queryset=VRF.objects.all(),
-        label='VRF',
-    )
-    # Duplicate of `vrf` for backward-compatibility
     vrf_id = NullableModelMultipleChoiceFilter(
         name='vrf_id',
         queryset=VRF.objects.all(),
         label='VRF',
     )
+    vrf = NullableModelMultipleChoiceFilter(
+        name='vrf',
+        queryset=VRF.objects.all(),
+        to_field_name='rd',
+        label='VRF (RD)',
+    )
     tenant_id = NullableModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
@@ -191,17 +191,17 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
         action='search_by_parent',
         label='Parent prefix',
     )
-    vrf = NullableModelMultipleChoiceFilter(
-        name='vrf',
-        queryset=VRF.objects.all(),
-        label='VRF',
-    )
-    # Duplicate of `vrf` for backward-compatibility
     vrf_id = NullableModelMultipleChoiceFilter(
         name='vrf_id',
         queryset=VRF.objects.all(),
         label='VRF',
     )
+    vrf = NullableModelMultipleChoiceFilter(
+        name='vrf',
+        queryset=VRF.objects.all(),
+        to_field_name='rd',
+        label='VRF (RD)',
+    )
     tenant_id = NullableModelMultipleChoiceFilter(
         name='tenant',
         queryset=Tenant.objects.all(),
@@ -253,35 +253,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
         except AddrFormatError:
             return queryset.none()
 
-    def _vrf(self, queryset, value):
-        if str(value) == '':
-            return queryset
-        try:
-            vrf_id = int(value)
-        except ValueError:
-            return queryset.none()
-        if vrf_id == 0:
-            return queryset.filter(vrf__isnull=True)
-        return queryset.filter(vrf__pk=value)
-
-    def _tenant(self, queryset, value):
-        if str(value) == '':
-            return queryset
-        return queryset.filter(
-            Q(tenant__slug=value) |
-            Q(tenant__isnull=True, vrf__tenant__slug=value)
-        )
-
-    def _tenant_id(self, queryset, value):
-        try:
-            value = int(value)
-        except ValueError:
-            return queryset.none()
-        return queryset.filter(
-            Q(tenant__pk=value) |
-            Q(tenant__isnull=True, vrf__tenant__pk=value)
-        )
-
 
 class VLANGroupFilter(django_filters.FilterSet):
     site_id = django_filters.ModelMultipleChoiceFilter(

+ 1 - 1
netbox/ipam/forms.py

@@ -418,7 +418,7 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
         'placeholder': 'Prefix',
     }))
     family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
-    vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='slug',
+    vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='rd',
                             label='VRF', null_option=(0, 'Global'))
     tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
                                to_field_name='slug', null_option=(0, 'None'))

+ 61 - 11
netbox/utilities/filters.py

@@ -1,15 +1,65 @@
 import django_filters
+import itertools
 
+from django import forms
 from django.db.models import Q
+from django.utils.encoding import force_text
 
 
-class NullableModelMultipleChoiceFilter(django_filters.MultipleChoiceFilter):
+class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField):
+    """
+    This field operates like a normal ModelMultipleChoiceField except that it allows for one additional choice which is
+    used to represent a value of Null. This is accomplished by creating a new iterator which first yields the null
+    choice before entering the queryset iterator, and by ignoring the null choice during cleaning. The effect is similar
+    to defining a MultipleChoiceField with:
+
+        choices = [(0, 'None')] + [(x.id, x) for x in Foo.objects.all()]
+
+    However, the above approach forces immediate evaluation of the queryset, which can cause issues when calculating
+    database migrations.
+    """
+    iterator = forms.models.ModelChoiceIterator
+
+    def __init__(self, null_value=0, null_label='None', *args, **kwargs):
+        self.null_value = null_value
+        self.null_label = null_label
+        super(NullableModelMultipleChoiceField, self).__init__(*args, **kwargs)
+
+    def _get_choices(self):
+        if hasattr(self, '_choices'):
+            return self._choices
+        # Prepend the null choice to the queryset iterator
+        return itertools.chain(
+            [(self.null_value, self.null_label)],
+            self.iterator(self),
+        )
+    choices = property(_get_choices, forms.ChoiceField._set_choices)
+
+    def clean(self, value):
+        # Strip all instances of the null value before cleaning
+        if value is not None:
+            stripped_value = [x for x in value if x != force_text(self.null_value)]
+        else:
+            stripped_value = value
+        super(NullableModelMultipleChoiceField, self).clean(stripped_value)
+        return value
+
+
+class NullableModelMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
+    """
+    This class extends ModelMultipleChoiceFilter to accept an additional value which implies "is null". The default
+    queryset filter argument is:
+
+        .filter(fieldname=value)
+
+    When filtering by the value representing "is null" ('0' by default) the argument is modified to:
+
+        .filter(fieldname__isnull=True)
+    """
+    field_class = NullableModelMultipleChoiceField
 
     def __init__(self, *args, **kwargs):
-        # Convert the queryset to a list of choices prefixed with a "None" option
-        queryset = kwargs.pop('queryset')
-        self.to_field_name = kwargs.pop('to_field_name', 'pk')
-        kwargs['choices'] = [(0, 'None')] + [(getattr(o, self.to_field_name), o) for o in queryset]
+        self.null_value = kwargs.get('null_value', 0)
         super(NullableModelMultipleChoiceFilter, self).__init__(*args, **kwargs)
 
     def filter(self, qs, value):
@@ -24,13 +74,13 @@ class NullableModelMultipleChoiceFilter(django_filters.MultipleChoiceFilter):
 
         q = Q()
         for v in set(value):
-            # Filtering on NULL
-            if v == str(0):
+            # Filtering by "is null"
+            if v == force_text(self.null_value):
                 arg = {'{}__isnull'.format(self.name): True}
-            # Filtering on a related field (e.g. slug)
-            elif self.to_field_name != 'pk':
-                arg = {'{}__{}'.format(self.name, self.to_field_name): v}
-            # Filtering on primary key
+            # Filtering by a related field (e.g. slug)
+            elif self.field.to_field_name is not None:
+                arg = {'{}__{}'.format(self.name, self.field.to_field_name): v}
+            # Filtering by primary key (default)
             else:
                 arg = {self.name: v}
             if self.conjoined:

+ 0 - 1
netbox/utilities/forms.py

@@ -4,7 +4,6 @@ import re
 
 from django import forms
 from django.core.urlresolvers import reverse_lazy
-from django.db.models import Count
 from django.utils.encoding import force_text
 from django.utils.html import format_html
 from django.utils.safestring import mark_safe