The recording for this lesson has not been published yet.
Look through your views. This line, or something very close to it, appears everywhere:
# blog/views.py
def post_list(request):
posts = Post.objects.filter(
published_date__lte=timezone.now()
).order_by("-published_date")
return render(request, "blog/post_list.html", {"posts": posts})
def category_detail(request, slug):
category = get_object_or_404(Category, slug=slug)
posts = category.posts.filter(
published_date__lte=timezone.now()
).order_by("-published_date")
...
def feed(request):
posts = Post.objects.filter(
published_date__lte=timezone.now()
).order_by("-published_date")[:20]
...
The definition of "published" is now spelled out in three places. On the day you add a
status field or decide drafts should also be hidden from the sitemap, you have
to find every one of them - and you will miss one, and it will be the one that leaks
unpublished posts.
A manager is where that knowledge belongs. The rule: a view describes what the page needs; the model describes what the words mean.
objects is not magic. It is a class attribute holding a
Manager instance, added automatically if you do not declare one. Its job is to
hand you a fresh QuerySet; the QuerySet is what actually knows how to filter.
# blog/models.py
from django.db import models
from django.utils import timezone
class PublishedManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(published_date__lte=timezone.now())
class Post(models.Model):
...
objects = models.Manager() # keep the default, explicitly
published = PublishedManager()
Post.published.all() # only live posts
Post.published.count()
Post.objects.all() # still everything, including drafts
Django only adds objects when the model defines no manager at all. As soon
as you write published = PublishedManager() and nothing else,
Post.objects disappears and the admin breaks. Always list the plain
manager too.
This approach has a real limitation: published is a filter you can only apply
at the start. There is no way to say "of these posts, the published ones", and you cannot
add a second concept like in_category() without writing methods that return
plain QuerySets and therefore stop chaining.
Put the methods on the QuerySet instead. Because each one returns self.filter(...),
which is still a PostQuerySet, they compose in any order:
# blog/managers.py
from django.db import models
from django.db.models import Count, Q
from django.utils import timezone
class PostQuerySet(models.QuerySet):
def published(self):
return self.filter(published_date__lte=timezone.now())
def drafts(self):
return self.filter(published_date__isnull=True)
def in_category(self, slug):
return self.filter(category__slug=slug)
def tagged(self, *slugs):
return self.filter(tags__slug__in=slugs).distinct()
def with_comment_counts(self):
return self.annotate(
num_comments=Count("comments", filter=Q(comments__approved=True))
)
def search(self, term):
if not term:
return self
return self.filter(Q(title__icontains=term) | Q(text__icontains=term))
def newest_first(self):
return self.order_by("-published_date")
# blog/models.py
from .managers import PostQuerySet
class Post(models.Model):
...
objects = PostQuerySet.as_manager()
The views collapse into sentences:
def post_list(request):
posts = (
Post.objects.published()
.with_comment_counts()
.newest_first()
)
return render(request, "blog/post_list.html", {"posts": posts})
def category_detail(request, slug):
category = get_object_or_404(Category, slug=slug)
posts = category.posts.published().newest_first()
...
Note the second one: because the reverse accessor uses the model's default manager class,
category.posts also has published(). That is the payoff of
putting the logic on the QuerySet.
as_manager() copies the QuerySet methods onto a manager, but not the other way
around. If you also want manager-only behaviour - a different base queryset, or a helper
that does not return a QuerySet - use from_queryset():
class PostManager(models.Manager.from_queryset(PostQuerySet)):
def get_queryset(self):
return super().get_queryset().select_related("author", "category")
def create_draft(self, author, title, text):
return self.create(author=author, title=title, text=text, published_date=None)
class Post(models.Model):
...
objects = PostManager()
| You want | Use |
|---|---|
| chainable query methods only | PostQuerySet.as_manager() |
| chainable methods plus manager helpers | Manager.from_queryset(PostQuerySet) |
| a permanently narrowed set of rows | a Manager overriding get_queryset() |
Order matters. The first manager declared becomes
Model._default_manager, and that is the one Django itself uses - the admin,
dumpdata, related object lookups and generic views:
class Post(models.Model):
...
objects = PostQuerySet.as_manager() # first = default manager
published = PublishedManager()
class Meta:
base_manager_name = "objects"
default_manager_name = "objects"
base_manager_name is the subtler of the two. It is the manager Django uses when
it follows a relation to fetch a single related object, for example
comment.post. If that manager filters rows out, following the relation can
raise Post.DoesNotExist for a row that plainly exists.
A default manager that hides rows makes objects vanish from the admin, breaks
on_delete cascades and silently loses data in dumpdata. Keep
the unfiltered manager first and expose the narrow view as a QuerySet method or a second
manager.
Once the rule lives in one place, one test protects the whole site:
# blog/tests/test_managers.py
from datetime import timedelta
from django.test import TestCase
from django.utils import timezone
from blog.models import Post
class PostQuerySetTests(TestCase):
def setUp(self):
self.author = User.objects.create_user("ada")
self.live = Post.objects.create(
author=self.author, title="Live", text="x",
published_date=timezone.now() - timedelta(days=1),
)
self.draft = Post.objects.create(author=self.author, title="Draft", text="x")
self.future = Post.objects.create(
author=self.author, title="Later", text="x",
published_date=timezone.now() + timedelta(days=1),
)
def test_published_excludes_drafts_and_future(self):
self.assertQuerySetEqual(Post.objects.published(), [self.live])
def test_methods_chain_in_any_order(self):
self.assertEqual(
list(Post.objects.published().search("Live")),
list(Post.objects.search("Live").published()),
)
blog/managers.py with a PostQuerySet that has
published(), drafts() and newest_first(), and wire
it up with as_manager().published_date__lte=timezone.now() in your views and feeds
with published(). Grep the project afterwards to prove none is left.in_category(slug) and tagged(*slugs), then write one
expression returning published posts in "django" tagged "orm", newest first.CommentQuerySet with approved() and
pending(), and use post.comments.approved() in the detail
template's context.PublishedManager the first manager on
Post, open the admin, and describe what breaks. Then undo it.