From 79375461771856d875280e12d24e3729d2482a27 Mon Sep 17 00:00:00 2001 From: Camille Harang Date: Thu, 12 Jul 2012 19:47:17 +0200 Subject: [PATCH 1/3] FilterMixin --- djangorestframework/mixins.py | 168 +++++++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 0f292b4e7..18ef441d1 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import AnonymousUser from django.core.paginator import Paginator from django.db.models.fields.related import ForeignKey from django.http import HttpResponse +from django.utils.safestring import mark_safe from urlobject import URLObject from djangorestframework import status @@ -31,7 +32,8 @@ __all__ = ( 'UpdateModelMixin', 'DeleteModelMixin', 'ListModelMixin', - 'PaginatorMixin' + 'PaginatorMixin', + 'FilterMixin' ) @@ -734,3 +736,167 @@ class PaginatorMixin(object): serialized_page_info['results'] = serialized_object_list return serialized_page_info + + +class FilterMixin(object): + + """ + `Mixin` class that allows to filter results based on the value of their fields, + by passing Django's `QuerySet` arguments in GET requests. + """ + + filter_fields = {} + """ + Dictionary listing the names of the fields (dictionary's keys) that can be fetched, + according to a selection of Django's `QuerySet` field lookups + (see https://docs.djangoproject.com/en/1.4/ref/models/querysets/#field-lookups). + + Querystring example: ?username__istartswith=joe&email__endswith=mydomain.com + + Field lookups' declaration: + + filter_fields = { + 'username': True, # All field lookups are allowed. + 'email': {'exclude': ('regex', 'iregex',)}, # All field lookups are allowed but 'regex' and 'iregex'. + 'first_name': {'fields': ('exact', 'iexact',)}, # Only 'exact' and 'iexact' field lookups are allowed. + } + """ + + filter_required = True + """ + Will return an empty `QuerySet` if set to True and filtering wasn't not properly triggered (via the GET request). + """ + + def __init__(self, *args, **kwargs): + + self._filter_lookups = { + 'exact': self._filter_lookup_value_orig, + 'iexact': self._filter_lookup_value_orig, + 'contains': self._filter_lookup_value_orig, + 'icontains': self._filter_lookup_value_orig, + 'in': self._filter_lookup_value_list, + 'gt': self._filter_lookup_value_field, + 'gte': self._filter_lookup_value_field, + 'lt': self._filter_lookup_value_field, + 'lte': self._filter_lookup_value_field, + 'startswith': self._filter_lookup_value_orig, + 'istartswith': self._filter_lookup_value_orig, + 'endswith': self._filter_lookup_value_orig, + 'iendswith': self._filter_lookup_value_orig, + 'range': self._filter_lookup_value_list, + 'year': self._filter_lookup_value_int, + 'month': self._filter_lookup_value_int, + 'day': self._filter_lookup_value_int, + 'week_day': self._filter_lookup_value_int, + 'isnull': lambda field, value: value.lower() == 'true', + 'search': self._filter_lookup_value_orig, + 'regex': self._filter_lookup_value_re, + 'iregex': self._filter_lookup_value_re + } + + for k, v in self.filter_fields.items(): + + if v == True: self.filter_fields[k] = set(self._filter_lookups.keys()) + + elif isinstance(v, dict): + + fields = set() + + if 'fields' in v: fields = set(v['fields']) + else: fields = set(self._filter_lookups.keys()) + + if 'exclude' in v: fields = fields - set(v['exclude']) + + self.filter_fields[k] = fields + + super(FilterMixin, self).__init__(*args, **kwargs) + + def _filter_lookup_value_field(self, field, value): + + return self.resource.model._meta.get_field(field).to_python(value) + + def _filter_lookup_value_orig(self, field, value): return value + + def _filter_lookup_value_int(self, field, value): return int(value) + + def _filter_lookup_value_re(self, field, value): return r'%s' % value + + def _filter_lookup_value_list(self, field, value): + + split = value.split(',') + try: return map(lambda x: self._filter_lookup_value_field(field, x), split) + except TypeError: return split + + def get_description(self, html): + + """ + Appends filter's documentation to the `Resource`'s description. + """ + + desc = super(FilterMixin, self).get_description(html) + + filter_desc_req = u' (required)' if self.filter_required else u'' + + # Sort fields and lookup suffixes in alphabetical order for readability. + # Not using OrderedDict for Python <= 2.6 compatibility. + filter_fields_ordered_keys = list(self.filter_fields.keys()) + filter_fields_ordered_keys.sort() + filter_fields_ordered_lookups = dict(map(lambda k: (k, list(self.filter_fields[k]),), filter_fields_ordered_keys)) + map(lambda v: v.sort(), filter_fields_ordered_lookups.values()) + filter_desc_example = u'%s__%s' % (filter_fields_ordered_keys[0], filter_fields_ordered_lookups[filter_fields_ordered_keys[0]][0]) + + if html: + + filter_desc_fields_html = u''.join(map(lambda k: \ + u'
  • %s + __ + %s.
  • ' \ + % (k, u', '.join(filter_fields_ordered_lookups[k])), filter_fields_ordered_keys)) + + filter_desc = u"""

    Filter options%s

    +
    +

    The following fields can be used to filter results in GET requests, +they should be extended by lookup suffixes +with a double underscore (e.g. ?%s=) +except for the exact suffix which is the default:

    +
      %s
    +
    +""" % (filter_desc_req, filter_desc_example, filter_desc_example, filter_desc_fields_html) + + return mark_safe(u'%s\n%s' % (desc, filter_desc)) + + else: + + filter_desc_fields_txt = u'\n'.join(map(lambda k: u'* %s: %s.' \ + % (k, u', '.join(filter_fields_ordered_lookups[k])), filter_fields_ordered_keys)) + + return u"""%s\n\nFilter options%s:\n\n%s""" % (desc, filter_desc_req, filter_desc_fields_txt) + + def get_query_kwargs(self, *args, **kwargs): + + """ + Return the `QuerySet`'s args according to the GET request's arguments. + """ + + kwargs = super(FilterMixin, self).get_query_kwargs(*args, **kwargs) + self._filter_triggered = False + + for k in self.request.GET: + + field = k.split('__') + if len(field) == 2: lookup = field[1] + else: lookup = 'exact' + field = field[0] + value = self.request.GET[k] + + if field in self.filter_fields and lookup in self.filter_fields[field]: + + value = self._filter_lookups[lookup](field, value) + kwargs['%s__%s' % (field, lookup)] = value + self._filter_triggered = True + + return kwargs + + def get(self, *args, **kwargs): + + queryset = super(FilterMixin, self).get(*args, **kwargs) + if self.filter_required and not self._filter_triggered: return self.resource.model.objects.none() + return queryset \ No newline at end of file From 8253db67cc01f32385e5e8fc8af2a4d201ec34a3 Mon Sep 17 00:00:00 2001 From: Camille Harang Date: Fri, 13 Jul 2012 00:13:06 +0200 Subject: [PATCH 2/3] No fitler description if no filter_fields defined. --- djangorestframework/mixins.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 18ef441d1..e2062326a 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -835,6 +835,8 @@ class FilterMixin(object): desc = super(FilterMixin, self).get_description(html) + if not len(self.filter_fields): return desc + filter_desc_req = u' (required)' if self.filter_required else u'' # Sort fields and lookup suffixes in alphabetical order for readability. From b474c275b76df4ae28291bcfaf46bd794b012ed5 Mon Sep 17 00:00:00 2001 From: Camille Harang Date: Wed, 8 Aug 2012 21:04:49 +0200 Subject: [PATCH 3/3] moved mixin's get_query_kwargs to get_query_args to allow the use of django.db.models.Q which is only callable as *args, not **kwargs. --- djangorestframework/mixins.py | 65 ++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index e2062326a..eb49d898f 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -6,6 +6,7 @@ classes that can be added to a `View`. from django.contrib.auth.models import AnonymousUser from django.core.paginator import Paginator from django.db.models.fields.related import ForeignKey +from django.db.models import Q from django.http import HttpResponse from django.utils.safestring import mark_safe from urlobject import URLObject @@ -463,12 +464,13 @@ class ModelMixin(object): queryset = None - def get_query_kwargs(self, *args, **kwargs): + def get_query_args(self, *args, **kwargs): """ Return a dict of kwargs that will be used to build the model instance retrieval or to filter querysets. """ + args = list(args) kwargs = dict(kwargs) # If the URLconf includes a .(?P\w+) pattern to match against @@ -477,7 +479,7 @@ class ModelMixin(object): if BaseRenderer._FORMAT_QUERY_PARAM in kwargs: del kwargs[BaseRenderer._FORMAT_QUERY_PARAM] - return kwargs + return [], kwargs def get_instance_data(self, model, content, **kwargs): """ @@ -506,11 +508,11 @@ class ModelMixin(object): return all_kw_args - def get_instance(self, **kwargs): + def get_instance(self, *args, **kwargs): """ Get a model instance for read/update/delete requests. """ - return self.get_queryset().get(**kwargs) + return self.get_queryset().get(*args, **kwargs) def get_queryset(self): """ @@ -532,10 +534,10 @@ class ReadModelMixin(ModelMixin): """ def get(self, request, *args, **kwargs): model = self.resource.model - query_kwargs = self.get_query_kwargs(request, *args, **kwargs) + query_args, query_kwargs = self.get_query_args(request, **kwargs) try: - self.model_instance = self.get_instance(**query_kwargs) + self.model_instance = self.get_instance(*query_args, **query_kwargs) except model.DoesNotExist: raise ErrorResponse(status.HTTP_404_NOT_FOUND) @@ -588,12 +590,12 @@ class UpdateModelMixin(ModelMixin): """ def put(self, request, *args, **kwargs): model = self.resource.model - query_kwargs = self.get_query_kwargs(request, *args, **kwargs) + query_args, query_kwargs = self.get_query_args(request, **kwargs) # TODO: update on the url of a non-existing resource url doesn't work # correctly at the moment - will end up with a new url try: - self.model_instance = self.get_instance(**query_kwargs) + self.model_instance = self.get_instance(*query_args, **query_kwargs) for (key, val) in self.CONTENT.items(): setattr(self.model_instance, key, val) @@ -609,10 +611,10 @@ class DeleteModelMixin(ModelMixin): """ def delete(self, request, *args, **kwargs): model = self.resource.model - query_kwargs = self.get_query_kwargs(request, *args, **kwargs) + query_args, query_kwargs = self.get_query_args(request, **kwargs) try: - instance = self.get_instance(**query_kwargs) + instance = self.get_instance(*query_args, **query_kwargs) except model.DoesNotExist: raise ErrorResponse(status.HTTP_404_NOT_FOUND, None, {}) @@ -628,9 +630,9 @@ class ListModelMixin(ModelMixin): def get(self, request, *args, **kwargs): queryset = self.get_queryset() ordering = self.get_ordering() - query_kwargs = self.get_query_kwargs(request, *args, **kwargs) + query_args, query_kwargs = self.get_query_args(request, **kwargs) - queryset = queryset.filter(**query_kwargs) + queryset = queryset.filter(*query_args, **query_kwargs) if ordering: queryset = queryset.order_by(*ordering) @@ -860,8 +862,11 @@ they should be extended by exact
    suffix which is the default:

    return u"""%s\n\nFilter options%s:\n\n%s""" % (desc, filter_desc_req, filter_desc_fields_txt) - def get_query_kwargs(self, *args, **kwargs): + def get_query_args(self, *args, **kwargs): """ - Return the `QuerySet`'s args according to the GET request's arguments. + Return the `QuerySet`'s args and kwargs according to the GET request's arguments. """ - kwargs = super(FilterMixin, self).get_query_kwargs(*args, **kwargs) + args, kwargs = super(FilterMixin, self).get_query_args(self, *args, **kwargs) self._filter_triggered = False + q = None - for k in self.request.GET: + for k, v in self.request.GET.items(): + + if k.startswith('or__'): + q_or = True + k = k[4:] + else: q_or = False field = k.split('__') if len(field) == 2: lookup = field[1] else: lookup = 'exact' field = field[0] - value = self.request.GET[k] - if field in self.filter_fields and lookup in self.filter_fields[field]: + if v and field in self.filter_fields and lookup in self.filter_fields[field]: - value = self._filter_lookups[lookup](field, value) - kwargs['%s__%s' % (field, lookup)] = value + v = self._filter_lookups[lookup](field, v) + if not q_or: kwargs['%s__%s' % (field, lookup)] = v + else: + q_this = Q(**{'%s__%s' % (field, lookup): v}) + if q: q = q | q_this + else: q = q_this self._filter_triggered = True - return kwargs + if q: args.append(q) + + return args, kwargs def get(self, *args, **kwargs): queryset = super(FilterMixin, self).get(*args, **kwargs) - if self.filter_required and not self._filter_triggered: return self.resource.model.objects.none() - return queryset \ No newline at end of file + if self.filter_required and not self._filter_triggered: + return self.resource.model.objects.none() + return queryset