Repository: carltongibson/django-filter

Issue:
Meta.fields dict usage silently fails on declared filters
The below is a simplified version of https://stackoverflow.com/q/53411473/1103124

```python
class MyModel(models.Model):
    name = models.CharField()


class CustomFilter(django_filters.FilterSet):
    value = django_filters.CharFilter(field_name="name")

    class Meta:
        model = MyModel
        fields = {
            'value': ['exact', 'iexact', 'contains', 'icontains', ...],
        }
```

In the above, the user has aliased the model's `name` field to `value`. They then try to generate additional filters against the parameter name instead of the underlying model name, but this fails silently. This should be raising `TypeError("'Meta.fields' contains fields that are not defined on this FilterSet: value")`.

The underlying issue is the check here:
https://github.com/carltongibson/django-filter/blob/15683cd14e0f20a0bed8abab3448b5789e78fc62/django_filters/filterset.py#L348

Since `value` is a declared filter, it's removed from the list. 

The "missing fields" handling should be reworked to "missing field/lookup expressions". I assume this code also silently fails for non-existent lookups such as `foo`.

----

One alternative solution would be to support #774. It would be possible to use the declared filter's attribute name and `field_name` as the basis for generating additional aliased filters, but I have a feeling this could be too magical/complex. e.g., would users expect the declared filter's arguments (e.g., `choices`) to be copied or not?


Discussion:
Ran into this myself yesterday. The docs seemed to imply the ```fields``` tag would be used solely in conjunction with ```models```.  I've closed my PR for now because now I'm not sure about the exact functionality that the ```fields``` keyword is supposed to bring in.
Having a declared field in `Meta.fields` is acceptable. The issue is that an error should be raised when trying to use it to generate extra lookups.
Ah alright. Yeah that makes sense.
I've reopened my pr. Basically I've included an extra check for all fields other than the LookupChoiceFilter to check that if a field which is specified in ```Meta.fields``` also exists in declared fields, then make sure that the lookup expressions match; ie declaration of fields in both ```Meta.fields``` and the declare fields agree on the same lookup expressions for that field.

I'm not sure if this covers all possible cases as I'm not entirely familiar with the codebase yet. Any feedback would be greatly appreciated!

Resolving patch:
diff --git a/django_filters/filterset.py b/django_filters/filterset.py
--- a/django_filters/filterset.py
+++ b/django_filters/filterset.py
@@ -344,12 +344,14 @@ def get_filters(cls):
                 if field is not None:
                     filters[filter_name] = cls.filter_for_field(field, field_name, lookup_expr)
 
-        # filter out declared filters
-        undefined = [f for f in undefined if f not in cls.declared_filters]
+        # Allow Meta.fields to contain declared filters *only* when a list/tuple
+        if isinstance(cls._meta.fields, (list, tuple)):
+            undefined = [f for f in undefined if f not in cls.declared_filters]
+
         if undefined:
             raise TypeError(
-                "'Meta.fields' contains fields that are not defined on this FilterSet: "
-                "%s" % ', '.join(undefined)
+                "'Meta.fields' must not contain non-model field names: %s"
+                % ', '.join(undefined)
             )
 
         # Add in declared filters. This is necessary since we don't enforce adding


Test patch:
diff --git a/tests/rest_framework/test_backends.py b/tests/rest_framework/test_backends.py
--- a/tests/rest_framework/test_backends.py
+++ b/tests/rest_framework/test_backends.py
@@ -121,7 +121,7 @@ def test_filterset_fields_malformed(self):
         view.filterset_fields = ['non_existent']
         queryset = FilterableItem.objects.all()
 
-        msg = "'Meta.fields' contains fields that are not defined on this FilterSet: non_existent"
+        msg = "'Meta.fields' must not contain non-model field names: non_existent"
         with self.assertRaisesMessage(TypeError, msg):
             backend.get_filterset_class(view, queryset)
 
@@ -173,7 +173,7 @@ class View(FilterFieldsRootView):
 
         backend = DjangoFilterBackend()
 
-        msg = "'Meta.fields' contains fields that are not defined on this FilterSet: non_existent"
+        msg = "'Meta.fields' must not contain non-model field names: non_existent"
         with self.assertRaisesMessage(TypeError, msg):
             backend.get_schema_fields(View())
 
diff --git a/tests/test_filterset.py b/tests/test_filterset.py
--- a/tests/test_filterset.py
+++ b/tests/test_filterset.py
@@ -429,10 +429,11 @@ class F(FilterSet):
             username = CharFilter()
 
             class Meta:
-                model = Book
-                fields = {'id': ['exact'],
-                          'username': ['exact'],
-                          }
+                model = User
+                fields = {
+                    'id': ['exact'],
+                    'username': ['exact'],
+                }
 
         self.assertEqual(len(F.declared_filters), 1)
         self.assertEqual(len(F.base_filters), 2)
@@ -440,8 +441,11 @@ class Meta:
         expected_list = ['id', 'username']
         self.assertTrue(checkItemsEqual(list(F.base_filters), expected_list))
 
-    def test_meta_fields_containing_unknown(self):
-        with self.assertRaises(TypeError) as excinfo:
+    def test_meta_fields_list_containing_unknown_fields(self):
+        msg = ("'Meta.fields' must not contain non-model field names: "
+               "other, another")
+
+        with self.assertRaisesMessage(TypeError, msg):
             class F(FilterSet):
                 username = CharFilter()
 
@@ -449,36 +453,47 @@ class Meta:
                     model = Book
                     fields = ('username', 'price', 'other', 'another')
 
-        self.assertEqual(
-            str(excinfo.exception),
-            "'Meta.fields' contains fields that are not defined on this FilterSet: "
-            "other, another"
-        )
+    def test_meta_fields_dict_containing_unknown_fields(self):
+        msg = "'Meta.fields' must not contain non-model field names: other"
+
+        with self.assertRaisesMessage(TypeError, msg):
+            class F(FilterSet):
+
+                class Meta:
+                    model = Book
+                    fields = {
+                        'id': ['exact'],
+                        'title': ['exact'],
+                        'other': ['exact'],
+                    }
 
-    def test_meta_fields_dictionary_containing_unknown(self):
-        with self.assertRaises(TypeError):
+    def test_meta_fields_dict_containing_declarative_alias(self):
+        # Meta.fields dict cannot generate lookups for an *aliased* field
+        msg = "'Meta.fields' must not contain non-model field names: other"
+
+        with self.assertRaisesMessage(TypeError, msg):
             class F(FilterSet):
+                other = CharFilter()
 
                 class Meta:
                     model = Book
-                    fields = {'id': ['exact'],
-                              'title': ['exact'],
-                              'other': ['exact'],
-                              }
+                    fields = {
+                        'id': ['exact'],
+                        'title': ['exact'],
+                        'other': ['exact'],
+                    }
 
     def test_meta_fields_invalid_lookup(self):
         # We want to ensure that non existent lookups (or just simple misspellings)
         # throw a useful exception containg the field and lookup expr.
-        with self.assertRaises(FieldLookupError) as context:
+        msg = "Unsupported lookup 'flub' for field 'tests.User.username'."
+
+        with self.assertRaisesMessage(FieldLookupError, msg):
             class F(FilterSet):
                 class Meta:
                     model = User
                     fields = {'username': ['flub']}
 
-        exc = str(context.exception)
-        self.assertIn('tests.User.username', exc)
-        self.assertIn('flub', exc)
-
     def test_meta_exlude_with_declared_and_declared_wins(self):
         class F(FilterSet):
             username = CharFilter()