Django База [2023]: Система подписчиков (модель, представление, JavaScript) 🤝 #43
Django

Django База [2023]: Система подписчиков (модель, представление, JavaScript) 🤝 #43

Razilator

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

Добавление поля following в модель Profile

Профиль пользователя мы уже создавали в уроках ранее: 16, 17, изучите их для полного понимания, если вы не следуете по урокам.

Добавим поле following типа ManyToManyField в модель Profile. Это поле будет содержать ссылки на другие объекты Profile, на которые подписан пользователь.

modules/system/models.py
from django.db import models
from django.contrib.auth import get_user_model

User = get_user_model()


class Profile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    slug = models.SlugField(verbose_name='URL', max_length=255, blank=True, unique=True)
    following = models.ManyToManyField('self', verbose_name='Подписки', related_name='followers', symmetrical=False, blank=True)
    
    # Другие поля...
    
    # Другие методы...
    
    @property
    def get_avatar(self):
        if self.avatar:
            return self.avatar.url
        return f'https://ui-avatars.com/api/?size=190&background=random&name={self.slug}'

Обратите внимание на аргументы related_name='followers' и symmetrical=False.

  • Аргумент related_name определяет, как имя обратной связи для этого поля будет выглядеть у связанных объектов Profile.
  • Аргумент symmetrical=False указывает на то, что связь является несимметричной.

Не забываем провести миграции:

Терминал
(venv) PS C:\Users\Razilator\Desktop\Base\backend> py manage.py makemigrations
Migrations for 'system':
  modules\system\migrations\0005_profile_following.py
    - Add field following to profile
(venv) PS C:\Users\Razilator\Desktop\Base\backend> py manage.py migrate       
Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions, sites, system, taggit
Running migrations:
  Applying system.0005_profile_following... OK

Создание представления ProfileFollowingCreateView для подписки/отписки от пользователя

Теперь добавим представление ProfileFollowingCreateView, которое будет обрабатывать запросы на подписку/отписку от пользователя. Оно будет наследоваться от класса View и будет работать с объектами модели Profile.

modules/system/views.py
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.http import JsonResponse


@method_decorator(login_required, name='dispatch')
class ProfileFollowingCreateView(View):
    """
    Создание подписки для пользователей
    """
    model = Profile

    def is_ajax(self):
        return self.request.headers.get('X-Requested-With') == 'XMLHttpRequest'

    def post(self, request, slug):
        user = self.model.objects.get(slug=slug)
        profile = request.user.profile
        if profile in user.followers.all():
            user.followers.remove(profile)
            message = f'Подписаться на {user}'
            status = False
        else:
            user.followers.add(profile)
            message = f'Отписаться от {user}'
            status = True
        data = {
            'username': profile.user.username,
            'get_absolute_url': profile.get_absolute_url(),
            'slug': profile.slug,
            'avatar': profile.get_avatar,
            'message': message,
            'status': status,
        }
        return JsonResponse(data, status=200)

Этот код представляет собой представление Django для создания и удаления подписки на профиль пользователя.

  • @method_decorator(login_required, name='dispatch') - это декоратор метода, который требует, чтобы пользователь был авторизован для доступа к данному представлению.
  • ProfileFollowingCreateView - это класс представления Django, который наследуется от View. Он определяет два метода - is_ajax() и post().
  • is_ajax() - это метод, который проверяет, является ли запрос AJAX-запросом. Он возвращает True, если заголовок запроса X-Requested-With имеет значение XMLHttpRequest.
  • post() - это метод, который выполняет действия для создания и удаления подписки на профиль пользователя. Он получает объект request и slug из URL-адреса. Он получает профиль пользователя по slug и проверяет, является ли текущий пользователь уже подписчиком этого профиля. Если текущий пользователь является подписчиком профиля, то он удаляется из списка подписчиков, иначе он добавляется в список подписчиков. Затем он формирует словарь data с данными для ответа и возвращает ответ в формате JSON. Ответ содержит информацию о пользователе, который создает или удаляет подписку, а также сообщение и флаг состояния, которые отображаются в пользовательском интерфейсе для создания и удаления подписки.

Обработка представления ProfileFollowingCreateView в urls.py

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

urls.py
from django.urls import path

from .views import ProfileUpdateView, ProfileDetailView, UserRegisterView, UserLoginView, UserPasswordChangeView, \
    UserForgotPasswordView, UserPasswordResetConfirmView, UserConfirmEmailView, EmailConfirmationSentView, \
    EmailConfirmedView, EmailConfirmationFailedView, UserLogoutView, FeedbackCreateView, ProfileFollowingCreateView
    
urlpatterns = [
    path('user/edit/', ProfileUpdateView.as_view(), name='profile_edit'),
    path('user/<str:slug>/', ProfileDetailView.as_view(), name='profile_detail'),
    path('user/follow/<str:slug>/', ProfileFollowingCreateView.as_view(), name='follow'),
    path('login/', UserLoginView.as_view(), name='login'),
    path('logout/', UserLogoutView.as_view(), name="logout"),
    path('password-change/', UserPasswordChangeView.as_view(), name='password_change'),
    path('password-reset/', UserForgotPasswordView.as_view(), name='password_reset'),
    path('set-new-password/<uidb64>/<token>/', UserPasswordResetConfirmView.as_view(), name='password_reset_confirm'),
    path('register/', UserRegisterView.as_view(), name='register'),
    path('email-confirmation-sent/', EmailConfirmationSentView.as_view(), name='email_confirmation_sent'),
    path('confirm-email/<str:uidb64>/<str:token>/', UserConfirmEmailView.as_view(), name='confirm_email'),
    path('email-confirmed/', EmailConfirmedView.as_view(), name='email_confirmed'),
    path('confirm-email-failed/', EmailConfirmationFailedView.as_view(), name='email_confirmation_failed'),
    path('feedback/', FeedbackCreateView.as_view(), name='feedback'),
]

Добавление в шаблон функций подписки/отписки

Нам необходимо добавить кнопки, а также блоки с подписчиками и подписками. Для этого мы изменим наш файле profile_detail.html:

templates/system/profile_detail.html
{% extends 'main.html' %} 
{% load static %} 
{% block content %}
<div class="card border-0">
	<div class="card-body">
		<div class="row">
			<div class="col-md-3">
				<figure>
					<img src="{{ profile.get_avatar }}" class="img-fluid rounded-0" alt="{{ profile }}" />
				</figure>
			</div>
			<div class="col-md-9">
				<h5 class="card-title">
					{{ profile }}
				</h5>
				<div class="card-text">
					<ul>
						<li>Никнейм: {{ profile.user.username }}</li>
						{% if profile.user.get_full_name %}
						<li>Имя и фамилия: {{ profile.user.get_full_name }}</li>
						{% endif %}
						<li>Заходил: {{ profile.user.last_login }} | {% if profile.is_online %}Онлайн{% else %}Не в сети{% endif %}</li>
						<li>Дата рождения: {{ profile.birth_date }}</li>
						<li>О себе: {{ profile.bio }}</li>
					</ul>
					{% if request.user.is_authenticated and request.user != profile.user %} {% if request.user.profile in profile.followers.all %}
					<button class="btn btn-sm btn-danger btn-follow" data-slug="{{ profile.slug }}">
						Отписаться от {{ profile.user.username }}
					</button>
					{% else %}
					<button class="btn btn-sm btn-primary btn-follow" data-slug="{{ profile.slug }}">
						Подписаться на {{ profile.user.username }}
					</button>
					{% endif %} {% elif request.user == profile.user %}
					<a href="{% url 'profile_edit' %}" class="btn btn-sm btn-primary">Редактировать профиль</a>
					{% endif %}
				</div>
			</div>
		</div>
	</div>
	<div class="card border-0">
		<div class="card-body">
			<div class="row">
				<div class="col-md-6">
					<h6 class="card-title">
						Подписки
					</h6>
					<div class="card-text">
						<div class="row">
							{% for following in profile.following.all %}
							<div class="col-md-2">
								<a href="{{ following.get_absolute_url }}">
									<img src="{{ following.get_avatar }}" class="img-fluid rounded-1" alt="{{ following }}" />
								</a>
							</div>
							{% endfor %}
						</div>
					</div>
				</div>
				<div class="col-md-6">
					<h6 class="card-title">
						Подписчики
					</h6>
					<div class="card-text">
						<div class="row followers-box">
							{% for follower in profile.followers.all %}
							<div class="col-md-2" id="user-slug-{{ follower.slug }}">
								<a href="{{ follower.get_absolute_url }}">
									<img src="{{ follower.get_avatar }}" class="img-fluid rounded-1" alt="{{ follower }}" />
								</a>
							</div>
							{% endfor %}
						</div>
					</div>
				</div>
			</div>
		</div>
	</div>
</div>
{% endblock %}
{% block script %}
<script src="{% static 'custom/js/profile.js' %}"></script>
{% endblock %}

JavaScript код для созданий системы подписок без перезагрузки

Для того, чтобы мы могли подписываться и отписывать без перезагрузки, нам нужно создать обработку представления через JS, для этого в папке src/custom/js создадим файл profile.js, который мы подключили выше в шаблоне в {% block script %}:

templates/src/custom/js/profile.js
const followBtn = document.querySelector('.btn-follow');
const followerBox = document.querySelector('.followers-box');

followBtn.addEventListener('click', event => {
    const userSlug = event.target.dataset.slug
    fetch(`/user/follow/${userSlug}/`, {
        method: 'POST',
        headers: {
            "X-CSRFToken": csrftoken,
            "X-Requested-With": "XMLHttpRequest",
    }}).then(response => response.json())
    .then(data => {
        const isBtnPrimary = followBtn.classList.contains('btn-primary');
        const message = data.message || '';

        if (isBtnPrimary) {
            followBtn.classList.remove('btn-primary');
            followBtn.classList.add('btn-danger');
        } else {
            followBtn.classList.remove('btn-danger');
            followBtn.classList.add('btn-primary');
        }
        if (data.status) {
            followerBox.innerHTML += `
                <div class="col-md-2" id="user-slug-${data.slug}">
                    <a href="${data.get_absolute_url}">
                        <img src="${data.avatar}" class="img-fluid rounded-1" alt="${data.slug}"/>
                    </a>
                </div>
            `;
        } else {
            const currentUserSlug = document.querySelector(`#user-slug-${data.slug}`)
            currentUserSlug && currentUserSlug.remove();
        }
        followBtn.innerHTML = message;
    });
}) 

Данный код отвечает за обработку нажатия на кнопку "Подписаться/Отписаться" на странице пользователя.

Первые две строки находят и сохраняют ссылки на элементы страницы: кнопку "Подписаться/Отписаться" и блок для отображения списка подписчиков.

Далее устанавливается обработчик события click на кнопку "Подписаться/Отписаться", который выполняет следующие действия:

  • Получает значение атрибута data-slug из элемента, по которому произошел клик. Это значение используется в URL-адресе для запроса на сервер идентифицирующем пользователя, на которого нажали.
  • Выполняет AJAX-запрос на сервер, используя fetch(), передавая URL-адрес и настройки запроса. В заголовках запроса передаются два значения: X-CSRFToken (токен CSRF, который обеспечивает защиту от CSRF-атак) и X-Requested-With (добавляется для указания типа запроса, чтобы сервер мог определить, что это AJAX-запрос).
  • Обрабатывает ответ от сервера в виде объекта JSON, который возвращается после выполнения запроса. Если запрос выполнен успешно, то изменяется состояние кнопки "Подписаться/Отписаться" и отображается информация о подписчиках. Если запрос выполнен с ошибкой, то удаляется информация о недействительном подписчике.
  • В итоге, если пользователь нажимает на кнопку "Подписаться/Отписаться", то происходит выполнение AJAX-запроса на сервер, который изменяет состояние кнопки и отображает информацию о подписчиках на странице.

Примечание: если у вас возникла ошибка Uncaught ReferenceError: csrftoken is not defined, то это значит, что Вы не вставили фрагмент из 27 урока:

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");

Работа системы подписчиков в Django

Зайдем на страницу другого пользователя и нажмем кнопку подписаться
Зайдем на страницу другого пользователя и нажмем кнопку подписаться
После нажатия на кнопку мы изменили класс кнопки и ее текст, а в блоке подписчиков появился наш профиль
После нажатия на кнопку мы изменили класс кнопки и ее текст, а в блоке подписчиков появился наш профиль
Нажав отписаться наш профиль удалится из блока, а кнопка вернется в состояние по умолчанию
Нажав отписаться наш профиль удалится из блока, а кнопка вернется в состояние по умолчанию

Создание представления для вывода статей авторов, на которых мы подписались

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

blog/views.py
from django.views.generic import ListView
from django.contrib.auth.mixins import LoginRequiredMixin

from .models import Article


class ArticleBySignedUser(LoginRequiredMixin, ListView):
    """
    Представление, выводящее список статей авторов, на которые подписан текущий пользователь
    """
    model = Article
    template_name = 'blog/articles_list.html'
    context_object_name = 'articles'
    login_url = 'login'
    paginate_by = 10

    def get_queryset(self):
        authors = self.request.user.profile.following.values_list('id', flat=True)
        queryset = self.model.objects.all().filter(author__id__in=authors)
        return queryset

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['title'] = 'Статьи авторов' 
        return context

Класс наследуется от двух других классов: LoginRequiredMixin (этот класс проверяет, что пользователь авторизован, иначе он будет перенаправлен на страницу входа) и ListView (этот класс позволяет выводить список объектов модели на странице).

Если вы создадите такое представление, то оно будет выводить список статей, написанных пользователями, на которых подписан текущий пользователь. Оно будет использовать модель Article и шаблон blog/articles_list.html. По умолчанию, список статей будет разбит на страницы по 10 статей в каждой, а заголовок страницы будет "Статьи пользователей, на которых вы подписаны".

Метод get_queryset() определяет, какие статьи будут отображаться в списке. Он использует self.request.user.profile.following.values_list('id', flat=True) для получения списка ID пользователей, на которых подписан текущий пользователь. Затем он отфильтровывает статьи, чтобы отобразить только те, которые были написаны авторами с этими ID.

Метод get_context_data() используется для передачи дополнительных данных в контекст шаблона. Здесь он добавляет заголовок страницы в контекст.

Добавим обработку представления ArticleBySignedUser в urls.py

В файле urls.py нашего приложения blog добавим импорт представления, а также строку для url представления.

blog/urls.py
from django.urls import path

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

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/signed/', ArticleBySignedUser.as_view(), name='articles_by_signed_user'),
    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'),
]

Проверяем список статей авторов, на которых мы подписались

Все тестовые статьи на сайте я переведу на пользователей, на которых я подписался:

Я подписан на двух пользователей
Я подписан на двух пользователей

Перейду на страницу: http://127.0.0.1:8000/articles/signed/

На странице видно только статьи авторов, на которых я подписался
На странице видно только статьи авторов, на которых я подписался

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

Дополнительно: решение SQL N+1 с системой подписчиков

Наверняка вы заметите, что при большом количестве подписок/подписчиков будет видно, что запросы растут с каждым подписчиком/подпиской, для этого нам нужно добавить метод prefetch_related() в представление к queryset, либо создать менеджер модели.

Отдельные уроки по оптимизации и менеджеру.

modules/system/views.py
class ProfileDetailView(DetailView):
    """
    Представление для просмотра профиля
    """
    model = Profile
    context_object_name = 'profile'
    template_name = 'system/profile_detail.html'
    queryset = model.objects.all().select_related('user').prefetch_related('followers', 'followers__user', 'following', 'following__user')

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['title'] = f'Страница пользователя: {self.object.user.username}'
        return context
;