Evgenii Legotckoi
Evgenii Legotckoi30 сентября 2016 г. 12:42

Django - Урок 011. Добавление комментариев на сайт с Django

Взявшись за реализацию комментариев на сайте под Django, я с удивлением обнаружил, что Django не предоставляет никаких модулей для реализации комментариев. Вернее он предоставлял его раньше, это был модуль django.contrib.comments , но в версии 1.7 его объявили как deprecated и предложили либо пилить самостоятельно, либо воспользоваться чем-нибудь вроде Disqus. Хорошо, он вроде тоже поддерживает подсветку синтаксиса кода, но... в статьях одна подсветка, в комментариях другая - это будет некрасиво.

Поэтому будем внедрять собственный велосипед и ловить свои баги.

Для реализации комментариев необходимо:

  • Добавить новую модель, назовём её Comment;
  • Добавить представление, которое будет обрабатывать добавление комментария;
  • Добавить форму для ввода комментария;
  • Воспользоваться для организации древовидной структуры подходом Materialized Path ;

Модель Comment

Модель комментариев будет содержать следующие поля:

  • path - будет содержать массив целочисленных значений, который будет содержать полный путь к корню. Как было сказано в статье по Materialized Path, это ID всех родительских элементов;
  • article_id - внешний ключ на статью, в которой находится комментарий;
  • author_id - внешний ключ на автора комментарий;
  • content - сам комментарий;
  • pub_date - дата и время публикации комментария;

Помимо этого даны методы get_offset() , который будет определять уровень сдвига комментария по длине пути, и get_col() , который будет определять количество колонок в сетке, которые будет занимать комментарий, а также переопределён метод str , который будет отвечать за отображение части содержимого комментария в админке.

Сдвиг и количество колонок будут организовывать древовидное отображение комментариев на странице, но сдвиг будет не более 6 колонок, поскольку на данный момент сетка разбивается на 12 колонок.

class Comment(models.Model):
    class Meta:
        db_table = "comments"

    path = ArrayField(models.IntegerField())
    article_id = models.ForeignKey(Article)
    author_id = models.ForeignKey(User)
    content = models.TextField('Комментарий')
    pub_date = models.DateTimeField('Дата комментария', default=timezone.now)

    def __str__(self):
        return self.content[0:200]

    def get_offset(self):
        level = len(self.path) - 1
        if level > 5:
            level = 5
        return level

    def get_col(self):
        level = len(self.path) - 1
        if level > 5:
            level = 5
        return 12 - level

Файл urls.py

Комментарий отправляет на сайт с помощью POST запроса по определённому адресу, который необходимо описать в файле urls.py.

from django.conf.urls import url

from . import views

app_name = 'post'
urlpatterns = [
    url(r'^(?P<article_id>[0-9]+)/$', views.EArticleView.as_view(), name='article'),
    url(r'^comment/(?P<article_id>[0-9]+)/$', views.add_comment, name='add_comment'),
]

Как я уже говорил во многих статьях, я работаю в модуле knowledge со статьями и разделами, но на этот раз я вынес статьи в отдельный модуль, чтобы унифицировать URL статей, чтобы они не зависели от разделов и не терялась индексация в том случае, если статья будет перемещена в другой раздел. Поэтому теперь работа идёт с модулем post . Туда же будет отправлен и комментарий. Отправляться комментарий в итоге будет по пути post/comment/12, если пользователь комментирует статьи с ID = 12.

Форма комментария

Форма комментария будет помещена в отдельный файл forms.py.

Обработка формы и сохранение комментария будет происходить в представлении, поэтому метода save здесь нет. Данная форма служит лишь для ввода комментария и отсылки его на сервер.

from django import forms

from .models import Comment


class CommentForm(forms.Form):

    parent_comment = forms.IntegerField(
        widget=forms.HiddenInput,
        required=False
    )

    comment_area = forms.CharField(
        label="",
        widget=forms.Textarea
    )

Поле parent_comment очень важное, поскольку оно будет содержать ID родительского комментария, которое будет автоматически подставляться при ответе на один из комментариев под статьёй. Пользователь его заполнять не будет и оно будет скрытым. К тому же его заполнение не является обязательным, поскольку комментарий может относиться непосредственно к статье.

Ну а отношение к определённому комментарию делается с помощью JavaScript скрипта.

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

Для добавления комментария я ограничился лишь методом, без всякого представления, тем более, что декоратор @login_required проще прикручивается к методу, чем к представлению.

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

from django.views import View
from django.shortcuts import render_to_response, get_object_or_404, redirect
from django.contrib import auth
from django.http import Http404
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from django.core.exceptions import ObjectDoesNotExist
from django.template.context_processors import csrf

from knowledge.models import Article, Comment
from knowledge.forms import CommentForm


class EArticleView(View):
    template_name = 'post/article.html'
    comment_form = CommentForm

    def get(self, request, *args, **kwargs):
        article = get_object_or_404(Article, id=self.kwargs['article_id'])
        context = {}
        context.update(csrf(request))
        user = auth.get_user(request)
        # Помещаем в контекст все комментарии, которые относятся к статье
        # попутно сортируя их по пути, ID автоинкрементируемые, поэтому
        # проблем с иерархией комментариев не должно возникать 
        context['comments'] = article.comment_set.all().order_by('path')
        context['next'] = article.get_absolute_url()
        # Будем добавлять форму только в том случае, если пользователь авторизован
        if user.is_authenticated:
            context['form'] = self.comment_form

        return render_to_response(template_name=self.template_name, context=context)

# Декораторы по которым, только авторизованный пользователь 
# может отправить комментарий и только с помощью POST запроса
@login_required
@require_http_methods(["POST"])
def add_comment(request, article_id):

    form = CommentForm(request.POST)
    article = get_object_or_404(Article, id=article_id)

    if form.is_valid():
        comment = Comment()
        comment.path = []
        comment.article_id = article
        comment.author_id = auth.get_user(request)
        comment.content = form.cleaned_data['comment_area']
        comment.save()

        # Django не позволяет увидеть ID комментария по мы не сохраним его, 
        # хотя PostgreSQL имеет такие средства в своём арсенале, но пока не будем
        # работать с сырыми SQL запросами, поэтому сформируем path после первого сохранения
        # и пересохраним комментарий 
        try:
            comment.path.extend(Comment.objects.get(id=form.cleaned_data['parent_comment']).path)
            comment.path.append(comment.id)
        except ObjectDoesNotExist:
            comment.path.append(comment.id)

        comment.save()

    return redirect(article.get_absolute_url())

Шаблон статьи с комментарием

Я уже говорил, что использую модуль django-bootstrap3 для вёрстки страниц? Поэтому не удивляйтесь тому, как свёрстан этот шаблон.

Комментарии представляют собой обычные строки в Grid системе Bootstrap, а древовидность достигается за счёт сдвига колонок.

Очень важным здесь является наличие у каждой строки id={{ comment.id }} - Это как раз то самое значение, которое будет подставляться в скрытое поле формы, если пользователь комментирует не статью, а какой-то из комментариев.

По этому же ID с помощью JavaScript будет перемещаться форма комментария по странице. А помещаться форма будет с помощью функции show_comments_form(). Данная функция помещается в обработчик ссылки "Ответить", у каждого комментария, а также в обработчик ссылки, просто для написания комментария. Данная функция использует библиотеку jQuery. Поэтому не забываем её подключить в вашем базовом шаблоне. У меня подключается та версия, которая используется с Bootstrap.

{% extends 'home/base.html' %}
{% load bootstrap3 %}
{% block page %}
    <article>
        <h1>{{ article.article_title }}</h1>
        {{ article.article_content|safe }}
    </article>
    <h2>Комментарии</h2>
    {% for comment in comments %}
        <a name="comment-{{ comment.id }}"></a>
        <div class="row" id="{{ comment.id }}">
            <div class="col-md-{{ comment.get_col }} col-md-offset-{{ comment.get_offset }}">
                <div class="panel panel-default">
                    <div class="panel-heading">
                        <strong>{{ comment.author_id.get_full_name|default:comment.author_id.username }}</strong>&nbsp;&nbsp;
                        {{ comment.pub_date }}
                        <a href="#comment-{{ comment.id }}">#</a>
                    </div>
                    <div class="panel-body">
                        <div>{{ comment.content|safe }}</div>
                        {% if form %}<a class="btn btn-default btn-xs pull-right"
                                        onclick="return show_comments_form({{ comment.id }})">
                            {% bootstrap_icon "share-alt" %}&nbsp;&nbsp;Ответить</a>
                        {% endif %}
                    </div>
                </div>
            </div>
        </div>
    {% endfor %}
    {% if form %}
        <h3 id="write_comment"><a onclick="return show_comments_form('write_comment')">Написать комментарий</a></h3>
        <form id="comment_form" action="{% url 'post:add_comment' article.id %}" method="post">
        {% csrf_token %}
        {% bootstrap_form form %}
        {% buttons %}
            <button type="submit" class="btn btn-primary">{% bootstrap_icon "comment" %}&nbsp;&nbsp;Комментировать</button>
        {% endbuttons %}
        </form>
    {% else %}
        <div class="panel panel-warning">
            <div class="panel-heading">
                <h3 class="panel-title">Комментарии</h3>
            </div>
            <div class="panel-body">
                Только авторизованные пользователи могут оставлять комментарии.<br />
            </div>
        </div>
    {% endif %}
{% endblock %}

JavaScript для перемещения комментариев по странице

Ну тут всё просто до безобразия. Если id = write_comment , то значит, что комментарий имеет первый уровень и скрытое поле будет пустым, а форма перемещается под надпись "Написать комментарий". В противном случае заполняем скрытое поле и помещаем его под комментарием, под которым даём ответ.

function show_comments_form(parent_comment_id)
{
    if (parent_comment_id == 'write_comment')
    {
        $("#id_parent_comment").val('')
    }
    else
    {
        $("#id_parent_comment").val(parent_comment_id);
    }
    $("#comment_form").insertAfter("#" + parent_comment_id);
}

Для Django рекомендую VDS-сервера хостера Timeweb .

Рекомендуем хостинг TIMEWEB
Рекомендуем хостинг TIMEWEB
Стабильный хостинг, на котором располагается социальная сеть EVILEG. Для проектов на Django рекомендуем VDS хостинг.

Вам это нравится? Поделитесь в социальных сетях!

bernar92
  • 6 декабря 2017 г. 0:39

не совсем понятно как это реализовать куда импортировать view и urls подскажите !

Evgenii Legotckoi
  • 6 декабря 2017 г. 3:18

views и urls должны присутсвовать в вашем приложении, например сайт в Django состоит из нескольких приложений, которые создаются через команду startapp, по умолчанию там всегда есть директории urls и views .

В моём случае это приложение post.
app_name = 'post'
Evgenii Legotckoi
  • 6 декабря 2017 г. 3:22

Если говорить про то, куда в итоге подключить urls , то они должны быть подключены в главный файл urls, он должен располагаться там же, где и файл settings.py

bernar92
  • 6 декабря 2017 г. 6:19

сделал немного по другому

class EArticleView(View):
    template_name = 'knowledge/article.html'
    comment_form = CommentForm

    def get(self, request,  *args, **kwargs):
        article = get_object_or_404(Article, id=self.kwargs['article_id'])
        context = {}
        context.update(csrf(request))
        user = auth.get_user(request)
        context['article'] = article
        # Помещаем в контекст все комментарии, которые относятся к статье
        # попутно сортируя их по пути, ID автоинкрементируемые, поэтому
        # проблем с иерархией комментариев не должно возникать
        context['comments'] = article.comment_set.all().order_by('path')
        context['next'] = article.get_absolute_url()
        # Будем добавлять форму только в том случае, если пользователь авторизован
        if user.is_authenticated:
            context['form'] = self.comment_form

        return render(request, template_name=self.template_name, context=context)

    # Декораторы по которым, только авторизованный пользователь
    # может отправить комментарий и только с помощью POST запроса
    @method_decorator(login_required)
    def post(self, request, *args, **kwargs):
        if request.method == 'POST':

            form = CommentForm(request.POST)
            article = get_object_or_404(Article, id=self.kwargs['article_id'])
            if form.is_valid():
                comment = Comment(
                    path=[],
                    article_id=article,
                    author_id=request.user,
                    content=form.cleaned_data['comment_area']
                )
                comment.save()

                # Django не позволяет увидеть ID комментария по мы не сохраним его,
                # хотя PostgreSQL имеет такие средства в своём арсенале, но пока не будем
                # работать с сырыми SQL запросами, поэтому сформируем path после первого сохранения
                # и пересохраним комментарий
                try:
                    comment.path.extend(Comment.objects.get(id=form.cleaned_data['parent_comment']).path)
                    comment.path.append(comment.id)
                    print('получилось')
                except ObjectDoesNotExist:
                    comment.path.append(comment.id)
                    print('не получилось')
                comment.save()
            return redirect(article.get_absolute_url())
как думаете так тоже хорошо ?

Evgenii Legotckoi
  • 6 декабря 2017 г. 6:30
Да, так будет даже лучше, я на сайте уже обновил до такого вида код

Вот это уже не нужно
if request.method == 'POST':
Поскольку Вы и так используете метод post, то есть эта проверка избыточна.

Что касается древовидных комментариев, то я от них как видите отказался. Я просто добавляю ID комментария, на который был дан ответ. Древовидные комментарии в итоге оказались не очень удобны для сайта с вставками программного кода.
bernar92
  • 7 декабря 2017 г. 4:24

есть визуальный пример ?


bernar92
  • 7 декабря 2017 г. 4:30

так

Evgenii Legotckoi
  • 7 декабря 2017 г. 4:47

Визуальный пример чего? комментариев?
При ответе на конкретный комментарий рядом с ником отвечающего будет стрелочка и указание ник другого пользователя. Который будет ссылкой на комментарий, на который был дан ответ.

ИМ
  • 1 февраля 2018 г. 13:58

Доброго времени суток Евгений. У меня ка то так получилось:

Не подскажете с чем может быть связано?
Evgenii Legotckoi
  • 1 февраля 2018 г. 16:20

Добрый день.

Скорее всего поля неправильно были получены. Что-то не так с запросом. То есть в полученном объекте значения values не были корректно забраны. Либо просто неправильно написали названия полей в шаблоне
ИМ
  • 15 ноября 2018 г. 16:35

Доброго времени суток Евгений. Не подскажете что я делаю не так? Получаю ошибку такого характера:


Reverse for 'add_comment' with arguments '('',)' not found. 1 pattern(s) tried: ['comment\\/(?P<article_id>[0-9]+)\\/$']

Говорит что ошибка в views в этой строке:

return render(request, template_name=self.template_name, context=context) 







Evgenii Legotckoi
  • 16 ноября 2018 г. 1:50

Добрый день!

шаблон не находит, или шаблон неправильно прописали, или тег шаблона неправильно написан, иных выводов сделать не могу, из того, что вы написали. трейсбек нужно смотреть. Создайте тему на форуме и выложить трейсбек с ошибкой.


progammist
  • 20 мая 2020 г. 7:15

А какие меры можно принять, чтобы обеспечить защиту от спама?
И еще как можно реализовать, чтобы по комменты выводились в админке и их можно было отклонять/одобрять?

Evgenii Legotckoi
  • 20 мая 2020 г. 16:07

Самая первая мера - это комментарии только для зарегистрированных пользователей.
Пока ресурс маленький, то и спамеров почти нет, которые готовы зарегистрироваться на сайте ради спам комментария.
Ну а для регистрации такие ограничения как подтверждение email и recaptcha.
У меня так спамеры активно начали появляться только когда посещаемость перевалила за 1500 в день.

Что касается одобрения и отклонения, то у меня просто есть поле moderation в каждой модели и action для админки, которые помечают контент как спам или модерированный контент. Но если учесть, что обычно спаммер регистрируется и только спамит. То я просто удаляю весь акканут со всем контентом спамера, а ег email заношу в чёрный список, чтобы с этого email регистрация больше не проходила. Для меня это вопрос двух действий и 10 секунд, а для спамера, который вручную этим занимается - это очень накладно.

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

progammist
  • 21 мая 2020 г. 4:39
  • (ред.)

поле moderation в каждой модели и action для админки, которые помечают контент как спам или модерированный контент.

А action - это функция одобрения/отклонения?

Evgenii Legotckoi
  • 22 мая 2020 г. 2:43

У меня эта часть кода выведена в open source, смотрите здесь

progammist
  • 28 мая 2020 г. 13:56

path = ArrayField(models.IntegerField())

ArrayField подходит только для postresql, а для mysql - выдает ошибку. Как можно переписать эту строку, чтобы на mysql работало?

Evgenii Legotckoi
  • 28 мая 2020 г. 15:31

Я бы вам посоветовал выкинуть mysql на помойку, но вы меня наверное не послушаете.
Скорее или конвертировать последовательность Integer значений в строку и сохранять в обычный CharField или извращаться с ManyToManyField.
Даже не знаю, что из этих двух вариантов будет хуже. Один порожадает лишний оверхед с преобразованиями, а второй оверхед с запросами.
Ни то ни другое не адекватное, но при шлифовке наверное будет работать.

progammist
  • 28 мая 2020 г. 15:42
  • (ред.)

а в чем явное преимущество postgresql над mysql?)

Evgenii Legotckoi
  • 28 мая 2020 г. 15:49

Он более функциональный и его функционал объективно лучше поддерживается Django.

Из первого, что приходит на ум:

  • Это наличие полей типа Array
  • Поддержка полей для JSON
  • Хорошая поддержка GIS функционала, также есть и батарейки соответствующие

Честно, я так сразу не вспомню, но когда сам задавался вопросом, то столкнулся с тем, что рекомендуют при разработке на Джанго использовать Postgres.

Вообще самая рекомендуемая связка стека - это Django/PostgreSQL/Nginx.

Как бы можно и MySQL, но вы скорее увидите на StackOverflow вопрос о том как сделать в MySQL так, как это делается в PostgreSQL, а не наоборот.
А для меня это уже повод задуматься.

b
  • 21 марта 2022 г. 5:29

Извините, проверка функциональности, визуально здесь не увидел древовидной иерархии в комментах (вроде я тоже не дерево:))
зы спасибо за интересные статьи!

Evgenii Legotckoi
  • 17 апреля 2022 г. 17:36

В рамках развития сайта выпилил полностью. Ибо неудобно было поддерживать. Да и всё-таки не нравятся они мне из-за избыточной траты полезного пространства. Не говоря уже о том, что на мобильных платформах это выглядит отвратно.

NSProject
  • 15 июня 2022 г. 6:03

Меня интересует как экранировать или преобразовывать теги которые нежелательны при добавлении в бд. Ибо текст комментария выводится

{{ comment.text|safe }}

то есть как html. А без safe это просто набор текста и всё

Evgenii Legotckoi
  • 15 июня 2022 г. 6:50
NSProject
  • 15 июня 2022 г. 7:27

Плохо я однако пользовался поиском по сайту. Спасибо

Evgenii Legotckoi
  • 15 июня 2022 г. 7:28

Поиск не совсем хорошо работает, так что норм

Комментарии

Только авторизованные пользователи могут публиковать комментарии.
Пожалуйста, авторизуйтесь или зарегистрируйтесь
m
  • moreSpb
  • 18 марта 2024 г. 15:32

C++ - Тест 003. Условия и циклы

  • Результат:85баллов,
  • Очки рейтинга6
в

C++ - Тест 003. Условия и циклы

  • Результат:50баллов,
  • Очки рейтинга-4
l

C++ - Тест 005. Структуры и Классы

  • Результат:91баллов,
  • Очки рейтинга8
Последние комментарии
k
kmssr8 февраля 2024 г. 18:43
Qt Linux - Урок 001. Автозапуск Qt приложения под Linux как сделать автозапуск для флэтпака, который не даёт создавать файлы в ~/.config - вот это вопрос ))
АК
Анатолий Кононенко5 февраля 2024 г. 1:50
Qt WinAPI - Урок 007. Работаем с ICMP Ping в Qt Без строки #include <QRegularExpressionValidator> в заголовочном файле не работает валидатор.
EVA
EVA25 декабря 2023 г. 10:30
Boost - статическая линковка в CMake проекте под Windows Ошибка LNK1104 часто возникает, когда компоновщик не может найти или открыть файл библиотеки. В вашем случае, это файл libboost_locale-vc142-mt-gd-x64-1_74.lib из библиотеки Boost для C+…
J
JonnyJo25 декабря 2023 г. 8:38
Boost - статическая линковка в CMake проекте под Windows Сделал всё по-как у вас, но выдаёт ошибку [build] LINK : fatal error LNK1104: не удается открыть файл "libboost_locale-vc142-mt-gd-x64-1_74.lib" Хоть убей, не могу понять в чём дел…
G
Gvozdik18 декабря 2023 г. 21:01
Qt/C++ - Урок 056. Подключение библиотеки Boost в Qt для компиляторов MinGW и MSVC Для решения твой проблемы добавь в файл .pro строчку "LIBS += -lws2_32" она решит проблему , лично мне помогло.
Сейчас обсуждают на форуме
P
Pisych27 февраля 2023 г. 4:04
Как получить в массив значения из связанной модели? Спасибо, разобрался:))
AC
Alexandru Codreanu19 января 2024 г. 11:57
QML Обнулить значения SpinBox Доброго времени суток, не могу разобраться с обнулением значение SpinBox находящего в делегате. import QtQuickimport QtQuick.ControlsWindow { width: 640 height: 480 visible: tr…
BlinCT
BlinCT27 декабря 2023 г. 8:57
Растягивать Image на парент по высоте Ну и само собою дял включения scrollbar надо чтобы был Flickable. Так что выходит как то так Flickable{ id: root anchors.fill: parent clip: true property url linkFile p…
Дмитрий
Дмитрий10 января 2024 г. 4:18
Qt Creator загружает всю оперативную память Проблема решена. Удалось разобраться с помощью утилиты strace. Запустил ее: strace ./qtcreator Начал выводиться весь лог работы креатора. В один момент он начал считывать фай…
Evgenii Legotckoi
Evgenii Legotckoi12 декабря 2023 г. 6:48
Побуквенное сравнение двух строк Добрый день. Там случайно не высылается этот сигнал textChanged ещё и при форматировани текста? Если решиать в лоб, то можно просто отключать сигнал/слотовое соединение внутри слота и …

Следите за нами в социальных сетях