2025, Nov 09 03:02

Взаимные подписчики в Django: считаем во вьюхе, а не в шаблоне

Разбираем, почему подсчет взаимных подписчиков в шаблоне Django ломается и показываем правильный путь: annotate, Exists и Prefetch в queryset, подсчет во вьюхе.

Считать взаимных подписчиков прямо в шаблоне Django кажется заманчивой идеей: аватары уже под рукой, почему бы не вызвать count или не воспользоваться length там же, где вы их выводите? Но есть нюанс: шаблоны Django преднамеренно ограничены и не предназначены для обработки данных. В этом материале разбираем, что именно ломается, по какой причине и как корректно посчитать взаимные связи и их количество во вьюхе — там, где этой логике и место.

Постановка задачи

У вас есть самоссылочный граф подписок, и вы уже умеете показывать аватары взаимных подписчиков. Однако попытка получить их количество в шаблоне с помощью count или length падает или дает неверный результат. Корневая проблема — смешивание выборки данных с их представлением.

Минимальный пример проблемы

# models.py
from django.conf import settings
from django.db import models

class Persona(models.Model):
    account = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        blank=True,
        null=True,
    )
    avatar = models.ImageField(
        upload_to='UploadedProfilePicture/',
        default='ProfileAvatar/avatar.png',
        blank=True,
    )
    follows = models.ManyToManyField(
        'Persona',
        symmetrical=False,
        related_name='backers',
        blank=True,
    )
# views.py
from django.db.models import Q
from django.shortcuts import render

def network_view(request):
    page_title = 'Following'

    everyone = Persona.objects.exclude(Q(account=request.user))

    my_follow_ids = request.user.persona.follows.values_list('pk', flat=True)
    mutual_pool = Persona.objects.filter(pk__in=my_follow_ids)

    return render(
        request,
        'my_template.html',
        {
            'people': everyone,
            'mutual_pool': mutual_pool,
        },
    )
{% comment %} my_template.html {% endcomment %}
{% for person in people %}
  <p>{{ person.account }}</p>

  {% for candidate in mutual_pool %}
    {% if candidate in person.follows.all %}
      <img src="{{ candidate.avatar.url }}" class="hover-followers-img" />
      {{ candidate.count }}
    {% endif %}
  {% endfor %}
{% endfor %}

Здесь сразу две проблемы. Во‑первых, candidate — это одиночный объект, а не коллекция, поэтому вызывать у него count бессмысленно. Во‑вторых, отбор взаимных подписчиков зашит в шаблон через проверку in и повторяется для каждой строки, из‑за чего код работает неэффективно и затрудняет подсчет.

Что на самом деле не так

Язык шаблонов Django намеренно ограничен. Он не позволяет произвольно вызывать функции, передавать параметры в методы или строить сложную логику выборки. И даже если обойти эти ограничения через фильтры, отбор данных в шаблоне получается трудным для сопровождения и неэффективным. Отбор, агрегации и prefetch следует делать во вьюхе или на уровне queryset; шаблоны должны только отображать уже подготовленные данные.

Правильный подход: считать взаимных и их количество во вьюхе

Перенесите весь фильтр и агрегации в queryset. Используйте annotate для подсчетов и prefetch_related с отфильтрованным queryset для самих объектов взаимных подписчиков, которые вы будете показывать. Тогда шаблон останется простым: он лишь выведет аватары и уже заранее посчитанное число.

Решение для актуального Django (с Exists и Prefetch)

# views.py
from django.db.models import Count, Exists, OuterRef, Prefetch, Q
from django.db.models.functions import Greatest
from django.shortcuts import render

# Примечание: snake_case — каноничный стиль именования функций в Python

def network_view(request):
    page_title = 'Following'

    people = (
        Persona.objects
        .exclude(Q(account=request.user))
        .annotate(
            # Исключаем отрицательные значения, ограничивая нулем
            mutual_total=Greatest(
                Count(
                    'follows',
                    filter=Q(follows__backers__account=request.user)
                ) - 2,
                0,
            )
        )
        .prefetch_related(
            Prefetch(
                'follows',
                Persona.objects.filter(
                    Exists(
                        Persona.follows.through.objects.filter(
                            from_persona_id=OuterRef('pk'),
                            to_persona__account=request.user,
                        )
                    )
                ),
                to_attr='mutual_peers',
            )
        )
    )

    return render(
        request,
        'my_template.html',
        {
            'people': people,
        },
    )

Рендеринг в шаблоне становится простым и быстрым: каждая строка уже содержит заранее отобранных взаимных и предвычисленное число.

{% comment %} my_template.html {% endcomment %}
{% for person in people %}
  <p>{{ person.account }}</p>

  {% for peer in person.mutual_peers %}
    <img src="{{ peer.avatar.url }}" class="hover-followers-img" />
  {% endfor %}

  <b>{{ person.mutual_total }}</b>
{% endfor %}

Альтернатива для Django 2.2

Если вы используете Django 2.2, prefetch с Exists непосредственно в filter там работает иначе. Пометьте строки через annotate, а затем отфильтруйте по этому признаку внутри queryset для Prefetch.

# views.py
from django.db.models import Count, Exists, OuterRef, Prefetch, Q
from django.db.models.functions import Greatest
from django.shortcuts import render


def network_view(request):
    page_title = 'Following'

    people = (
        Persona.objects
        .exclude(Q(account=request.user))
        .annotate(
            mutual_total=Greatest(
                Count(
                    'follows',
                    filter=Q(follows__backers__account=request.user)
                ) - 2,
                0,
            )
        )
        .prefetch_related(
            Prefetch(
                'follows',
                Persona.objects
                .annotate(
                    is_peer=Exists(
                        Persona.follows.through.objects.filter(
                            from_persona_id=OuterRef('pk'),
                            to_persona__account=request.user,
                        )
                    )
                )
                .filter(is_peer=True),
                to_attr='mutual_peers',
            )
        )
    )

    return render(
        request,
        'my_template.html',
        {
            'people': people,
        },
    )

Рендеринг в шаблоне идентичен предыдущему варианту, поскольку структура контекста не меняется.

Почему это важно

Подсчеты и фильтрация в шаблоне смешивают уровни ответственности и бьют по производительности. Каждая проверка in в цикле либо обращается к базе, либо гоняет по памяти лишние данные. Перенеся отбор и агрегации в queryset, вы задействуете оптимизации на стороне БД, снизите потребление памяти и сохраните шаблоны чистыми и предсказуемыми. Заодно это избавляет от путаницы вроде попыток вызвать count у отдельного объекта вместо коллекции.

Итоги

Держите отбор и агрегации во вьюхах или queryset, а не в шаблонах. Prefetch делайте только для тех взаимных подписчиков, которых собираетесь показывать, а нужное число добавляйте через annotate. Для имен функций используйте snake_case. Если вы на старой версии Django, например 2.2, предпочитайте схему annotate, затем filter внутри Prefetch; по возможности обновляйтесь — в новых релизах ORM заметно сильнее.

Статья основана на вопросе на StackOverflow от пользователя user30880337 и ответе willeM_ Van Onsem.