Django База [2023]: Система Лайк / Дизлайк с помощью JavaScript ❤️‍🔥 #36
Django

Django База [2023]: Система Лайк / Дизлайк с помощью JavaScript ❤️‍🔥 #36

Razilator

В этой статье мы рассмотрим создание системы Лайк / Дизлайк в Django без перезагрузки страницы с помощью JavaScript.

Создание модели рейтинга

Первым делом мы создадим модель рейтинга, для этого в файле models.py приложения blog добавим код модели:

blog/models.py
from django.db import models


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

    class Meta:
        unique_together = ('article', 'ip_address')
        ordering = ('-time_create',)
        indexes = [models.Index(fields=['-time_create', 'value'])]
        verbose_name = 'Рейтинг'
        verbose_name_plural = 'Рейтинги'
    
    def __str__(self):
        return self.article.title

Этот код определяет модель Rating, которая наследуется от models.Model из Django. Модель содержит следующие поля:

  • article - поле внешнего ключа, которое ссылается на модель Article.
  • user - поле внешнего ключа, которое ссылается на модель User. Оно может быть пустым.
  • value - поле выбора значений, которое может иметь одно из двух значений: 1 (Нравится) или -1 (Не нравится).
  • time_create - поле даты и времени, которое устанавливается автоматически при создании объекта.
  • ip_address - поле IP-адреса.

Модель определяет несколько метаданных для управления ее поведением. unique_together гарантирует уникальность комбинации article и ip_address. ordering указывает порядок сортировки по умолчанию, indexes определяет индекс на поля time_create и value. verbose_name и verbose_name_plural определяют отображаемые имена модели в административном интерфейсе.

str метод определен для модели Rating, чтобы при печати объекта модели выводилось название связанной статьи.

Не забываем провести миграции: py manage.py makemigrations и py manage.py migrate

Подсчет рейтинга у статей/статьи, оптимизация N+1

Теперь нам необходимо написать функцию подсчета суммы рейтинга и добавить рейтинг в prefetch_related в нашем менеджере, для этого у модели Article добавим следующие изменения:

blog/models.py
class Article(models.Model):
    """
    Модель постов для сайта
    """

    class ArticleManager(models.Manager):
        """
        Кастомный менеджер для модели статей
        """

        def all(self):
            """
            Список статей (SQL запрос с фильтрацией для страницы списка статей)
            """
            return self.get_queryset().select_related('author', 'category').prefetch_related('ratings').filter(status='published')

        def detail(self):
            """
            Детальная статья (SQL запрос с фильтрацией для страницы со статьёй)
            """
            return self.get_queryset()\
                .select_related('author', 'category')\
                .prefetch_related('comments', 'comments__author', 'comments__author__profile', 'tags', 'ratings')\
                .filter(status='published')

    title = models.CharField(verbose_name='Заголовок', max_length=255)
    slug = models.SlugField(verbose_name='Альт.название', max_length=255, blank=True, unique=True)
    
    # Другие поля... 
    
    # Другие функции...
    
    def get_sum_rating(self):
        return sum([rating.value for rating in self.ratings.all()])

Отлично. Перейдем к представлению.

Создаем представление для работы с рейтингом

Нам необходимо создать представление, которое будет работать с JS, для этого в нашем файле views.py приложения blog добавим следующее представление:

blog/views.py
from django.http import JsonResponse
from django.views.generic import View

from .models import Rating
from ..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()})
            else:
                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()})

Этот код представляет класс-контроллер, который обрабатывает POST-запросы на создание и изменение модели Рейтинг (Rating). Он использует стандартный метод post() для обработки запроса.

При получении запроса, представление извлекает данные из запроса, включая идентификатор статьи, значение оценки, IP-адрес клиента (получаем с помощью функции, которую мы создали в этом уроке) и аутентифицированного пользователя (если таковой имеется). Затем используется метод get_or_create() для получения экземпляра модели Рейтинг (Rating). Если объект уже существует, он либо удаляется, либо обновляется соответствующим образом, а если объект новый, он сохраняется.

Наконец, метод возвращает JsonResponse, содержащий статус операции ('created', 'updated' или 'deleted') и сумму оценок статьи, полученных с помощью метода get_sum_rating(), определенного в модели Article.

Определим контроллер в urls.py

Добавим обработку представления в urls.py нашего приложения blog:

blog/urls.py
from django.urls import path

from .views import ArticleListView, ArticleDetailView, ArticleByCategoryListView, articles_list, ArticleCreateView, \
    ArticleUpdateView, ArticleDeleteView, CommentCreateView, ArticleByTagListView, ArticleSearchResultView, RatingCreateView

urlpatterns = [
    path('', ArticleListView.as_view(), name='home'),
    path('articles/', articles_list, name='articles_by_page'),
    path('articles/create/', ArticleCreateView.as_view(), name='articles_create'),
    path('articles/<str:slug>/update/', ArticleUpdateView.as_view(), name='articles_update'),
    path('articles/<str:slug>/delete/', ArticleDeleteView.as_view(), name='articles_delete'),
    path('articles/<str:slug>/', ArticleDetailView.as_view(), name='articles_detail'),
    path('articles/<int:pk>/comments/create/', CommentCreateView.as_view(), name='comment_create_view'),
    path('articles/tags/<str:tag>/', ArticleByTagListView.as_view(), name='articles_by_tags'),
    path('category/<str:slug>/', ArticleByCategoryListView.as_view(), name="articles_by_category"),
    path('search/', ArticleSearchResultView.as_view(), name='search'),
    path('rating/', RatingCreateView.as_view(), name='rating'),
]

Создадим JavaScript для обработки рейтинга у статей/статьи

Ранее в уроках по созданию системы комментариев с помощью JavaScipt мы уже создавали подобные файлы и подключали, ознакомьтесь, если забыли, или пришли в статью не из уроков: комментарии без перезагрузки в Django, в нем мы создаем файл для csrftoken.

Но я напомню, если вы ранее не создавали систему комментариев с JS, то вам необходим следующий код для отправки csrftoken и использования его в JS:

templates/src/custom/js/backend.js
const getCookie = (name) => {
  let cookieValue = null;
  if (document.cookie && document.cookie !== "") {
    const cookies = document.cookie.split(";");
    for (let i = 0; i < cookies.length; i++) {
      const cookie = cookies[i].trim();
      // Does this cookie string begin with the name we want?
      if (cookie.substring(0, name.length + 1) === name + "=") {
        cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
        break;
      }
    }
  }
  return cookieValue;
};

const csrftoken = getCookie("csrftoken");

Этот код определяет функцию getCookie, которая используется для получения значения cookie по имени, и объявляет переменную csrftoken, используя функцию getCookie для извлечения значения cookie с именем "csrftoken". Этот код может быть использован в других файлах JavaScript, если они ссылаются на этот код, где csrftoken доступен для использования.

Сам файл backend.js с csrftoken подключаем в самый низ в main.html, перед body:

templates/main.html
<script src="{% static 'custom/js/backend.js' %}"></script>

Вернеёмся к созданию рейтинга. Далее я создам файл ratings.js в папке src/custom/js/ со следующим JS кодом системы рейтинга:

templates/src/custom/js/ratings.js
const ratingButtons = document.querySelectorAll('.rating-buttons');

ratingButtons.forEach(button => {
    button.addEventListener('click', event => {
        // Получаем значение рейтинга из data-атрибута кнопки
        const value = parseInt(event.target.dataset.value)
        const articleId = parseInt(event.target.dataset.article)
        const ratingSum = button.querySelector('.rating-sum');
        // Создаем объект FormData для отправки данных на сервер
        const formData = new FormData();
        // Добавляем id статьи, значение кнопки
        formData.append('article_id', articleId);
        formData.append('value', value);
        // Отправляем AJAX-Запрос на сервер
        fetch("/rating/", {
            method: "POST",
            headers: {
                "X-CSRFToken": csrftoken,
                "X-Requested-With": "XMLHttpRequest",
            },
            body: formData
        }).then(response => response.json())
        .then(data => {
            // Обновляем значение на кнопке
            ratingSum.textContent = data.rating_sum;
        })
        .catch(error => console.error(error));
    });
});

Данный код устанавливает обработчик кликов на кнопки рейтинга, которые имеют класс 'rating-buttons'. Когда пользователь кликает на одну из кнопок, происходит извлечение значения рейтинга из data-атрибута кнопки, а также id статьи, которую пользователь оценил. Затем создается объект FormData, который содержит эту информацию и отправляется AJAX-запрос на сервер с использованием метода fetch(). В заголовке запроса передается CSRF-токен и заголовок X-Requested-With со значением XMLHttpRequest. После получения ответа сервера, значение суммарного рейтинга обновляется на кнопке с помощью метода textContent.

Добавление кнопок и обработка js на странице списка статей

Далее подключаем ratings.js в articles_list.html и добавляем соответствующие кнопки для лайка, дизлайка и суммы рейтинга:

templates/blog/articles_list.html
{% extends 'main.html' %}
{% load static %}

{% block content %}
    {% for article in articles %}
    <div class="card mb-3">
        <div class="row">
            <div class="col-4">
                <img src="{{ article.thumbnail.url }}" class="card-img-top" alt="{{ article.title }}">
            </div>
            <div class="col-8">
                <div class="card-body">
                    <h5 class="card-title"><a href="{{ article.get_absolute_url }}">{{ article.title }}</a></h5>
                    <p class="card-text">{{ article.short_description|safe }}</p>
                    </hr>
                    Категория: <a href="{% url 'articles_by_category' article.category.slug %}">{{ article.category.title }}</a> 
                    / Добавил: {{ article.author.username }}
                  </div>
                </div>
                <div class="rating-buttons">
                    <button class="btn btn-sm btn-primary" data-article="{{ article.id }}" data-value="1">Лайк</button>
                    <button class="btn btn-sm btn-secondary" data-article="{{ article.id }}" data-value="-1">Дизлайк</button>
                    <button class="btn btn-sm btn-secondary rating-sum">{{ article.get_sum_rating }}</button>
                </div>         
            </div>     
      </div>
    {% endfor %}
{% endblock %}

{% block script %}
<script src="{% static 'custom/js/ratings.js' %}"></script>
{% endblock%}

Также добавим в шаблон полной статьи новые изменения articles_detail.html:

templates/blog/articles_detail.html
{% extends 'main.html' %}
{% load mptt_tags static %}

{% block content %}
<div class="card mb-3 border-0 shadow-sm">
	<div class="row">
		<div class="col-4">
			<img src="{{ article.thumbnail.url }}" class="card-img-top" alt="{{ article.title }}" />
		</div>
		<div class="col-8">
			<div class="card-body">
				<h5>{{ article.title }}</h5>
				<p class="card-text">{{ article.full_description|safe }}</p>
				Категория: <a href="{% url 'articles_by_category' article.category.slug %}">{{ article.category.title }}</a> / Добавил: {{ article.author.username }} / <small>{{ article.time_create }}</small>
			</div>
		</div>
	</div>
	{% if article.tags.all %}
	<div class="card-footer border-0">
		Теги записи: {% for tag in article.tags.all %} <a href="{% url 'articles_by_tags' tag.slug %}">{{ tag }}</a>, {% endfor %}
	</div>
	{% endif %}
	<div class="rating-buttons">
		<button class="btn btn-sm btn-primary" data-article="{{ article.id }}" data-value="1">Лайк</button>
		<button class="btn btn-sm btn-secondary" data-article="{{ article.id }}" data-value="-1">Дизлайк</button>
		<button class="btn btn-sm btn-secondary rating-sum">{{ article.get_sum_rating }}</button>
	</div>         
</div>
<div class="card border-0">
	<div class="card-body">
		<h5 class="card-title">
			Комментарии
		</h5>
		{% include 'blog/comments/comments_list.html' %}
	</div>
</div>
{% endblock %}

{% block script %}
<script src="{% static 'custom/js/ratings.js' %}"></script>
{% endblock%}

{% block sidebar %}
<div class="card mb-2 border-0">
    <div class="card-body">
        <div class="card-title">
           Похожие статьи
        </div>
        <div class="card-text">
            <ul class="similar-articles">
                {% for sim_article in similar_articles %}
                    <li><a href="{{ sim_article.get_absolute_url }}">{{ sim_article.title }}</a></li>
                {% endfor %}
            </ul>
        </div>
    </div>
</div>
{% endblock %}

Проверка работы кнопок

Нажимаем лайк
Нажимаем лайк
При нажатии на лайк мы получаем 1 у статьи
При нажатии на лайк мы получаем 1 у статьи
Нажав дизлайк мы получим -1
Нажав дизлайк мы получим -1
В полной статье тот-же функционал, тот же код отлично работает
В полной статье тот-же функционал, тот же код отлично работает
Как выглядит рейтинг в админ-панеле
Как выглядит рейтинг в админ-панеле
Также мы избавились от лишних SQL N+1
Также мы избавились от лишних SQL N+1
;