Создание системы Like / Dislike
Задача
Под каждой статьёй — кнопки 👍 и 👎 и общий рейтинг. Голосовать могут и гости, но один раз: для гостей запоминаем 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
Объявите модель Rating с полями: article — ForeignKey на Article (CASCADE, related_name="ratings"),
user — ForeignKey на User (CASCADE, blank=True, null=True), value — IntegerField с
choices=[(1, "Нравится"), (-1, "Не нравится")], ip_address — GenericIPAddressField().
class Rating(models.Model):
article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name="ratings")
user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
value = models.IntegerField(choices=[(1, "Нравится"), (-1, "Не нравится")])
ip_address = models.GenericIPAddressField()
Напишите функцию get_client_ip(request): если в request.META есть заголовок HTTP_X_FORWARDED_FOR,
верните первый адрес из списка (x_forwarded_for.split(",")[0]), иначе — request.META.get("REMOTE_ADDR").
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")
Добавьте в модель Article метод get_sum_rating, возвращающий сумму голосов:
sum(rating.value for rating in self.ratings.all()).
class Article(models.Model):
def get_sum_rating(self):
return sum(rating.value for rating in self.ratings.all())
Пользователь, уже поставивший «лайк», нажимает «лайк» ещё раз. Какое поведение обычно ожидается?
Как посчитать рейтинг всех статей списка без запроса на каждую статью?
Войдите, чтобы отмечать пройденные уроки.