Django База [2023]: Система древовидных комментариев - модель, форма, представление (часть 1) 💬 #26
В этой статье мы рассмотрим создание древовидных комментариев в Django 4.1 с использованием библиотеки MPTT. Статья будет разделена на две части, в одной я покажу модель, форму, представление, а в другой JavaScript код для добавления комментариев без обновления страницы.
Если вы хотите выразить благодарность автору сайта, статей и курса по Django, вы можете сделать это по ссылке ниже:
Вторая часть о системе иерархических комментариев в ней рассмотрены шаблоны и добавлен JavaScript код для динамического добавления комментариев на странице без перезагрузки страницы в браузере.
В уроке ранее мы уже установили библиотеку MPTT для древовидных категорий, но если вы зашли сюда не по урокам, то устанавливаем с помощью команды в терминале: pip install django-mptt
, и в файле settings.py добавляем в установленные приложения:
INSTALLED_APPS = (
'django.contrib.auth',
#Другие приложения
'mptt',
)
Создание модели Comment (древовидных комментариев)
В файле models.py нашего приложения blog добавляем следующий фрагмент кода в самый низ:
from django.db import models
from django.contrib.auth import get_user_model
from mptt.models import MPTTModel, TreeForeignKey
User = get_user_model()
class Comment(MPTTModel):
"""
Модель древовидных комментариев
"""
STATUS_OPTIONS = (
('published', 'Опубликовано'),
('draft', 'Черновик')
)
article = models.ForeignKey(Article, on_delete=models.CASCADE, verbose_name='Статья', related_name='comments')
author = models.ForeignKey(User, verbose_name='Автор комментария', on_delete=models.CASCADE, related_name='comments_author')
content = models.TextField(verbose_name='Текст комментария', max_length=3000)
time_create = models.DateTimeField(verbose_name='Время добавления', auto_now_add=True)
time_update = models.DateTimeField(verbose_name='Время обновления', auto_now=True)
status = models.CharField(choices=STATUS_OPTIONS, default='published', verbose_name='Статус поста', max_length=10)
parent = TreeForeignKey('self', verbose_name='Родительский комментарий', null=True, blank=True, related_name='children', on_delete=models.CASCADE)
class MTTMeta:
order_insertion_by = ('-time_create',)
class Meta:
db_table = 'app_comments'
indexes = [models.Index(fields=['-time_create', 'time_update', 'status', 'parent'])]
ordering = ['-time_create']
verbose_name = 'Комментарий'
verbose_name_plural = 'Комментарии'
def __str__(self):
return f'{self.author}:{self.content}'
В примере выше:
- c помощью библиотеки MPTT создаем древовидную систему комментариев.
- ссылаемся на Article, так как комментарий закрепляется за статьей.
- cсылаемся на User (автора комментария).
- добавляем индексы для сортировки, получения оптимизированных результатов.
order_insertion_by
- сортировка по вложенности
Не забываем про приминение миграций!
С помощью команд: py manage.py makemigrations
и py manage.py migrate
проводим миграции для созданной модели комментариев.
Результат миграций:
(venv) PS C:\Users\Razilator\Desktop\Base\backend> python manage.py makemigrations
Migrations for 'blog':
modules\blog\migrations\0003_alter_article_time_create_comment_and_more.py
- Alter field time_create on article
- Create model Comment
- Create index app_comment_time_cr_0c0ec5_idx on field(s) -time_create, time_update, status, parent of model comment
(venv) PS C:\Users\Razilator\Desktop\Base\backend> python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, blog, contenttypes, sessions, sites, system
Running migrations:
Applying blog.0003_alter_article_time_create_comment_and_more... OK
Отлично. Миграции проведены.
Полный код models.py на основе предыдущих уроков:
from django.db import models
from django.core.validators import FileExtensionValidator
from django.contrib.auth import get_user_model
from mptt.models import MPTTModel, TreeForeignKey
from django.urls import reverse
from modules.services.utils import unique_slugify
User = get_user_model()
class Category(MPTTModel):
"""
Модель категорий с вложенностью
"""
title = models.CharField(max_length=255, verbose_name='Название категории')
slug = models.SlugField(max_length=255, verbose_name='URL категории', blank=True)
description = models.TextField(verbose_name='Описание категории', max_length=300)
parent = TreeForeignKey(
'self',
on_delete=models.CASCADE,
null=True,
blank=True,
db_index=True,
related_name='children',
verbose_name='Родительская категория'
)
class MPTTMeta:
"""
Сортировка по вложенности
"""
order_insertion_by = ('title',)
class Meta:
"""
Сортировка, название модели в админ панели, таблица в данными
"""
verbose_name = 'Категория'
verbose_name_plural = 'Категории'
db_table = 'app_categories'
def __str__(self):
"""
Возвращение заголовка статьи
"""
return self.title
def get_absolute_url(self):
return reverse('articles_by_category', kwargs={'slug': self.slug})
class Article(models.Model):
"""
Модель постов для сайта
"""
class ArticleManager(models.Manager):
"""
Кастомный менеджер для модели статей
"""
def all(self):
"""
Список статей (SQL запрос с фильтрацией для страницы списка статей)
"""
return self.get_queryset().select_related('author', 'category').filter(status='published')
STATUS_OPTIONS = (
('published', 'Опубликовано'),
('draft', 'Черновик')
)
title = models.CharField(verbose_name='Заголовок', max_length=255)
slug = models.SlugField(verbose_name='Альт.название', max_length=255, blank=True, unique=True)
short_description = models.TextField(verbose_name='Краткое описание', max_length=500)
full_description = models.TextField(verbose_name='Полное описание')
thumbnail = models.ImageField(
verbose_name='Превью поста',
blank=True,
upload_to='images/thumbnails/%Y/%m/%d/',
validators=[FileExtensionValidator(allowed_extensions=('png', 'jpg', 'webp', 'jpeg', 'gif'))]
)
status = models.CharField(choices=STATUS_OPTIONS, default='published', verbose_name='Статус поста', max_length=10)
time_create = models.DateTimeField(auto_now_add=True, verbose_name='Время добавления')
time_update = models.DateTimeField(auto_now=True, verbose_name='Время обновления')
author = models.ForeignKey(to=User, verbose_name='Автор', on_delete=models.SET_DEFAULT, related_name='author_posts',
default=1)
updater = models.ForeignKey(to=User, verbose_name='Обновил', on_delete=models.SET_NULL, null=True,
related_name='updater_posts', blank=True)
fixed = models.BooleanField(verbose_name='Зафиксировано', default=False)
category = TreeForeignKey('Category', on_delete=models.PROTECT, related_name='articles', verbose_name='Категория')
objects = ArticleManager()
class Meta:
db_table = 'app_articles'
ordering = ['-time_create', '-fixed']
indexes = [models.Index(fields=['-time_create', '-fixed'])]
verbose_name = 'Статья'
verbose_name_plural = 'Статьи'
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse('articles_detail', kwargs={'slug': self.slug})
def save(self, *args, **kwargs):
"""
Сохранение полей модели при их отсутствии заполнения
"""
if not self.slug:
self.slug = unique_slugify(self, self.title)
super().save(*args, **kwargs)
class Comment(MPTTModel):
"""
Модель древовидных комментариев
"""
STATUS_OPTIONS = (
('published', 'Опубликовано'),
('draft', 'Черновик')
)
article = models.ForeignKey(Article, on_delete=models.CASCADE, verbose_name='Статья', related_name='comments')
author = models.ForeignKey(User, verbose_name='Автор комментария', on_delete=models.CASCADE,
related_name='comments_author')
content = models.TextField(verbose_name='Текст комментария', max_length=3000)
time_create = models.DateTimeField(verbose_name='Время добавления', auto_now_add=True)
time_update = models.DateTimeField(verbose_name='Время обновления', auto_now=True)
status = models.CharField(choices=STATUS_OPTIONS, default='published', verbose_name='Статус поста', max_length=10)
parent = TreeForeignKey('self', verbose_name='Родительский комментарий', null=True, blank=True,
related_name='children', on_delete=models.CASCADE)
class MTTMeta:
order_insertion_by = ('-time_create',)
class Meta:
db_table = 'app_comments'
indexes = [models.Index(fields=['-time_create', 'time_update', 'status', 'parent'])]
ordering = ['-time_create']
verbose_name = 'Комментарий'
verbose_name_plural = 'Комментарии'
def __str__(self):
return f'{self.author}:{self.content}'
Создание формы для создания комментариев
В файле forms.py нашего приложения blog нам необходимо создать форму. В самй низ добавляем следующий фрагмент кода:
from django import forms
from .models import Comment
class CommentCreateForm(forms.ModelForm):
"""
Форма добавления комментариев к статьям
"""
parent = forms.IntegerField(widget=forms.HiddenInput, required=False)
content = forms.CharField(label='', widget=forms.Textarea(attrs={'cols': 30, 'rows': 5, 'placeholder': 'Комментарий', 'class': 'form-control'}))
class Meta:
model = Comment
fields = ('content',)
В примере выше мы создаем форму на основе нашей модели Comment наследуясь от ModelForm.
Добавляем скрытое поле для ID родителя. Его будем заполнять с помощью JS.
Создание представления для добавления комментария через JS
Теперь создадим представление для добавления комментариев с помощью JavaScript, для этого в файл views.py нашего приложения blog добавьте следующий код в самый низ:
from django.views.generic import CreateView
from django.contrib.auth.mixins import LoginRequiredMixin
from .forms import CommentCreateForm
from .models import Comment
class CommentCreateView(LoginRequiredMixin, CreateView):
model = Comment
form_class = CommentCreateForm
def is_ajax(self):
return self.request.headers.get('X-Requested-With') == 'XMLHttpRequest'
def form_invalid(self, form):
if self.is_ajax():
return JsonResponse({'error': form.errors}, status=400)
return super().form_invalid(form)
def form_valid(self, form):
comment = form.save(commit=False)
comment.article_id = self.kwargs.get('pk')
comment.author = self.request.user
comment.parent_id = form.cleaned_data.get('parent')
comment.save()
if self.is_ajax():
return JsonResponse({
'is_child': comment.is_child_node(),
'id': comment.id,
'author': comment.author.username,
'parent_id': comment.parent_id,
'time_create': comment.time_create.strftime('%Y-%b-%d %H:%M:%S'),
'avatar': comment.author.profile.avatar.url,
'content': comment.content,
'get_absolute_url': comment.author.profile.get_absolute_url()
}, status=200)
return redirect(comment.article.get_absolute_url())
def handle_no_permission(self):
return JsonResponse({'error': 'Необходимо авторизоваться для добавления комментариев'}, status=400)
Подробнее о примере созданного представления выше:
Это представление на основе класса CreateView, которое позволяет обрабатывать форму создания комментария в AJAX. Мы используем миксин LoginRequiredMixin, что гарантирует, что только авторизованный пользователь может создать комментарий.
Метод is_ajax()
возвращает True, если запрос был сделан через AJAX, и False в противном случае.
Метод form_invalid()
вызывается, когда форма создания комментария не проходит валидацию. Если запрос был через AJAX, то возвращается JsonResponse с ошибкой, иначе вызывается родительский метод form_invalid()
.
Метод form_valid()
вызывается, когда форма создания комментария прошла валидацию. В нем сохраняется новый комментарий, и возвращается успешный ответ с атрибутами в виде json с помощью JsonResponse, в случае, если это был не AJAX запрос, редеректим пользователя на статью.
Далее нам необходимо модернизировать контекст детальной статьи:
from django.views.generic import DetailView
from .forms import CommentCreateForm
from .models import Article
class ArticleDetailView(DetailView):
model = Article
template_name = 'blog/articles_detail.html'
context_object_name = 'article'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = self.object.title
context['form'] = CommentCreateForm
return context
Ну и конечно, полный вариант нашего views.py на основе всех предыдущих уроков:
from django.http import JsonResponse
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.shortcuts import render, redirect
from django.core.paginator import Paginator
from .models import Article, Category, Comment
from .forms import ArticleCreateForm, ArticleUpdateForm, CommentCreateForm
from ..services.mixins import AuthorRequiredMixin
class ArticleListView(ListView):
model = Article
template_name = 'blog/articles_list.html'
context_object_name = 'articles'
paginate_by = 10
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = 'Главная страница'
return context
class ArticleDetailView(DetailView):
model = Article
template_name = 'blog/articles_detail.html'
context_object_name = 'article'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = self.object.title
context['form'] = CommentCreateForm
return context
class ArticleByCategoryListView(ListView):
model = Article
template_name = 'blog/articles_list.html'
context_object_name = 'articles'
category = None
def get_queryset(self):
self.category = Category.objects.get(slug=self.kwargs['slug'])
queryset = Article.objects.all().filter(category__slug=self.category.slug)
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = f'Статьи из категории: {self.category.title}'
return context
class ArticleCreateView(LoginRequiredMixin, CreateView):
"""
Представление: создание материалов на сайте
"""
model = Article
template_name = 'blog/articles_create.html'
form_class = ArticleCreateForm
login_url = 'home'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = 'Добавление статьи на сайт'
return context
def form_valid(self, form):
form.instance.author = self.request.user
form.save()
return super().form_valid(form)
class ArticleUpdateView(AuthorRequiredMixin, SuccessMessageMixin, UpdateView):
"""
Представление: обновления материала на сайте
"""
model = Article
template_name = 'blog/articles_update.html'
context_object_name = 'article'
form_class = ArticleUpdateForm
login_url = 'home'
success_message = 'Материал был успешно обновлен'
def get_context_data(self, *, object_list=None, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = f'Обновление статьи: {self.object.title}'
return context
def form_valid(self, form):
# form.instance.updater = self.request.user
form.save()
return super().form_valid(form)
class ArticleDeleteView(AuthorRequiredMixin, DeleteView):
"""
Представление: удаления материала
"""
model = Article
success_url = reverse_lazy('home')
context_object_name = 'article'
template_name = 'blog/articles_delete.html'
def get_context_data(self, *, object_list=None, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = f'Удаление статьи: {self.object.title}'
return context
def articles_list(request):
articles = Article.objects.all()
paginator = Paginator(articles, per_page=2)
page_number = request.GET.get('page')
page_object = paginator.get_page(page_number)
context = {'page_obj': page_object}
return render(request, 'blog/articles_func_list.html', context)
class CommentCreateView(LoginRequiredMixin, CreateView):
model = Comment
form_class = CommentCreateForm
def is_ajax(self):
return self.request.headers.get('X-Requested-With') == 'XMLHttpRequest'
def form_invalid(self, form):
if self.is_ajax():
return JsonResponse({'error': form.errors}, status=400)
return super().form_invalid(form)
def form_valid(self, form):
comment = form.save(commit=False)
comment.article_id = self.kwargs.get('pk')
comment.author = self.request.user
comment.parent_id = form.cleaned_data.get('parent')
comment.save()
if self.is_ajax():
return JsonResponse({
'is_child': comment.is_child_node(),
'id': comment.id,
'author': comment.author.username,
'parent_id': comment.parent_id,
'time_create': comment.time_create.strftime('%Y-%b-%d %H:%M:%S'),
'avatar': comment.author.profile.avatar.url,
'content': comment.content,
'get_absolute_url': comment.author.profile.get_absolute_url()
}, status=200)
return redirect(comment.article.get_absolute_url())
def handle_no_permission(self):
return JsonResponse({'error': 'Необходимо авторизоваться для добавления комментариев'}, status=400)
Отлично. Теперь осталось добавить комментарии в админ-панель и добавить обработку представления в urls.py.
Обработка представления в urls.py
В файле urls.py нашего приложения blog добавим следующую строку:
from django.urls import path
from .views import ArticleListView, ArticleDetailView, ArticleByCategoryListView, articles_list, ArticleCreateView, \
ArticleUpdateView, ArticleDeleteView, CommentCreateView
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('category/<str:slug>/', ArticleByCategoryListView.as_view(), name="articles_by_category"),
]
Добавление модели комментариев в админ-панель
Для этого в файле admin.py нашего приложения blog добавляем следующий код:
from django.contrib import admin
from mptt.admin import DraggableMPTTAdmin
from .models import Comment
@admin.register(Comment)
class CommentAdminPage(DraggableMPTTAdmin):
"""
Админ-панель модели комментариев
"""
list_display = ('tree_actions', 'indented_title', 'article', 'author', 'time_create', 'status')
mptt_level_indent = 2
list_display_links = ('article',)
list_filter = ('time_create', 'time_update', 'author')
list_editable = ('status',)
Напоминаю, что наш файл admin.py на основе всех предыдущих уроков выглядит следующим образом:
from django.contrib import admin
from mptt.admin import DraggableMPTTAdmin
from .models import Category, Article, Comment
@admin.register(Category)
class CategoryAdmin(DraggableMPTTAdmin):
"""
Админ-панель модели категорий
"""
list_display = ('tree_actions', 'indented_title', 'id', 'title', 'slug')
list_display_links = ('title', 'slug')
prepopulated_fields = {'slug': ('title',)}
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('title',)}
@admin.register(Comment)
class CommentAdminPage(DraggableMPTTAdmin):
"""
Админ-панель модели комментариев
"""
list_display = ('tree_actions', 'indented_title', 'article', 'author', 'time_create', 'status')
mptt_level_indent = 2
list_display_links = ('article',)
list_filter = ('time_create', 'time_update', 'author')
list_editable = ('status',)
Результат в админ-панеле в виде вложенных комментариев:
Надеюсь у вас все получилось, в следующей части мы добавим все это в шаблон и добавим JavaScript код для добавления комментариев без перезагрузки страницы.