neithere / django-view-shortcuts
A set of shortcuts for Django views
Clone this repository (size: 74.4 KB): HTTPS / SSH
$ hg clone http://bitbucket.org/neithere/django-view-shortcuts/
| commit 43: | 8f843415cb07 |
| parent 42: | 16791681d04b |
| branch: | default |
| tags: | 1.1 |
Refactor filters: add filter factory from django.contrib.admin.filterspecs.
- View neithere's profile
-
neithere's public repos »
- bitbucket-russian
- plasma-test
- glasnaegel
- django-autoslug
- pyrant
- datashaping
- libpluck
- scripts
- raindrop2
- pytyrant-rearranged
- nuvola
- django-ljsync
- django-view-shortcuts
- django-timeinput
- textutils
- glashammer-patches
- feedzilla
- django-common
- eav-django
- pytyrant
- django-todoist
- svarga-schemaless
- django-organizer-iad
- django-navigation
- django-organizer-gtd
- django-harness
- pymodels
- Send message
10 months ago
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)
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 |
|
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 |
|
|
248 |
lookup, param = p |
|
243 |
249 |
else: |
244 |
|
|
250 |
lookup = param = p |
|
245 |
251 |
value = request.GET.get(param) |
246 |
f = Filter |
|
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. |
|
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 |
|
|
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( |
|
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 |
[< |
|
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 |
[< |
|
41 |
[<RelationFilter "category": False>, <RelationFilter "author": True>, <AllValuesFilter "status": False>, <BooleanFilter "paid": False>] |
|
42 |
42 |
>>> filters.active |
43 |
[< |
|
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 |
[< |
|
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 |
[< |
|
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 |
< |
|
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' |
|
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' |
|
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 |
|
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', |
