neithere / django-view-shortcuts

A set of shortcuts for Django views

Changed (Δ2.1 KB):

raw changeset »

view_shortcuts/__init__.py (1 lines added, 1 lines removed)

view_shortcuts/filters.py (136 lines added, 56 lines removed)

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

Up to file-list view_shortcuts/__init__.py:

12
12
__author__  = 'Andy Mikhailenko'
13
13
__license__ = 'GNU Lesser General Public License (GPL), Version 3'
14
14
__url__     = 'http://bitbucket.org/neithere/django-view-shortcuts/'
15
__version__ = '1.1.1'
15
__version__ = '1.2'

Up to file-list view_shortcuts/filters.py:

@@ -17,13 +17,14 @@ from django.db import models
17
17
from django.utils.translation import ugettext_lazy as _
18
18
from decorators import cached_property
19
19
20
20
21
def filter_date(items, field_name, year, month=None, day=None):
21
22
    """
22
23
    Filters given queryset by date if any provided. Accepts three scopes: year, month and day.
23
24
    Similar to date_based generic view but for ordinary views.
24
    
25
25
26
    Instead of:
26
    
27
27
28
        def my_entry_list(request, year=None, month=None, day=None):
28
29
            entries = Entry.objects.all()
29
30
            if year and month and day:
@@ -32,9 +33,9 @@ def filter_date(items, field_name, year,
32
33
                entries = entries.filter(pub_date__month=month)
33
34
            if year:
34
35
                entries = entries.filter(pub_date__year=year)
35
    
36
36
37
    ...You can just write:
37
    
38
38
39
        def my_entry_list(request, year=None, month=None, day=None):
39
40
            entries = Entry.objects.all()
40
41
            entries = filter_date(entries, 'pub_date', year, month, day)
@@ -50,33 +51,33 @@ def filter_date(items, field_name, year,
50
51
def filter_date_range(items, start, end):
51
52
    """
52
53
    Filters given queryset by date range.
53
    
54
54
55
    Useful for queries with lookups (i.e. 'foo__bar') where nested date lookups
55
56
    are not possible (e.g. things like 'foo__somedate__year__gte' are not allowed).
56
    
57
57
58
    Usage:
58
59
    filter_date_range(queryset, start, end)
59
60
    where "start" and "end" are tuples of this form: (field_name, year, [month, [day])
60
    
61
61
62
    Example:
62
    
63
63
64
        def my_entry_list(request, from_year=None, to_year=None):
64
65
            people = Entry.objects.all()
65
66
            # show all entries of this year
66
67
            people = filter_date_range(people, ('joined', from_year), ('left', to_year))
67
68
    """
68
    
69
69
70
    def __make_qargs(mode, field_name, year, month, day):
70
71
        q_field = '%s__%s' % (field_name, mode)
71
72
        q_date  = '%s-%s-%s' % (year, month, day)
72
73
        return {q_field:q_date}
73
    
74
74
75
    def __make_qargs_till(field_name, year, month='12', day='31'):
75
76
        return __make_qargs('lte', field_name, year, month, day)
76
    
77
77
78
    def __make_qargs_from(field_name, year, month='01', day='01'):
78
79
        return __make_qargs('gte', field_name, year, month, day)
79
    
80
80
81
    qargs = dict(__make_qargs_from(*start), **__make_qargs_till(*end))
81
82
    items = items.filter(**qargs)
82
83
    return items
@@ -85,18 +86,18 @@ def filter_field(items, field_name, valu
85
86
    """
86
87
    Filters given queryset by given field.
87
88
    This filter does not significantly shorten your code but does make it a bit more readable.
88
    
89
89
90
    Instead of:
90
    
91
91
92
        def my_entry_list(request, foo=None, bar=None):
92
93
            entries = Entry.objects.all()
93
94
            if foo:
94
95
                entries = entries.filter(foo=foo)
95
96
            if bar:
96
97
                entries = entries.filter(bar=bar)
97
    
98
98
99
    ...You can write:
99
    
100
100
101
        def my_entry_list(request, foo=None, bar=None):
101
102
            entries = Entry.objects.all()
102
103
            entries = filter_field(entries, 'foo', foo)
@@ -114,18 +115,18 @@ def filter_param(items, request, field_n
114
115
    Filters given queryset by given field with its value automatically taken from
115
116
    given Request parameter. If the param is not specified, it's assumed to be
116
117
    of the same name with the field.
117
    
118
118
119
    Instead of:
119
    
120
120
121
        def my_entry_list(request):
121
122
            entries = Entry.objects.all()
122
123
            if request.GET.get('foo'):
123
124
                entries = entries.filter(foo=request.GET.get('foo'))
124
125
            if request.GET.get('barbar'):
125
126
                entries = entries.filter(bar=request.GET.get('barbar'))
126
    
127
127
128
    You can write:
128
    
129
129
130
        def my_entry_list(request):
130
131
            entries = Entry.objects.all()
131
132
            entries = filter_param(entries, request, 'foo')
@@ -139,6 +140,26 @@ def filter_param(items, request, field_n
139
140
        items = items.filter(**{field_name:value})
140
141
    return items
141
142
143
144
class facet(dict):
145
    """"A dictionary representing a facet filter settings.
146
    A Filter instance will be built using them.
147
148
    Example:
149
150
    >>> facets = (
151
    ...     facet('category'),
152
    ...     facet('author__pk', 'author', AlphabetRelationFilter),
153
    ... )
154
    >>> FilterList(qs, request, facets)
155
    """
156
    def __init__(self, lookup, param=None, kind=None):
157
        super(dict, self).__init__()
158
        if kind: assert issubclass(kind, Filter)
159
        self['lookup'] = lookup
160
        self['param']  = param or lookup
161
        self['kind']   = kind or Filter
162
142
163
class FilterList(list):
143
164
    """Filters given queryset by multiple fields with their values automatically
144
165
    taken from given HttpRequest parameters. If a parameter is not specified,
@@ -244,17 +265,23 @@ class FilterList(list):
244
265
        def _generate_filters(request, qs, params, single, sort_by_usage):
245
266
            single_triggered = False
246
267
            for p in params:
247
                if isinstance(p, (tuple,list)):
268
                klass = Filter
269
                if isinstance(p, facet):
270
                    lookup, param, klass = p['lookup'], p['param'], p['kind']
271
                elif isinstance(p, (tuple,list)):
272
                    warnings.warn("using tuple for lookup/param coupling is"\
273
                                  "deprecated, use filters.facet() instead.",
274
                                  DeprecationWarning, 2)
248
275
                    lookup, param = p
249
276
                else:
250
277
                    lookup = param = p
278
                active = False
251
279
                value = request.GET.get(param)
252
                f = Filter.create(param, qs=qs, lookup=lookup, active=False, sort_by_usage=sort_by_usage)
253
280
                if value and not single_triggered:
254
281
                    if single:
255
282
                        single_triggered = True
256
                    f.active = True
257
                    f.value = value
283
                    active = True
284
                f = klass.create(param, qs, lookup, value, active, sort_by_usage)
258
285
                yield f
259
286
        super(FilterList, self).__init__(
260
287
            _generate_filters(request, qs, params, single, sort_by_usage)
@@ -308,44 +335,70 @@ class FilterList(list):
308
335
        )
309
336
        return self._qs.model.objects.filter(**lookup_params)
310
337
338
311
339
class Filter(object):
312
340
    """ A facet filter. Objects of this class are instantiated by filter_params()
313
341
    and returned along with the queryset.
314
342
    Filter objects can then be passed to template and used to generate UI
315
343
    for tuning the queryset.
316
    
344
317
345
    By default the choices are sorted by usage (most referenced on top). To reset
318
    this behaviour, set sort_by_usage=False, 
319
    
346
    this behaviour, set sort_by_usage=False,
347
320
348
    Usage: see FilterList.
321
349
322
350
    Each filter subclass knows how to display a filter for a field that passes a
323
351
    certain test -- e.g. being a DateField or ForeignKey.
324
352
    """
325
    filter_specs = []
326
    def __init__(self, param, qs, lookup, field, active, value, sort_by_usage):
353
    _cached_fields = {}
354
    _filter_specs = []
355
    def __init__(self, param, qs, lookup, value, active=False, sort_by_usage=False):
327
356
        self.param = param
328
357
        self.qs = qs
329
358
        self.lookup = lookup
330
        self.field = field
359
        self.value = value
331
360
        self.active = active
332
        self.value = value
333
361
        self.sort_by_usage = sort_by_usage
362
        self.field = self.resolve_field(qs, lookup)
334
363
335
364
    def __repr__(self):
336
365
        return u'<%s "%s": %s>' % (self.__class__.__name__, self.param, self.active)
337
366
338
367
    @classmethod
339
    def register(cls, test, factory):
340
        cls.filter_specs.append((test, factory))
368
    def register(cls, factory):
369
        """Registers Filter subclass so that it can be automatically chosen if
370
        no concrete class is explicitly specified. Note that classes are checked
371
        one by one in the order they are registered, and the first one which passes
372
        the test (i.e. which ``suitable_for()`` method returns True for given
373
        field) is chosen. The last one should be the universal AllValuesFilter.
374
        If your class is registered after it, it will *not* be used. If your
375
        class appears to be "all values" too, do not register it -- just specify
376
        it in your views in a facet.
377
        """
378
        cls._filter_specs.append(factory)
379
380
    @staticmethod
381
    def resolve_field(qs, lookup):
382
        f = None
383
        if lookup not in Filter._cached_fields:
384
            # we need the "author" part of "author__pk" lookup
385
            f = qs.model._meta.get_field(lookup.split('__')[0])
386
        return Filter._cached_fields.setdefault(lookup, f)
341
387
342
388
    @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)
389
    def create(cls, param, qs, lookup, value, active=False, sort_by_usage=True):
390
        # chosen by user
391
        if not cls == Filter:
392
            return cls(param, qs, lookup, value, active, sort_by_usage)
393
        # autoselect
394
        field = cls.resolve_field(qs,lookup)
395
        for factory in cls._filter_specs:
396
            if factory.suitable_for(field):
397
                return factory(param, qs, lookup, value, active, sort_by_usage)
398
399
    @classmethod
400
    def suitable_for(cls, field):
401
        return True
349
402
350
403
    @cached_property
351
404
    def urlencode(self):
@@ -371,24 +424,24 @@ class Filter(object):
371
424
        """Returns possible choices, each annotated with the number of linked
372
425
        objects. Multiple FKs from one model to another are supported as well as
373
426
        explicit choice lists and implicit ones (i.e. any possible values).
374
        
427
375
428
        Resulting list contains FilterChoice objects.
376
        
429
377
430
        An existing choice will be excluded from results if:
378
        
431
379
432
          a) it is not used by any object in current queryset;
380
          
433
381
434
          b) it is used but is not in the field's explicit list of choices
382
435
             (i.e. the "choices" keyword).
383
436
        """
384
437
        return list(self.generate_choices())
385
    
438
386
439
    def get_active_choices(self):
387
440
        "Returns list of currently selected options for this filter."
388
441
        for c in self.choices:
389
442
            if c.active:
390
443
                yield c
391
    
444
392
445
    def get_first_active_choice(self):
393
446
        "Returns one of currently selected options for this filter (usually enough)."
394
447
        for c in self.get_active_choices():
@@ -398,20 +451,27 @@ class Filter(object):
398
451
        """Counts related objects for each choice in given queryset (i.e. discover
399
452
        how popular is each option). Returns annotated queryset.
400
453
        """
401
        # 
454
        #
402
455
        choices = choices.annotate(items_count=models.Count(by))
403
456
        if self.sort_by_usage:
404
457
            choices = choices.order_by('-items_count')
405
458
        return choices
406
459
460
461
@Filter.register
407
462
class RelationFilter(Filter):
463
    @classmethod
464
    def suitable_for(cls, field):
465
        if field.rel:
466
            return True
467
408
468
    def extra_title(self):
409
469
        if isinstance(self.field, models.ManyToManyField):
410
470
            return self.field.rel.to._meta.verbose_name
411
471
412
472
    def generate_choices(self):
413
473
        # TODO: when multiple filters are active, count only intersections (? - can be heavy)
414
        
474
415
475
        related_name = getattr(self.field.rel, 'related_name', None) or \
416
476
                self.qs.model._meta.module_name
417
477
        # get all possible choices
@@ -425,22 +485,39 @@ class RelationFilter(Filter):
425
485
426
486
        for c in choices:
427
487
            yield FilterChoice(self, unicode(c), c.pk, c.items_count)
428
Filter.register(lambda f: f.rel, RelationFilter)
488
429
489
430
490
'''
491
@Filter.register
431
492
class DateFadeoutFilter(Filter):
432
493
    "Represents dates as single-level categories by remoteness from now."
494
495
    @staticmethod
496
    def suitable_for(field):
497
        return isinstance(f, models.DateField)
498
433
499
    # see django.contrib.admin.filterspecs.DateFieldFilterSpec
434
500
    pass
435
Filter.register(lambda f: isinstance(f, models.DateField), DateDrilldownFilter)
436
501
502
503
@Filter.register
437
504
class DateDrilldownFilter(Filter):
438
505
    "Represents dates as nested levels for year, month and day."
506
507
    @staticmethod
508
    def suitable_for(field):
509
        return isinstance(f, models.DateField)
510
439
511
    pass
440
Filter.register(lambda f: isinstance(f, models.DateField), DateDrilldownFilter)
441
512
'''
442
513
514
515
@Filter.register
443
516
class BooleanFilter(Filter):
517
    @staticmethod
518
    def suitable_for(field):
519
        return isinstance(field, models.BooleanField)
520
444
521
    def generate_choices(self):
445
522
        # retrieve unique values and count how many times each is used
446
523
        choices = self.qs.values(self.lookup).distinct()
@@ -455,8 +532,9 @@ class BooleanFilter(Filter):
455
532
                v = unicode(c.get(self.lookup))
456
533
                if v == val:
457
534
                    yield FilterChoice(self, name, val, c['items_count'])
458
Filter.register(lambda f: isinstance(f, models.BooleanField), BooleanFilter)
459
535
536
537
@Filter.register
460
538
class AllValuesFilter(Filter):
461
539
    def generate_choices(self):
462
540
        # retrieve unique values and count how many times each is used
@@ -476,7 +554,7 @@ class AllValuesFilter(Filter):
476
554
477
555
        for c in choices:
478
556
            yield FilterChoice(self, _title(c), _value(c), c['items_count'])
479
Filter.register(lambda f: True, AllValuesFilter)
557
480
558
481
559
class FilterChoice(object):
482
560
    def __init__(self, filter, title, value, items_count):
@@ -485,13 +563,14 @@ class FilterChoice(object):
485
563
        self.title = title
486
564
        self.value = value
487
565
        self.items_count = items_count
488
    
489
    __repr__ = lambda self: u'<Choice %s="%s">' % (self.filter.param, self.value)
490
    
566
567
    def __repr__(self):
568
        return u'<Choice %s="%s">' % (self.filter.param, self.value)
569
491
570
    @cached_property
492
571
    def urlencode(self):
493
572
        return urlencode({self.filter.param: self.value})
494
    
573
495
574
    @cached_property
496
575
    def active(self):
497
576
        "Returns True if the choice value equals to the filter's current value."
@@ -500,6 +579,7 @@ class FilterChoice(object):
500
579
        except TypeError, ValueError:
501
580
            return False
502
581
582
503
583
def filter_params(qs, request, params, single=False):
504
584
    warnings.warn("filter_params() is deprecated, use FilterList() instead.",
505
585
                  DeprecationWarning, 2)

Up to file-list view_shortcuts/tests.py:

@@ -21,10 +21,15 @@ __doc__="""
21
21
>>> s3.category=[c2]
22
22
>>> s3.save()
23
23
>>> qs = Story.objects.all()
24
>>> params = ('category', 'author', 'status', 'paid')
25
>>> from view_shortcuts.filters import FilterList
24
>>> from view_shortcuts.filters import FilterList, facet, RelationFilter
25
>>> filter_settings = (
26
...     facet('category'),
27
...     facet('author__id', 'author', RelationFilter), # redundant manual setting
28
...     facet('status'),
29
...     facet('paid')
30
... )
26
31
>>> request = mock_request()
27
>>> filters = FilterList(request, qs, params)
32
>>> filters = FilterList(request, qs, filter_settings)
28
33
>>> isinstance(filters, FilterList)
29
34
True
30
35
>>> len(filters)
@@ -36,7 +41,7 @@ 4
36
41
>>> filters.object_list
37
42
[<Story: s1>, <Story: s2>, <Story: s3>]
38
43
>>> request = mock_request(author=a1.pk)
39
>>> filters = FilterList(request, qs, params)
44
>>> filters = FilterList(request, qs, filter_settings)
40
45
>>> filters
41
46
[<RelationFilter "category": False>, <RelationFilter "author": True>, <AllValuesFilter "status": False>, <BooleanFilter "paid": False>]
42
47
>>> filters.active
@@ -46,12 +51,12 @@ 4
46
51
>>> filters.object_list
47
52
[<Story: s1>, <Story: s3>]
48
53
>>> request = mock_request(author=a1.pk, status=Story.PUBLISHED)
49
>>> filters = FilterList(request, qs, params)
54
>>> filters = FilterList(request, qs, filter_settings)
50
55
>>> filters.active
51
56
[<RelationFilter "author": True>, <AllValuesFilter "status": True>]
52
57
>>> filters.urlencode
53
58
'author=1&status=pub'
54
>>> filters = FilterList(request, qs, params)
59
>>> filters = FilterList(request, qs, filter_settings)
55
60
>>> filters
56
61
[<RelationFilter "category": False>, <RelationFilter "author": True>, <AllValuesFilter "status": True>, <BooleanFilter "paid": False>]
57
62
>>> filters.object_list
@@ -97,7 +102,7 @@ Paid: [paid=None]
97
102
    - yes (2) --> [paid=True]
98
103
    - no (1) --> [paid=False]
99
104
>>> qs_predefined = Story.objects.filter(status=Story.PUBLISHED)
100
>>> filters = FilterList(request, qs_predefined, params)
105
>>> filters = FilterList(request, qs_predefined, filter_settings)
101
106
>>> for f in filters:
102
107
...     print u'%s:   [%s]' % (f.title, f.urlencode)
103
108
...     for c in f.choices:
@@ -114,7 +119,7 @@ Paid: [paid=None]
114
119
    - yes (1) --> [paid=True]
115
120
    - no (1) --> [paid=False]
116
121
>>> qs_predefined = Story.objects.filter(author__name__contains='J')
117
>>> filters = FilterList(mock_request(status=Story.PUBLISHED), qs_predefined, params)
122
>>> filters = FilterList(mock_request(status=Story.PUBLISHED), qs_predefined, filter_settings)
118
123
>>> filters._qs            # predefined query
119
124
[<Story: s1>, <Story: s3>]
120
125
>>> filters.clean_query    # query made from scratch, no traces of predefined stuff
@@ -167,19 +172,19 @@ class Story(Model):
167
172
class RequestFactory(Client):
168
173
    """
169
174
    Class that lets you create mock Request objects for use in testing.
170
    
175
171
176
    Usage:
172
    
177
173
178
    rf = RequestFactory()
174
179
    get_request = rf.get('/hello/')
175
180
    post_request = rf.post('/submit/', {'foo': 'bar'})
176
    
181
177
182
    This class re-uses the django.test.client.Client interface, docs here:
178
183
    http://www.djangoproject.com/documentation/testing/#the-test-client
179
    
180
    Once you have a request object you can pass it to any view function, 
184
185
    Once you have a request object you can pass it to any view function,
181
186
    just as if that view had been hooked up using a URLconf.
182
    
187
183
188
    Source: http://www.djangosnippets.org/snippets/963/
184
189
    """
185
190
    def request(self, **request):