Совместное использование annotate и distinct(field) в Django

У меня есть куча отзывов в моем приложении. Пользователи могут "любить" отзывы.

Я пытаюсь получить наиболее понравившиеся отзывы. Однако в приложении есть несколько популярных пользователей, и все их отзывы имеют наибольшее количество лайков. Я хочу выбрать только один отзыв (в идеале самый понравившийся) для каждого пользователя.

Вот мои объекты,

class Review(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='review_user', db_index=True)
    review_text = models.TextField(max_length=5000)
    rating = models.SmallIntegerField(
        validators=[
            MaxValueValidator(10),
            MinValueValidator(1),
        ],
    )
    date_added = models.DateTimeField(db_index=True)
    review_id = models.AutoField(primary_key=True, db_index=True)

class LikeReview(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='likereview_user', db_index=True)
    review = models.ForeignKey(Review, on_delete=models.CASCADE, related_name='likereview_review', db_index=True)
    date_added = models.DateTimeField()

    class Meta:
        unique_together = [['user', 'review']]

А вот что у меня сейчас есть, чтобы получить больше всего понравившихся отзывов:

reviews = Review.objects.filter().annotate(
    num_likes=Count('likereview_review')
).order_by('-num_likes').distinct()

Как вы видите, отзывы, которые я получу, будут отсортированы по наибольшему количеству "лайков", но возможно, что все отзывы с наибольшим количеством "лайков" принадлежат одному и тому же пользователю. Я хочу добавить distinct('user') сюда, но получаю annotate() + distinct(fields) is not implemented.

Как я могу этого добиться?

Один из способов сделать это заключается в следующем:

  1. Получите список кортежей, представляющих user.id и review.id, упорядоченных по пользователю и количеству лайков по возрастанию
  2. .
  3. Преобразуйте список в dict для удаления дубликатов. Более поздние элементы заменяют более ранние, поэтому важно упорядочивание на шаге 1
  4. .
  5. Создайте список review.ids из значений в дикте
  6. .
  7. Получите набор запросов, используя список review.ids, упорядоченный по количеству лайков ОТСУТСТВИЕ
  8. .
from django.db.models import Count

user_review_list = Review.objects\
    .annotate(num_likes=Count('likereview_review'))\
    .order_by('user', 'num_likes')\
    .values_list('user', 'pk')

user_review_dict = dict(user_review_list)
review_pk_list = list(user_review_dict.values())

reviews = Review.objects\
    .annotate(num_likes=Count('likereview_review'))\
    .filter(pk__in=review_pk_list)\
    .order_by('-num_likes')

Это будет немного плохо читаемо из-за ваших связанных имен. Я бы предложил изменить Review.user.related_name на reviews, это сделает это гораздо более понятным, но я подробно описал это во второй части ответа.

При вашей текущей настройке мне удалось сделать это полностью в БД, используя подзапросы:

from django.db.models import Subquery, OuterRef, Count

# No DB Queries
best_reviews_per_user = Review.objects.all()\
    .annotate(num_likes=Count('likereview_review'))\
    .order_by('-num_likes')\
    .filter(user=OuterRef('id'))

# No DB Queries
review_sq = Subquery(best_reviews_per_user.values('review_id')[:1])

# First DB Query
best_review_ids = User.objects.all()\
    .annotate(best_review_id=review_sq)\
    .values_list('best_review_id', flat=True)

# Second DB Query
best_reviews = Review.objects.all()\
    .annotate(num_likes=Count('likereview_review'))\
    .order_by('-num_likes')\
    .filter(review_id__in=best_review_ids)\
    .exclude(num_likes=0)  # I assume this is the case


# Print it
for review in best_reviews:
    print(review, review.num_likes, review.user)

# Test it
assert len({review.user for review in best_reviews}) == len(best_reviews)
assert sorted([r.num_likes for r in best_reviews], reverse=True) == [r.num_likes for r in best_reviews]
assert all([r.num_likes for r in best_reviews])

Попробуем с этой полностью эквивалентной модельной структурой:

from django.db import models
from django.utils import timezone


class TimestampedModel(models.Model):
    """This makes your life much easier and is pretty DRY"""
    created = models.DateTimeField(default=timezone.now)
    class Meta:
        abstract = True


class Review(TimestampedModel):
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reviews', db_index=True)
    text = models.TextField(max_length=5000)
    rating = models.SmallIntegerField()
    likes = models.ManyToManyField(User, through='ReviewLike')


class ReviewLike(TimestampedModel):
    user = models.ForeignKey(User, on_delete=models.CASCADE, db_index=True)
    review = models.ForeignKey(Review, on_delete=models.CASCADE, db_index=True)

Похожие - это четкие m2m отношения между отзывами и пользователями, с дополнительным столбцом timestamp - это использование модели Through. Документы здесь.

Теперь имхо все гораздо легче читать.

from django.db.models import OuterRef, Count, Subquery


# No DB Queries
best_reviews = Review.objects.all()\
    .annotate(like_count=Count('likes'))\
    .exclude(like_count=0)\
    .order_by('-like_count')\

# No DB Queries
sq = Subquery(best_reviews.filter(user=OuterRef('id')).values('id')[:1])

# First DB Query
user_distinct_best_review_ids = User.objects.all()\
    .annotate(best_review=sq)\
    .values_list('best_review', flat=True)

# Second DB Query
best_reviews = best_reviews.filter(id__in=user_distinct_best_review_ids).all()
Вернуться на верх