From aed583f763ca3595085e6022d61c61ae3a898c27 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Fri, 30 Jul 2010 22:09:46 -0400 Subject: [PATCH] added manager_from (thanks George Sakkis) --- CHANGES.rst | 1 + README.rst | 43 +++++++++++++++++++++++++++++++++++++ model_utils/managers.py | 43 ++++++++++++++++++++++++++++++++++++- model_utils/tests/models.py | 19 +++++++++++++++- model_utils/tests/tests.py | 15 ++++++++++++- 5 files changed, 118 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 799c3cf..5860908 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,7 @@ CHANGES tip (unreleased) ---------------- +- added manager_from (thanks George Sakkis) - added StatusField, MonitorField, TimeFramedModel, and StatusModel (thanks Jannis Leidel) - deprecated ChoiceEnum and replaced with Choices diff --git a/README.rst b/README.rst index e2fdd74..cd756e0 100644 --- a/README.rst +++ b/README.rst @@ -290,3 +290,46 @@ set the ordering of the ``QuerySet`` returned by the ``QueryManager`` by chaining a call to ``.order_by()`` on the ``QueryManager`` (this is not required). +manager_from +============ + +A common "gotcha" when defining methods on a custom manager class is +that those same methods are not automatically also available on the +QuerySet used by that model, so are not "chainable". This can be +counterintuitive, as most of the public QuerySet API is also available +on managers. It is possible to create a custom Manager that returns +QuerySets that have the same additional methods, but this requires +boilerplate code. + +The ``manager_from`` function (`created by George Sakkis`_ and +included here by permission) solves this problem with zero +boilerplate. It creates and returns a Manager subclass with additional +behavior defined by mixin subclasses or functions you pass it, and the +returned Manager will return instances of a custom QuerySet with those +same additional methods:: + + from datetime import datetime + from django.db import models + + class AuthorMixin(object): + def by_author(self, user): + return self.filter(user=user) + + class PublishedMixin(object): + def published(self): + return self.filter(published__lte=datetime.now()) + + def unpublished(self): + return self.filter(published__gte=datetime.now()) + + + class Post(models.Model): + user = models.ForeignKey(User) + published = models.DateTimeField() + + objects = manager_from(AuthorMixin, PublishedMixin, unpublished) + + Post.objects.published() + Post.objects.by_author(user=request.user).unpublished() + +.. _created by George Sakkis: http://djangosnippets.org/snippets/2117/ diff --git a/model_utils/managers.py b/model_utils/managers.py index 424c48b..311c956 100644 --- a/model_utils/managers.py +++ b/model_utils/managers.py @@ -1,4 +1,8 @@ +from types import ClassType + from django.db import models +from django.db.models.manager import Manager +from django.db.models.query import QuerySet class QueryManager(models.Manager): def __init__(self, *args, **kwargs): @@ -17,4 +21,41 @@ class QueryManager(models.Manager): if hasattr(self, '_order_by'): return qs.order_by(*self._order_by) return qs - + + +def manager_from(*mixins, **kwds): + ''' + Returns a Manager instance with extra methods, also available and + chainable on generated querysets. + + (By George Sakkis, originally posted at + http://djangosnippets.org/snippets/2117/) + + :param mixins: Each ``mixin`` can be either a class or a function. The + generated manager and associated queryset subclasses extend the mixin + classes and include the mixin functions (as methods). + + :keyword queryset_cls: The base queryset class to extend from + (``django.db.models.query.QuerySet`` by default). + + :keyword manager_cls: The base manager class to extend from + (``django.db.models.manager.Manager`` by default). + ''' + bases = [kwds.get('queryset_cls', QuerySet)] + attrs = {} + for mixin in mixins: + if isinstance(mixin, (ClassType, type)): + bases.append(mixin) + else: + try: attrs[mixin.__name__] = mixin + except AttributeError: + raise TypeError('Mixin must be class or function, not %s' % + mixin.__class__) + # create the QuerySet subclass + id = hash(mixins + tuple(kwds.iteritems())) + qset_cls = type('Queryset_%d' % id, tuple(bases), attrs) + # create the Manager subclass + bases[0] = kwds.get('manager_cls', Manager) + attrs['get_query_set'] = lambda self: qset_cls(self.model, using=self._db) + manager_cls = type('Manager_%d' % id, tuple(bases), attrs) + return manager_cls() diff --git a/model_utils/tests/models.py b/model_utils/tests/models.py index 5ebfe6f..5d3ebe9 100644 --- a/model_utils/tests/models.py +++ b/model_utils/tests/models.py @@ -2,7 +2,7 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ from model_utils.models import InheritanceCastModel, TimeStampedModel, StatusModel, TimeFramedModel -from model_utils.managers import QueryManager +from model_utils.managers import QueryManager, manager_from from model_utils.fields import SplitField, MonitorField from model_utils import Choices @@ -74,3 +74,20 @@ class NoRendered(models.Model): """ body = SplitField(no_excerpt_field=True) + +class AuthorMixin(object): + def by_author(self, name): + return self.filter(author=name) + +class PublishedMixin(object): + def published(self): + return self.filter(published=True) + +def unpublished(self): + return self.filter(published=False) + +class Entry(models.Model): + author = models.CharField(max_length=20) + published = models.BooleanField() + + objects = manager_from(AuthorMixin, PublishedMixin, unpublished) diff --git a/model_utils/tests/tests.py b/model_utils/tests/tests.py index 426d80f..6860adf 100644 --- a/model_utils/tests/tests.py +++ b/model_utils/tests/tests.py @@ -10,7 +10,7 @@ from model_utils.fields import get_excerpt from model_utils.managers import QueryManager from model_utils.tests.models import (InheritParent, InheritChild, TimeStamp, Post, Article, Status, StatusPlainTuple, TimeFrame, Monitored, - StatusManagerAdded, TimeFrameManagerAdded) + StatusManagerAdded, TimeFrameManagerAdded, Entry) class GetExcerptTests(TestCase): @@ -318,3 +318,16 @@ if 'south' in settings.INSTALLED_APPS: self.assertRaises(FieldDoesNotExist, NoRendered._meta.get_field, '_body_excerpt') + +class ManagerFromTests(TestCase): + def setUp(self): + Entry.objects.create(author='George', published=True) + Entry.objects.create(author='George', published=False) + Entry.objects.create(author='Paul', published=True) + + def test_chaining(self): + self.assertEqual(Entry.objects.by_author('George').published().count(), + 1) + + def test_function(self): + self.assertEqual(Entry.objects.unpublished().count(), 1)