neithere / django-view-shortcuts

A set of shortcuts for Django views

Changed (Δ3.0 KB):

raw changeset »

README (25 lines added, 0 lines removed)

view_shortcuts/filters.py (126 lines added, 66 lines removed)

view_shortcuts/tests.py (21 lines added, 14 lines removed)

Up to file-list README:

1
Why view_shortcuts.filters, not django.contrib.admin.filterspecs?
2
3
- ordinary views, not only admin
4
- lookups mapped to params --> security
5
- no pre-generated html --> valid, flexible
6
- QuerySet in, QuerySet out: can pre- and post-filter
7
- choices with respect to existing QuerySet
8
9
What's good in filterspecs (included here now, too):
10
11
- factory with simple tests
12
  - enables field-specific behaviour
13
  - easily extendable with custom filterspecs
14
15
TODO:
16
17
- more filters:
18
  - alphabetic
19
  - date drilldown
20
  - date fadeout
21
  - range selection
22
- add ability to enforce a filter when creating a FilterList
23
  (e.g. date drilldown/fadeout have to pass the same test and, therefore,
24
  the one which is declared earlier will be chosen automatically; this makes
25
  no sense and developer should be able to choose the field manually.)

Up to file-list view_shortcuts/filters.py:

11
11
12
12
" A set of typical QuerySet filters for ordinary views. "
13
13
14
import warnings
14
15
from urllib import urlencode
15
from django.db.models import Count
16
from django.db import models
17
from django.utils.translation import ugettext_lazy as _
16
18
from decorators import cached_property
17
19
18
20
def filter_date(items, field_name, year, month=None, day=None):
@@ -100,6 +102,8 @@ def filter_field(items, field_name, valu
100
102
            entries = filter_field(entries, 'foo', foo)
101
103
            entries = filter_field(entries, 'bar', bar)
102
104
    """
105
    warnings.warn("filter_field() is deprecated, use FilterList() instead.",
106
                  DeprecationWarning, 2)
103
107
    if value:
104
108
        items = items.filter(**{field_name:value})
105
109
    return items
@@ -127,6 +131,8 @@ def filter_param(items, request, field_n
127
131
            entries = filter_param(entries, request, 'foo')
128
132
            entries = filter_param(entries, request, 'bar', 'barbar')
129
133
    """
134
    warnings.warn("filter_param() is deprecated, use FilterList() instead.",
135
                  DeprecationWarning, 2)
130
136
    param_name = param_name or field_name
131
137
    value = request.GET.get(param_name, None)
132
138
    if value:
@@ -239,11 +245,11 @@ class FilterList(list):
239
245
            single_triggered = False
240
246
            for p in params:
241
247
                if isinstance(p, (tuple,list)):
242
                    field, param = p
248
                    lookup, param = p
243
249
                else:
244
                    field = param = p
250
                    lookup = param = p
245
251
                value = request.GET.get(param)
246
                f = Filter(param, qs=qs, field=field, active=False, sort_by_usage=sort_by_usage)
252
                f = Filter.create(param, qs=qs, lookup=lookup, active=False, sort_by_usage=sort_by_usage)
247
253
                if value and not single_triggered:
248
254
                    if single:
249
255
                        single_triggered = True
@@ -298,7 +304,7 @@ class FilterList(list):
298
304
        Of course custom query managers are also reset.
299
305
        """
300
306
        lookup_params = dict(
301
            (filter.field, filter.value) for filter in self.active
307
            (filter.lookup, filter.value) for filter in self.active
302
308
        )
303
309
        return self._qs.model.objects.filter(**lookup_params)
304
310
@@ -312,36 +318,53 @@ class Filter(object):
312
318
    this behaviour, set sort_by_usage=False, 
313
319
    
314
320
    Usage: see FilterList.
321
322
    Each filter subclass knows how to display a filter for a field that passes a
323
    certain test -- e.g. being a DateField or ForeignKey.
315
324
    """
316
    def __init__(self, param, qs=None, field=None, active=False, value=None, sort_by_usage=True):
317
        assert (qs != None and field != None) or (qs == field == None), 'Please provide both queryset and field or neither.'
325
    filter_specs = []
326
    def __init__(self, param, qs, lookup, field, active, value, sort_by_usage):
318
327
        self.param = param
319
328
        self.qs = qs
329
        self.lookup = lookup
320
330
        self.field = field
321
331
        self.active = active
322
332
        self.value = value
323
333
        self.sort_by_usage = sort_by_usage
324
334
325
        # while self.field contains the field name, self._field contains the field itself
326
        self._field = None
327
        if self.field:
328
            fn = self.field.split('__')[0]  # we need the "author" part of "author__pk"
329
            self._field = self.qs.model._meta.get_field(fn)
335
    def __repr__(self):
336
        return u'<%s "%s": %s>' % (self.__class__.__name__, self.param, self.active)
330
337
331
    def __repr__(self):
332
        return u'<Filter "%s": %s>' % (self.param, self.active)
338
    @classmethod
339
    def register(cls, test, factory):
340
        cls.filter_specs.append((test, factory))
341
342
    @classmethod
343
    def create(cls, param, qs=None, lookup=None, active=False, value=None, sort_by_usage=True):
344
        fn = lookup.split('__')[0]  # we need the "author" part of "author__pk"
345
        field = qs.model._meta.get_field(fn)
346
        for test, factory in cls.filter_specs:
347
            if test(field):
348
                return factory(param, qs, lookup, field, active, value, sort_by_usage)
333
349
334
350
    @cached_property
335
351
    def urlencode(self):
336
352
        return urlencode({self.param: self.value})
337
353
354
    def extra_title(self):
355
        """Filter subclasses may overload this method to provide extra sources
356
        for filter title; they will only be used if no verbose name is specified
357
        in current model's field definition.
358
        """
359
        return None
360
338
361
    @cached_property
339
362
    def title(self):
340
363
        "Returns human-readable field name (if accessible)."
341
        if self._field:
342
            return unicode(self._field.verbose_name)
343
        else:
344
            return self.param.capitalize()
364
        return unicode(self.field.verbose_name or self.extra_title())
365
366
    def generate_choices(self):
367
        raise NotImplementedError
345
368
346
369
    @cached_property
347
370
    def choices(self):
@@ -358,53 +381,7 @@ class Filter(object):
358
381
          b) it is used but is not in the field's explicit list of choices
359
382
             (i.e. the "choices" keyword).
360
383
        """
361
        def _choices():
362
            if self._field.rel:
363
                # Relational field
364
                
365
                # TODO: when multiple filters are active, count only intersections (? - can be heavy)
366
                
367
                related_name = getattr(self._field.rel, 'related_name', None) or \
368
                               self.qs.model._meta.module_name
369
                
370
                # get all possible choices
371
                choices = self._field.rel.to.objects
372
                # apply constraints from field definition
373
                choices = choices.filter(**self._field.rel.limit_choices_to)
374
                # get intersection with current queryset
375
                choices = choices.filter(**{'%s__in' % related_name: self.qs })
376
                # count related objects for each choice (i.e. how popular is each option)
377
                choices = choices.annotate(items_count=Count(related_name))
378
                if self.sort_by_usage:
379
                    choices = choices.order_by('-items_count')
380
                
381
                for c in choices:
382
                    yield FilterChoice(self, unicode(c), c.pk, c.items_count)
383
            else:
384
                # Non-relational field
385
386
                # retrieve unique values and count how many times each is used
387
                choices = self.qs.values(self.field).distinct() \
388
                # count related objects for each choice (i.e. how popular is each option)
389
                choices = choices.annotate(items_count=Count('pk'))
390
                if self.sort_by_usage:
391
                    choices = choices.order_by('-items_count')
392
                
393
                # if list of choices is explicitly defined, exclude choices that
394
                # are not in this list (e.g. if the list was added post factum)
395
                if self._field.choices:
396
                    explicit_values = [ c[0] for c in self._field.choices ]
397
                    choices = choices.filter(**{'%s__in' % self.field: explicit_values})
398
                
399
                # choice title is its value unless the label is explicitly defined
400
                _value = lambda c: unicode(c.get(self.field))
401
                _title = lambda c: self._field.choices and \
402
                              dict(self._field.choices).get(c.get(self.field)) or _value(c)
403
                
404
                for c in choices:
405
                    yield FilterChoice(self, _title(c), _value(c), c['items_count'])
406
        
407
        return list(_choices())
384
        return list(self.generate_choices())
408
385
    
409
386
    def get_active_choices(self):
410
387
        "Returns list of currently selected options for this filter."
@@ -417,6 +394,90 @@ class Filter(object):
417
394
        for c in self.get_active_choices():
418
395
            return c
419
396
397
    def _annotate(self, choices, by='pk'):
398
        """Counts related objects for each choice in given queryset (i.e. discover
399
        how popular is each option). Returns annotated queryset.
400
        """
401
        # 
402
        choices = choices.annotate(items_count=models.Count(by))
403
        if self.sort_by_usage:
404
            choices = choices.order_by('-items_count')
405
        return choices
406
407
class RelationFilter(Filter):
408
    def extra_title(self):
409
        if isinstance(self.field, models.ManyToManyField):
410
            return self.field.rel.to._meta.verbose_name
411
412
    def generate_choices(self):
413
        # TODO: when multiple filters are active, count only intersections (? - can be heavy)
414
        
415
        related_name = getattr(self.field.rel, 'related_name', None) or \
416
                self.qs.model._meta.module_name
417
        # get all possible choices
418
        choices = self.field.rel.to.objects
419
        # apply constraints from field definition
420
        choices = choices.filter(**self.field.rel.limit_choices_to)
421
        # get intersection with current queryset
422
        choices = choices.filter(**{'%s__in' % related_name: self.qs })
423
        # count usage
424
        choices = self._annotate(choices, by=related_name)
425
426
        for c in choices:
427
            yield FilterChoice(self, unicode(c), c.pk, c.items_count)
428
Filter.register(lambda f: f.rel, RelationFilter)
429
430
'''
431
class DateFadeoutFilter(Filter):
432
    "Represents dates as single-level categories by remoteness from now."
433
    # see django.contrib.admin.filterspecs.DateFieldFilterSpec
434
    pass
435
Filter.register(lambda f: isinstance(f, models.DateField), DateDrilldownFilter)
436
437
class DateDrilldownFilter(Filter):
438
    "Represents dates as nested levels for year, month and day."
439
    pass
440
Filter.register(lambda f: isinstance(f, models.DateField), DateDrilldownFilter)
441
'''
442
443
class BooleanFilter(Filter):
444
    def generate_choices(self):
445
        # retrieve unique values and count how many times each is used
446
        choices = self.qs.values(self.lookup).distinct()
447
        choices = self._annotate(choices)
448
449
        bool_choices = (
450
            ('True',  _('yes')),
451
            ('False', _('no')),
452
        )
453
        for val,name in bool_choices:
454
            for c in choices:
455
                v = unicode(c.get(self.lookup))
456
                if v == val:
457
                    yield FilterChoice(self, name, val, c['items_count'])
458
Filter.register(lambda f: isinstance(f, models.BooleanField), BooleanFilter)
459
460
class AllValuesFilter(Filter):
461
    def generate_choices(self):
462
        # retrieve unique values and count how many times each is used
463
        choices = self.qs.values(self.lookup).distinct()
464
        choices = self._annotate(choices)
465
466
        # if list of choices is explicitly defined, exclude choices that
467
        # are not in this list (e.g. if the list was added post factum)
468
        if self.field.choices:
469
            explicit_values = [ c[0] for c in self.field.choices ]
470
            choices = choices.filter(**{'%s__in' % self.lookup: explicit_values})
471
472
        # choice title is its value unless the label is explicitly defined
473
        _value = lambda c: unicode(c.get(self.lookup))
474
        _title = lambda c: self.field.choices and \
475
                        dict(self.field.choices).get(c.get(self.lookup)) or _value(c)
476
477
        for c in choices:
478
            yield FilterChoice(self, _title(c), _value(c), c['items_count'])
479
Filter.register(lambda f: True, AllValuesFilter)
480
420
481
class FilterChoice(object):
421
482
    def __init__(self, filter, title, value, items_count):
422
483
        # TODO accept model objects and parse them as late as possible using self.filter
@@ -440,7 +501,6 @@ class FilterChoice(object):
440
501
            return False
441
502
442
503
def filter_params(qs, request, params, single=False):
443
    import warnings
444
504
    warnings.warn("filter_params() is deprecated, use FilterList() instead.",
445
505
                  DeprecationWarning, 2)
446
506
    filters = FilterList(request, qs, params, single)

Up to file-list view_shortcuts/tests.py:

@@ -5,42 +5,42 @@ __doc__="""
5
5
>>> c2 = Category.objects.create(title='Misc')
6
6
>>> a1 = Author.objects.create(name='John')
7
7
>>> a2 = Author.objects.create(name='Mary')
8
>>> s1 = Story(title='s1', text='test', status=Story.PUBLISHED)
8
>>> s1 = Story(title='s1', text='test', status=Story.PUBLISHED, paid=True)
9
9
>>> s1.save()
10
10
>>> s1.author=a1
11
11
>>> s1.category=[c1,c2]
12
12
>>> s1.save()
13
>>> s2 = Story(title='s2', text='test', status=Story.PUBLISHED)
13
>>> s2 = Story(title='s2', text='test', status=Story.PUBLISHED, paid=False)
14
14
>>> s2.save()
15
15
>>> s2.author=a2
16
16
>>> s2.category=[c1]
17
17
>>> s2.save()
18
>>> s3 = Story(title='s3', text='test', status=Story.DRAFT)
18
>>> s3 = Story(title='s3', text='test', status=Story.DRAFT, paid=True)
19
19
>>> s3.save()
20
20
>>> s3.author=a1
21
21
>>> s3.category=[c2]
22
22
>>> s3.save()
23
23
>>> qs = Story.objects.all()
24
>>> params = ('category', 'author', 'status')
24
>>> params = ('category', 'author', 'status', 'paid')
25
25
>>> from view_shortcuts.filters import FilterList
26
26
>>> request = mock_request()
27
27
>>> filters = FilterList(request, qs, params)
28
28
>>> isinstance(filters, FilterList)
29
29
True
30
30
>>> len(filters)
31
3
31
4
32
32
>>> filters.active
33
33
[]
34
34
>>> filters
35
[<Filter "category": False>, <Filter "author": False>, <Filter "status": False>]
35
[<RelationFilter "category": False>, <RelationFilter "author": False>, <AllValuesFilter "status": False>, <BooleanFilter "paid": False>]
36
36
>>> filters.object_list
37
37
[<Story: s1>, <Story: s2>, <Story: s3>]
38
38
>>> request = mock_request(author=a1.pk)
39
39
>>> filters = FilterList(request, qs, params)
40
40
>>> filters
41
[<Filter "category": False>, <Filter "author": True>, <Filter "status": False>]
41
[<RelationFilter "category": False>, <RelationFilter "author": True>, <AllValuesFilter "status": False>, <BooleanFilter "paid": False>]
42
42
>>> filters.active
43
[<Filter "author": True>]
43
[<RelationFilter "author": True>]
44
44
>>> filters.urlencode
45
45
'author=1'
46
46
>>> filters.object_list
@@ -48,17 +48,17 @@ 3
48
48
>>> request = mock_request(author=a1.pk, status=Story.PUBLISHED)
49
49
>>> filters = FilterList(request, qs, params)
50
50
>>> filters.active
51
[<Filter "author": True>, <Filter "status": True>]
51
[<RelationFilter "author": True>, <AllValuesFilter "status": True>]
52
52
>>> filters.urlencode
53
53
'author=1&status=pub'
54
54
>>> filters = FilterList(request, qs, params)
55
55
>>> filters
56
[<Filter "category": False>, <Filter "author": True>, <Filter "status": True>]
56
[<RelationFilter "category": False>, <RelationFilter "author": True>, <AllValuesFilter "status": True>, <BooleanFilter "paid": False>]
57
57
>>> filters.object_list
58
58
[<Story: s1>]
59
59
>>> flt_author = filters[1]
60
60
>>> flt_author
61
<Filter "author": True>
61
<RelationFilter "author": True>
62
62
>>> flt_author.active
63
63
True
64
64
>>> flt_author.title
@@ -83,7 +83,7 @@ 2
83
83
>>> for f in filters:
84
84
...     print u'%s:   [%s]' % (f.title, f.urlencode)
85
85
...     for c in f.choices:
86
...         print u'  - %s (%s) --> [%s]' % (c.title, c.items_count, c.urlencode)
86
...         print u'    - %s (%s) --> [%s]' % (c.title, c.items_count, c.urlencode)
87
87
Category:   [category=None]
88
88
    - News (2) --> [category=1]
89
89
    - Misc (2) --> [category=2]
@@ -93,12 +93,15 @@ Written by: [author=1]
93
93
Status:   [status=pub]
94
94
    - Published (2) --> [status=pub]
95
95
    - Draft (1) --> [status=draft]
96
Paid:   [paid=None]
97
    - yes (2) --> [paid=True]
98
    - no (1) --> [paid=False]
96
99
>>> qs_predefined = Story.objects.filter(status=Story.PUBLISHED)
97
100
>>> filters = FilterList(request, qs_predefined, params)
98
101
>>> for f in filters:
99
102
...     print u'%s:   [%s]' % (f.title, f.urlencode)
100
103
...     for c in f.choices:
101
...         print u'  - %s (%s) --> [%s]' % (c.title, c.items_count, c.urlencode)
104
...         print u'    - %s (%s) --> [%s]' % (c.title, c.items_count, c.urlencode)
102
105
Category:   [category=None]
103
106
    - News (2) --> [category=1]
104
107
    - Misc (1) --> [category=2]
@@ -107,6 +110,9 @@ Written by: [author=1]
107
110
    - Mary (1) --> [author=2]
108
111
Status:   [status=pub]
109
112
    - Published (2) --> [status=pub]
113
Paid:   [paid=None]
114
    - yes (1) --> [paid=True]
115
    - no (1) --> [paid=False]
110
116
>>> qs_predefined = Story.objects.filter(author__name__contains='J')
111
117
>>> filters = FilterList(mock_request(status=Story.PUBLISHED), qs_predefined, params)
112
118
>>> filters._qs            # predefined query
@@ -121,7 +127,7 @@ import urllib
121
127
from django.test import Client
122
128
from django.core.handlers.wsgi import WSGIRequest
123
129
from django.core.urlresolvers import reverse
124
from django.db.models import CharField, ForeignKey, IntegerField, \
130
from django.db.models import BooleanField, CharField, ForeignKey, IntegerField, \
125
131
                             ManyToManyField, Model, TextField
126
132
from django.utils.translation import ugettext_lazy as _
127
133
@@ -151,6 +157,7 @@ class Story(Model):
151
157
    category = ManyToManyField(Category, null=True, #related_name='category_set',
152
158
                               verbose_name=_('Category'))
153
159
    text     = TextField()
160
    paid     = BooleanField(_('Paid'))
154
161
155
162
    __unicode__ = lambda s: s.title
156
163
    get_url     = lambda s: reverse('example-story-detail',