2025, Nov 05 03:01

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

Почему annotate и prefetch_related не дают аватарки взаимных: надёжный способ в Django — считать пересечение во view, сделать срез и рендерить в шаблоне.

Показ превью взаимных подписчиков под каждым постом кажется простой задачей, пока не вмешиваются подсчёты, получение объектов и срезы в шаблоне. Подсчитать количество взаимных легко через annotate, но стабильный вывод самих объектов (и лишь нескольких аватаров на пост) быстро выявляет пределы логики в шаблоне и неверный слой для prefetch. Давайте пройдёмся по надёжному подходу, где всё делает представление (view): он устраняет несогласованность, возникающую из‑за slice в шаблонах, и избавляет от лишних запросов.

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

Для каждого поста в ленте нужно показывать взаимных подписчиков автора и текущего пользователя. Сам счётчик работает, но показ соответствующих аватарок становится ненадёжным, если резать выборки прямо в шаблоне; попытки с prefetch_related не подцепляли нужные объекты.

class Profile(models.Model):
    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True)
    profile_pic = models.ImageField(upload_to='UploadedProfilePicture/', default="ProfileAvatar/avatar.png", blank=True)
    following = models.ManyToManyField(
        'Profile',
        symmetrical=False,
        related_name='followers',
        blank=True,
    )

class Post(models.Model):
    poster_profile = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True)

# Вариант представления (view), который переносит логику в шаблон

def stream_view(request):
    feed_items = Post.objects.filter(
        Q(poster_profile__profile__followers__user=request.user)
    ).order_by("?").distinct()

    my_following_only = request.user.profile.following.filter(
        id__in=request.user.profile.following.values_list('id', flat=True)
    )

    return render(request, "app/newsfeed.html", {"feed_items": feed_items, "my_following_only": my_following_only})

# Фрагмент шаблона с нарезкой (slice) внутри проверки вхождения
# При использовании slice:"3" это работало непоследовательно для разных пользователей.

{% for entry in feed_items %}
  {% for candidate in my_following_only %}
    {% if candidate in entry.poster_profile.profile.following.all|slice:"3" %}
      <img src="{{ candidate.profile_pic.url }}" class="hover-followers-img" />
    {% endif %}
  {% endfor %}
{% endfor %}

Почему это работает неправильно

Здесь смешались две разные идеи. Во‑первых, annotate отлично подходит для агрегатных чисел, таких как количество взаимных, но он не приносит сами связанные объекты для отображения. Во‑вторых, prefetch_related с Prefetch хорошо работает с ManyToMany и обратными ForeignKey. Он не поможет, если вы пытаетесь «префетчить» через прямой ForeignKey вроде poster_profile. Наконец, проверки принадлежности и срезы в шаблоне провоцируют дополнительные запросы и дают нестабильный результат: срез применяется к выборке, оцениваемой заново на каждой итерации, а не к единожды подготовленному списку.

Вот попытка, сосредоточенная на подсчёте, которая не предоставляет сами объекты взаимных подписчиков для рендера:

class Profile(models.Model):
    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True)
    profile_pic = models.ImageField(upload_to='UploadedProfilePicture/', default="ProfileAvatar/avatar.png", blank=True)
    following = models.ManyToManyField(
        'Profile',
        symmetrical=False,
        related_name='followers',
        blank=True,
    )

class Post(models.Model):
    poster_profile = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True)

# Представление с annotate и Prefetch, которое не прикрепляет нужные объекты

def stream_view(request):
    feed_items = (
        Post.objects.filter(Q(poster_profile__profile__followers__user=request.user)).order_by("?")
        .annotate(
            mutual_total=Count(
                'poster_profile',
                filter=Q(poster_profile__profile__following__followers__user=request.user)
            )
        )
        .prefetch_related(
            Prefetch(
                'poster_profile',
                Post.objects.annotate(
                    is_mutual=Exists(
                        Post.objects.filter(
                            poster_profile=OuterRef('pk'),
                            poster_profile__profile__followers__user=request.user,
                        )
                    )
                ).filter(is_mutual=True),
                to_attr='mutual_followers',
            )
        )
    )

    return render(request, "app/newsfeed.html", {"feed_items": feed_items})

# Шаблон

{% for entry in feed_items %}
  {{ entry.mutual_total }}
{% endfor %}

# Попытка отрисовать изображения взаимных подписчиков не сработает, потому что они не были получены
{% for entry in feed_items %}
  {% for avatar in entry.mutual_followers %}
    <img src="{{ avatar.profile_pic.url }}" class="hover-followers-img" />
  {% endfor %}
{% endfor %}

Решение: вычисляем взаимных во вью, один раз режем, в шаблоне — только рендер

Надёжный вариант — собирать для каждого поста пересечение «кого я читаю» и «кого читает автор поста» во вью. Там же сделать срез нужного размера и передать в шаблон и этот срез, и полное количество. Так шаблон остаётся чистым, запросы не разрастаются в N+1, а несогласованность срезов на каждой итерации исчезает.

def feed_view(request):
    viewer_profile = request.user.profile

    posts_qs = (
        Post.objects
        .filter(poster_profile__profile__followers__user=request.user)
        .select_related('poster_profile', 'poster_profile__profile')
        .distinct()
    )

    feed_bundle = []
    for post in posts_qs:
        author_profile = post.poster_profile.profile
        overlap_qs = viewer_profile.following.filter(
            id__in=author_profile.following.values_list('id', flat=True)
        )
        feed_bundle.append({
            'post_obj': post,
            'mutual_objs': overlap_qs[:3],
            'mutual_size': overlap_qs.count(),
        })

    return render(request, "your_app_here/newsfeed.html", {"feed_bundle": feed_bundle})

А соответствующий шаблон превращается в чистый слой представления — без тяжёлых фильтров и проверок принадлежности:

{% for row in feed_bundle %}
  <div>
    <p>Post by {{ row.post_obj.poster_profile.username }}</p>
    <p>{{ row.mutual_size }} mutual followers</p>
    <div>
      {% for mf in row.mutual_objs %}
        <img src="{{ mf.profile_pic.url }}" alt="mutual follower" class="hover-followers-img" />
      {% empty %}
        <span>No mutual followers yet</span>
      {% endfor %}
    </div>
  </div>
{% endfor %}

Варианты нарезки (опционально)

Если хотите пропустить первых двух взаимных и показать остальных, сделайте это во вью в месте, где применяете срез. То же самое с количеством, соответствующим срезанной подвыборке.

# показывать элементы, начиная с индекса 2
'mutual_objs': overlap_qs[2:],

# количество элементов в срезе
'addition_count': overlap_qs[2:].count(),

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

Перенос логики пересечения и нарезки из шаблонов даёт одинаково стабильные результаты для всех пользователей, делает число запросов предсказуемым и убирает повторную оценку queryset на каждой итерации. annotate по‑прежнему полезен для чисел, но когда нужны реальные объекты на пост, вычисляйте их во вью и передавайте как готовые структуры для рендера. prefetch_related силён для ManyToMany и деревьев обратных ForeignKey, но он не прикрепит произвольно отфильтрованные объекты к прямому ForeignKey, такому как poster_profile.

Выводы

Оставляйте шаблоны для представления, а не для преобразования данных. Формируйте во вью перпостовые queryset’ы взаимных подписчиков, делайте один срез и передавайте и нужные аватары, и соответствующие счётчики за один раз. Так вы избежите несогласованности, наблюдаемой со slice в шаблоне, и гарантируете, что лента показывает корректных взаимных подписчиков для каждого пользователя.

Статья основана на вопросе на StackOverflow от user30880337 и ответе Viktor Sbruev.