Создание системы Like / Dislike

⏱ 30 мин · 🧩 заданий: 5

Задача

Под каждой статьёй — кнопки 👍 и 👎 и общий рейтинг. Голосовать могут и гости, но один раз: для гостей запоминаем IP-адрес, для вошедших — пользователя. Повторное нажатие той же кнопки отменяет голос, нажатие другой — меняет его.

Модель

class Rating(models.Model):
    article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name="ratings", verbose_name="Статья")
    user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True, verbose_name="Пользователь")
    value = models.IntegerField(choices=[(1, "Нравится"), (-1, "Не нравится")], verbose_name="Значение")
    time_create = models.DateTimeField(auto_now_add=True, verbose_name="Время добавления")
    ip_address = models.GenericIPAddressField(verbose_name="IP адрес")

    class Meta:
        constraints = [models.UniqueConstraint(fields=["article", "ip_address"], name="unique_article_ip")]
        ordering = ("-time_create",)
        indexes = [models.Index(fields=["-time_create", "value"])]
        verbose_name = "Рейтинг"
        verbose_name_plural = "Рейтинги"

    def __str__(self):
        return self.article.title

UniqueConstraint на уровне базы не даст проголосовать дважды с одного IP, даже если в коде ошибка.

Метод в Article:

def get_sum_rating(self):
    return sum(rating.value for rating in self.ratings.all())

С prefetch_related("ratings") это работает без лишних запросов. Для списков ещё лучше считать в базе:

from django.db.models import Sum
from django.db.models.functions import Coalesce

Article.custom.annotate(total_rating=Coalesce(Sum("ratings__value"), 0))

Coalesce превращает NULL (нет голосов) в 0.

IP-адрес клиента

modules/services/utils.py:

def get_client_ip(request):
    x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
    if x_forwarded_for:
        return x_forwarded_for.split(",")[0].strip()
    return request.META.get("REMOTE_ADDR")

За Nginx REMOTE_ADDR будет 127.0.0.1 — настоящий адрес Nginx передаёт в X-Forwarded-For (мы настраивали proxy_set_header в уроке про деплой).

IP — не идентичность

Заголовок X-Forwarded-For клиент может подделать, если перед приложением нет прокси, а у нескольких людей в одной сети один IP. Для серьёзного голосования требуйте вход, а IP используйте только для гостей.

Представление

from django.http import JsonResponse
from django.views import View

from modules.services.utils import get_client_ip


class RatingCreateView(View):
    model = Rating

    def post(self, request, *args, **kwargs):
        article_id = request.POST.get("article_id")
        value = int(request.POST.get("value"))
        ip_address = get_client_ip(request)
        user = request.user if request.user.is_authenticated else None

        rating, created = self.model.objects.get_or_create(
            article_id=article_id,
            ip_address=ip_address,
            defaults={"value": value, "user": user},
        )

        if not created:
            if rating.value == value:
                rating.delete()
                return JsonResponse({"status": "deleted", "rating_sum": rating.article.get_sum_rating()})
            rating.value = value
            rating.user = user
            rating.save()
            return JsonResponse({"status": "updated", "rating_sum": rating.article.get_sum_rating()})
        return JsonResponse({"status": "created", "rating_sum": rating.article.get_sum_rating()})
  • get_or_create ищет голос с этого IP за эту статью; если нет — создаёт с defaults;
  • тот же голос → удаляем; другой → меняем.
path("rating/", RatingCreateView.as_view(), name="rating"),

Шаблон

<div class="rating-buttons">
  <button class="btn btn-sm btn-primary rating-btn" data-article="{{ article.id }}" data-value="1">👍</button>
  <button class="btn btn-sm btn-secondary rating-btn" data-article="{{ article.id }}" data-value="-1">👎</button>
  <span class="rating-sum" id="rating-{{ article.id }}">{{ article.get_sum_rating }}</span>
</div>

JavaScript

templates/src/custom/js/ratings.js:

const ratingButtons = document.querySelectorAll(".rating-btn");

ratingButtons.forEach((button) => {
    button.addEventListener("click", async (event) => {
        const value = event.currentTarget.dataset.value;
        const articleId = event.currentTarget.dataset.article;
        const formData = new FormData();
        formData.append("article_id", articleId);
        formData.append("value", value);

        const response = await fetch("/rating/", {
            method: "POST",
            headers: {
                "X-CSRFToken": csrftoken,
                "X-Requested-With": "XMLHttpRequest",
            },
            body: formData,
        });
        const data = await response.json();
        document.querySelector(`#rating-${articleId}`).textContent = data.rating_sum;
    });
});

Кнопки работают и в списке статей, и на детальной странице — каждая знает свою статью по data-article.

Админка

@admin.register(Rating)
class RatingAdmin(admin.ModelAdmin):
    list_display = ("article", "user", "value", "ip_address", "time_create")
    list_filter = ("value",)

Итог

  • Rating хранит голос +1/−1, пользователя (или NULL) и IP; уникальность — UniqueConstraint.
  • IP клиента — из X-Forwarded-For за прокси, иначе REMOTE_ADDR.
  • Логика «поставить / отменить / изменить» — get_or_create в View.post, ответ — JSON.
  • Сумма рейтинга — get_sum_rating() или annotate(Sum(...)) для списков.

Практика решено 0 из 5

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Написать код

Объявите модель Rating с полями: article — ForeignKey на Article (CASCADE, related_name="ratings"), user — ForeignKey на User (CASCADE, blank=True, null=True), value — IntegerField с choices=[(1, "Нравится"), (-1, "Не нравится")], ip_address — GenericIPAddressField().

Задание 2 Написать код

Напишите функцию get_client_ip(request): если в request.META есть заголовок HTTP_X_FORWARDED_FOR, верните первый адрес из списка (x_forwarded_for.split(",")[0]), иначе — request.META.get("REMOTE_ADDR").

Задание 3 Написать код

Добавьте в модель Article метод get_sum_rating, возвращающий сумму голосов: sum(rating.value for rating in self.ratings.all()).

Задание 4 Один вариант ответа

Пользователь, уже поставивший «лайк», нажимает «лайк» ещё раз. Какое поведение обычно ожидается?

Задание 5 Один вариант ответа

Как посчитать рейтинг всех статей списка без запроса на каждую статью?

Войдите, чтобы отмечать пройденные уроки.