Менеджеры и команды управления

Выносим повторяющиеся запросы в менеджеры и QuerySet, а разовые задачи — в свои команды manage.py.

Manager — это интерфейс к запросам модели (то самое Model.objects), а QuerySet — ленивый, цепляемый набор объектов; кастомизируя их, вы даёте запросам понятные имена и переиспользуете логику.

Зачем это нужно на практике

По коду расползается один и тот же фильтр: Article.objects.filter(is_published=True, deleted=False) встречается в десятке view, в паре команд и в шаблонном теге. Это классическое дублирование, только спрятанное в запросах. Стоит изменить правило «что считать опубликованным» — скажем, добавить условие «и дата публикации уже наступила» — и нужно вручную найти и поправить все эти места. Что-нибудь обязательно пропустят, и на одной странице появятся статьи из будущего. Кастомные менеджеры и QuerySet решают ровно эту проблему: вы пишете Article.objects.published() в одном месте, и смысл запроса задан централизованно. Код становится читаемее, потому что в месте использования виден смысл («опубликованные»), а не механика фильтра, и надёжнее, потому что правило живёт в одной точке и меняется одним движением.

Это тот же принцип, по которому повторяющуюся логику выносят в функцию. Просто здесь «функция» — это метод запроса, привязанный к модели. И раз уж он привязан к модели, его естественно держать рядом с ней, а не растаскивать по view.

Кастомный QuerySet и его методы

Самый гибкий подход — описать методы прямо на QuerySet, потому что их результат можно сцеплять дальше. Идея в том, что у QuerySet есть приятное свойство: почти все его методы (filter, exclude, order_by) возвращают новый QuerySet. Если ваш метод тоже вернёт QuerySet, он встроится в эту механику и сможет стоять в середине цепочки. Каждый метод принимает self (текущий, ещё не выполненный queryset) и возвращает новый, отфильтрованный — он не трогает базу, а лишь уточняет будущий запрос:

from django.db import models

class ArticleQuerySet(models.QuerySet):
    def published(self):
        return self.filter(is_published=True, deleted=False)

    def by_author(self, user):
        return self.filter(author=user)

    def popular(self):
        return self.order_by("-views")

Чтобы эти методы стали доступны через objects, менеджер создают из QuerySet. Удобнее всего Manager.from_queryset — он «прокидывает» все методы QuerySet в менеджер автоматически:

class Article(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey("auth.User", on_delete=models.CASCADE)
    is_published = models.BooleanField(default=False)
    deleted = models.BooleanField(default=False)
    views = models.IntegerField(default=0)

    objects = ArticleQuerySet.as_manager()   # методы QS доступны на objects

Теперь запросы читаются как фразы, и — главное — сцепляются, потому что каждый метод вернул QuerySet:

Article.objects.published()                       # все опубликованные
Article.objects.published().by_author(request.user)  # его опубликованные
Article.objects.published().popular()[:5]         # топ-5 популярных

Manager против QuerySet-методов

В чём разница и что выбрать? Метод на менеджере доступен только как начало цепочки: Article.objects.something(), но не ...published().something(). Метод на QuerySet доступен в любом месте цепочки и потому композируется. Правило простое: фильтры и сортировки кладите в QuerySet (чтобы их можно было комбинировать), а на менеджере оставляйте операции, которые логично начинать с модели (например, агрегаты или «создать с предзаполнением»). Иногда менеджер переопределяют целиком, чтобы изменить базовый набор — например, по умолчанию прятать «удалённые»:

class LiveManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(deleted=False)

class Article(models.Model):
    # ...
    objects = models.Manager()    # все записи (первым — это менеджер по умолчанию)
    live = LiveManager()          # только не удалённые

Помните: первый объявленный менеджер становится менеджером по умолчанию (его использует админка и связи). Если первым поставить менеджер с фильтром, часть фреймворка «не увидит» отфильтрованные объекты — обычно первым держат полный objects.

Свои команды manage.py

Вторая опора продвинутого Django — собственные management-команды. Это скрипты, которые запускаются как python manage.py имя и при этом работают внутри полностью настроенного проекта: уже подгружены настройки, поднято подключение к базе, доступны все модели и ORM. Этим команда выгодно отличается от обычного скрипта, лежащего сбоку: не нужно вручную инициализировать Django, импортировать настройки, открывать соединение — фреймворк всё подготовил за вас. Команды идеальны для задач, которые не привязаны к HTTP-запросу: разовый импорт данных из CSV, ночная рассылка дайджеста, чистка устаревших записей, пересчёт денормализованных полей, любые регулярные операции по cron. По сути это «вход в ваш проект со стороны терминала».

Команда — это класс Command(BaseCommand) в файле приложение/management/commands/имя.py. Структура каталогов фиксированная, в обеих папках нужен __init__.py:

myapp/
  management/
    __init__.py
    commands/
      __init__.py
      cleanup_drafts.py      # запуск: python manage.py cleanup_drafts

Вся логика — в методе handle. Аргументы и опции описывают в add_arguments (Django использует стандартный argparse):

from django.core.management.base import BaseCommand
from myapp.models import Article

class Command(BaseCommand):
    help = "Удаляет черновики старше N дней"

    def add_arguments(self, parser):
        parser.add_argument("--days", type=int, default=30)
        parser.add_argument("--dry-run", action="store_true")

    def handle(self, *args, **options):
        from django.utils import timezone
        from datetime import timedelta
        edge = timezone.now() - timedelta(days=options["days"])
        qs = Article.objects.filter(is_published=False, created__lt=edge)
        count = qs.count()
        if options["dry_run"]:
            self.stdout.write(f"Будет удалено: {count}")
            return
        qs.delete()
        self.stdout.write(self.style.SUCCESS(f"Удалено черновиков: {count}"))

Запуск с опциями: python manage.py cleanup_drafts --days 60 --dry-run. Тип --days объявлен как int, поэтому Django сам преобразует строку из командной строки в число и сам выдаст понятную ошибку, если передать не число. Опция --dry-run с action="store_true" — это флаг без значения: написали его — в options будет True, не написали — False. Обратите внимание на детали хорошего тона: вывод идёт через self.stdout.write (а не print), успех подсвечивают через self.style.SUCCESS зелёным, а флаг --dry-run позволяет безопасно посмотреть, сколько записей попадёт под нож, до того как реально их удалить. Для команд, которые что-то необратимо меняют, такой «холостой прогон» — почти обязательная практика.

Как это работает под капотом

Менеджер — это «дескриптор» на классе модели: обращение Article.objects возвращает менеджер, привязанный к модели, а его методы вроде filter создают QuerySet. QuerySet ленивый: цепочка .published().popular() только накапливает условия и не ходит в базу; SQL выполняется лишь при итерации, срезе или list()/count(). Поэтому сцеплять методы дёшево — это сборка запроса, а не множество обращений к БД. Команды же Django находит, сканируя папки management/commands во всех приложениях из INSTALLED_APPS; имя файла становится именем команды, а call_command("cleanup_drafts") позволяет вызвать её и из кода, например из теста.

Частые ошибки

  • Фильтр-метод на менеджере вместо QuerySet — теряется возможность сцеплять (...published().popular() не сработает).
  • Менеджер с фильтром поставили первым — он стал дефолтным, и админка/связи перестали видеть скрытые объекты.
  • Нет __init__.py в management/ или commands/ — команда не находится, manage.py её «не видит».
  • print вместо self.stdout в команде — ломает перенаправление вывода и тесты через call_command.

Итоги

  • Кастомный QuerySet даёт именованные, сцепляемые фильтры; QuerySet.as_manager() прокидывает их в objects.
  • Фильтры — на QuerySet (композиция), «начинающие» операции — на менеджере; первый менеджер становится дефолтным.
  • QuerySet ленив: цепочка собирает SQL и бьёт в базу только при итерации/срезе/count().
  • Команды живут в management/commands/; логика в handle, аргументы в add_arguments.
  • В командах используйте self.stdout/self.style и флаг --dry-run для безопасных операций.
Проверьте себя
1. Почему фильтры-методы лучше определять на QuerySet, а не на Manager?
AМетоды QuerySet выполняются быстрее
BМетоды QuerySet можно сцеплять в любом месте цепочки, а методы менеджера — только в начале
CНа менеджере вообще нельзя определять методы
DQuerySet кэширует результат, а менеджер нет
2. Что особенного в первом объявленном менеджере модели?
AОн становится менеджером по умолчанию — его используют админка и связи
BОн автоматически фильтрует удалённые записи
CОн работает медленнее остальных
DНичего особенного, порядок менеджеров не важен
3. Где должен находиться файл кастомной команды manage.py с именем send_digest?
AВ корне проекта рядом с manage.py
BВ myapp/commands/send_digest.py
CВ myapp/management/commands/send_digest.py, при наличии __init__.py в обеих папках
DВ любом файле приложения, лишь бы был класс Command
4. Когда выполняется SQL-запрос при цепочке Article.objects.published().popular()?
AСразу при вызове published()
BПо одному запросу на каждый метод в цепочке
CТолько при итерации, срезе или вызове list()/count() — QuerySet ленивый
DЗапрос не выполняется, пока не вызвать .save()