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.