第 15 章:评论

我们可以通过两种方式为 Newspaper 网站添加评论功能。第一种是创建一个专门的评论应用并与文章关联;然而,这似乎有些过度设计了。相反,我们可以在文章应用中添加一个名为 Comment 的模型,并通过外键将其与 Article 模型关联。我们将采用更直接的方法,因为以后随时可以增加复杂性。

Django 的整个结构——一个项目包含多个较小的应用——旨在帮助开发者理清网站的架构。计算机并不关心代码如何组织。将功能拆分为更小的部分有助于我们和未来的团队成员理解 Web 应用程序的逻辑。但你不必过早地进行优化。如果将来你的评论逻辑变得复杂,当然可以将其拆分为独立的评论应用。但首要任务是让代码能够工作,确保其性能良好,并使其结构清晰,以便你或其他人几个月后仍能理解。

我们需要添加什么才能为网站提供评论功能?我们已经知道这需要涉及模型、URL、视图、模板,在本例中还需要表单。最终的解决方案需要所有这些要素,但处理它们的顺序很大程度上取决于我们自己。许多 Django 开发者发现按照以下顺序——模型 -> URL -> 视图 -> 模板/表单——效果最好,因此我们也将采用此顺序。到本章结束时,用户将能够对我们网站上的任何已有文章添加评论。

模型

首先,在现有数据库中添加一个名为 Comment 的新表。该模型将与 Article 建立多对一的外键关系:一篇文章可以有多条评论,但反之则不然。按照传统,外键字段的名称就是它所关联的模型名称,所以该字段将命名为 article。另外两个字段将是 commentauthor

打开 articles/models.py 文件,在已有代码下方添加以下内容。注意,我们按照最佳实践包含了 __str__get_absolute_url 方法。

# articles/models.py
...
class Comment(models.Model):                      # 新增
    article = models.ForeignKey(Article, on_delete=models.CASCADE)
    comment = models.CharField(max_length=140)
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )

    def __str__(self):
        return self.comment

    def get_absolute_url(self):
        return reverse("article_list")

由于我们更新了模型,现在需要创建新的迁移文件并应用它。注意,在 makemigrations 命令末尾加上 articles(这是可选的),表示我们只针对 articles 应用进行迁移。这是一个好习惯,因为想想看,如果我们在两个不同的应用中修改了模型会怎样?如果不指定具体应用,那么两个应用的变更会被合并到同一个迁移文件中,这会增加将来调试错误的难度。请保持每次迁移尽可能小且独立。

(.venv) $ python manage.py makemigrations articles
Migrations for 'articles':
  articles/migrations/0002_comment.py
    - Create model Comment

(.venv) $ python manage.py migrate
Operations to perform:
  Apply all migrations: accounts, admin, articles, auth, contenttypes, sessions
Running migrations:
  Applying articles.0002_comment... OK

管理后台

创建新模型后,最好先在管理后台中试用一下,然后再将其显示在网站上。将 Comment 添加到 admin.py 文件中,使其可见。

# articles/admin.py
from django.contrib import admin
from .models import Article, Comment          # 新增 Comment

class ArticleAdmin(admin.ModelAdmin):
    list_display = [
        "title",
        "body",
        "author",
    ]

admin.site.register(Article, ArticleAdmin)
admin.site.register(Comment)                  # 新增

然后启动服务器,运行 python manage.py runserver,导航到主页 http://127.0.0.1:8000/admin/。

管理后台主页

在”Articles”应用下,你会看到两个表:Comments 和 Articles。点击 Comments 旁边的”+ Add”。

管理后台评论页面

这里有 Article、Author 的下拉菜单以及 Comment 旁边的文本字段。选择一篇文章,写一条评论,然后选择一个非超级用户的作者(比如我在图中使用的 testuser)。然后点击”Save”按钮。

管理后台 testuser 评论

接下来你会在管理后台的”Comments”页面上看到你的评论。

管理后台单条评论

此时,我们可以添加一个额外的管理字段来在此页面查看评论和文章。但更好的方式是在单个 Article 模型中查看所有相关的 Comment 模型。我们可以通过 Django 管理后台的一个称为 inlines(内联)的功能来实现,它以可视化的方式显示外键关系。

有两种主要的内联视图:TabularInlineStackedInline。两者的唯一区别在于显示信息的模板不同。在 TabularInline 中,所有模型字段显示在一行上;在 StackedInline 中,每个字段独占一行。我们将实现两种方式,以便你可以决定更喜欢哪一种。

在你的文本编辑器中更新 articles/admin.py 以添加 StackedInline 视图。

# articles/admin.py
from django.contrib import admin
from .models import Article, Comment

class CommentInline(admin.StackedInline):     # 新增
    model = Comment

class ArticleAdmin(admin.ModelAdmin):         # 新增
    inlines = [
        CommentInline,
    ]
    list_display = [
        "title",
        "body",
        "author",
    ]

admin.site.register(Article, ArticleAdmin)
admin.site.register(Comment)                  # 新增

现在前往管理后台主页 http://127.0.0.1:8000/admin/,点击”Articles”。选择你刚评论过的文章。

管理后台变更页面

是不是好多了?我们可以在一个地方查看和修改所有相关的文章和评论。注意,默认情况下 Django 管理后台会在这里显示三个空行。你可以通过 extra 字段更改默认显示的数量。如果你希望默认不显示额外字段,代码如下:

# articles/admin.py
...
class CommentInline(admin.StackedInline):
    model = Comment
    extra = 0                                 # 新增

管理后台无额外评论

不过,我个人更喜欢使用 TabularInline,因为它可以在更少的空间内显示更多信息:评论、作者等都在同一行。要切换过去,只需将 CommentInlineadmin.StackedInline 改为 admin.TabularInline

# articles/admin.py
from django.contrib import admin
from .models import Article, Comment

class CommentInline(admin.TabularInline):     # 新增
    model = Comment
    extra = 0

class ArticleAdmin(admin.ModelAdmin):
    inlines = [
        CommentInline,
    ]
    list_display = [
        "title",
        "body",
        "author",
    ]

admin.site.register(Article, ArticleAdmin)
admin.site.register(Comment)

刷新 Articles 的当前管理后台页面,你会看到新的变化:每个模型的所有字段都显示在同一行上。

TabularInline 页面

好多了。现在我们需要通过更新模板来在网站上显示评论。

模板

我们希望评论出现在文章列表页面,并允许已登录用户在文章详情页面添加评论。这意味着需要更新模板文件 article_list.htmlarticle_detail.html

先从 article_list.html 开始。如果你再查看 articles/models.py 文件,很明显 Comment 通过外键关联到 article。要显示与特定文章相关的所有评论,我们将通过“查询”反向追溯关系,这是一种向数据库询问特定信息的方式。Django 有一个内置语法 FOO_set,其中 FOO 是源模型的小写名称。因此对于我们的 Article 模型,使用语法 {% for comment in article.comment_set.all %} 来查看所有相关评论。然后,在此 for 循环中,我们可以指定要显示的内容,例如评论本身和作者。

以下是更新后的 article_list.html 文件——更改从 card-body div 类之后开始。

<!-- templates/article_list.html -->
...
<div class="card-body">
    {{ article.body }}
    {% if article.author.pk == request.user.pk %}
        <a href="{% url 'article_edit' article.pk %}">Edit</a>
        <a href="{% url 'article_delete' article.pk %}">Delete</a>
    {% endif %}
</div>
<div class="card-footer">
    {% for comment in article.comment_set.all %}
        <p>
            <span class="fw-bold">
                {{ comment.author }} &middot;
            </span>
            {{ comment }}
        </p>
    {% endfor %}
</div>
</div>
<br/>
{% endfor %}
{% endblock content %}

如果你刷新 http://127.0.0.1:8000/articles/ 页面,我们可以看到页面上显示的新评论。

带评论的文章页面

让我们也将评论添加到每篇文章的详情页面。我们将使用相同的反向追溯关系技术来访问作为文章模型外键的评论。

<!-- templates/article_detail.html -->
{% extends "base.html" %}
{% block content %}
<div class="article-entry">
    <h2>{{ object.title }}</h2>
    <p>by {{ object.author }} | {{ object.date }}</p>
    <p>{{ object.body }}</p>
</div>
<!-- 更改从这里开始! -->
<hr>
<h4>评论</h4>
{% for comment in article.comment_set.all %}
    <p>{{ comment.author }} &middot; {{ comment }}</p>
{% endfor %}
<hr>
<!-- 更改在这里结束! -->
{% if article.author.pk == request.user.pk %}
    <p><a href="{% url 'article_edit' article.pk %}">编辑</a>
    <a href="{% url 'article_delete' article.pk %}">删除</a>
    </p>
{% endif %}
<p>返回 <a href="{% url 'article_list' %}">所有文章</a></p>
{% endblock content %}

导航到带有评论的文章详情页面,所有评论都会显示出来。

文章详情页带评论

我们的布局不会赢得任何设计大奖,但这是一本关于 Django 的书,所以我们的目标是输出正确的内容。

评论表单

评论现在可见了,但我们还需要添加一个表单,以便用户可以在网站上添加评论。Web 表单是一个非常复杂的主题,因为安全性至关重要:任何时候你接受用户的数据并存入数据库,都必须极其谨慎。好消息是,Django 表单为我们处理了大部分工作。

[ModelForm](https://docs.djangoproject.com/en/5.0/topics/forms/modelforms/#modelform) 是一个辅助类,它将数据库模型转换为表单。我们可以用它创建一个名为 CommentForm 的表单。我们可以将这个表单放在现有的 articles/models.py 文件中,但通常最佳实践是将所有表单放在应用内的专用 forms.py 文件中。这就是我们在此采用的方法。

用文本编辑器创建一个新文件 articles/forms.py。在顶部导入 forms(它包含 ModelForm 模块)。然后导入我们的模型 Comment,因为我们也需要用到它。最后,创建 CommentForm 类,指定底层模型和要暴露的特定字段 comment。当我们创建相应的视图时,将自动把作者设置为当前登录用户。

# articles/forms.py
from django import forms
from .models import Comment

class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = ("comment",)

Web 表单可能极其复杂,但 Django 很贴心地将大部分复杂性抽象掉了。

评论视图

目前,我们依赖通用的基于类的 DetailView 来驱动 ArticleDetailView。它显示单个条目,但需要配置以添加额外的信息,比如表单。基于类的视图非常强大,因为它们的继承结构意味着,如果我们知道在哪里查找,通常有一个特定的模块可以被覆盖来实现我们想要的结果。

我们在这里需要的模块叫做 get_context_data()。它用于通过更新 context(一个包含模板中所有可用变量名和值的字典对象)来向模板添加信息。出于性能原因,Django 模板只编译一次;如果我们想让某些内容在模板中可用,它必须在开始时加载到上下文中。

我们在这里要添加什么?就是我们的 CommentForm。由于 context 是一个字典,我们还需要分配一个变量名。用 form 怎么样?以下是 articles/views.py 中的新代码。

# articles/views.py
...
from .models import Article
from .forms import CommentForm              # 新增

class ArticleDetailView(LoginRequiredMixin, DetailView):
    model = Article
    template_name = "article_detail.html"

    def get_context_data(self, **kwargs):   # 新增
        context = super().get_context_data(**kwargs)
        context["form"] = CommentForm()
        return context

在文件顶部,from .models import Article 的上方,我们添加了 CommentForm 的导入行,然后更新了 get_context_data() 方法。首先,我们使用 super() 将所有现有信息拉入 context,然后添加变量名 form(值为 CommentForm()),最后返回更新后的 context。

评论模板

为了在 article_detail.html 模板文件中显示表单,我们将使用 form 变量和 crispy forms。这种模式与我们之前在其它表单中所做的相同。在顶部加载 crispy_form_tags,创建一个使用 csrf_token 确保安全的标准 POST 表单,并通过 { form|crispy } 显示表单字段。

<!-- templates/article_detail.html -->
{% extends "base.html" %}
{% load crispy_forms_tags %} <!-- 新增! -->
{% block content %}
<div class="article-entry">
    <h2>{{ object.title }}</h2>
    <p>by {{ object.author }} | {{ object.date }}</p>
    <p>{{ object.body }}</p>
</div>
<hr>
<h4>评论</h4>
{% for comment in article.comment_set.all %}
    <p>{{ comment.author }} &middot; {{ comment }}</p>
{% endfor %}
<hr>
<!-- 更改从这里开始! -->
<h4>添加评论</h4>
<form action="" method="post">{% csrf_token %}
    {{ form|crispy }}
    <button class="btn btn-success ms-2" type="submit">保存</button>
</form>
<!-- 更改在这里结束! -->
<div>
{% if article.author.pk == request.user.pk %}
    <p><a href="{% url 'article_edit' article.pk %}">编辑</a>
    <a href="{% url 'article_delete' article.pk %}">删除</a>
    </p>
{% endif %}
<p>返回 <a href="{% url 'article_list' %}">所有文章</a></p>
</div>
{% endblock content %}

如果你刷新详情页面,表单现在会显示出来,带有熟悉的 Bootstrap 和 crispy forms 样式。

表单已显示

成功了!然而我们只完成了一半。如果你尝试提交表单,会收到错误,因为我们的视图尚不支持任何 POST 方法!

评论提交视图

我们最终需要一个能够同时处理 GET 和 POST 请求的视图,具体取决于表单是仅用于显示还是可以提交。我们可以使用 FormMixin 将两者合并到 ArticleDetailView 中,但正如 Django 文档所清楚展示的,这种方法存在风险。

为了避免 DetailViewFormMixin 之间的微妙交互,我们将 GET 和 POST 的变体分离到各自的专用视图中。然后我们可以将 ArticleDetailView 转换为一个组合它们的包装视图。这是更高级的 Django 开发中非常常见的模式,因为单个 URL 通常需要根据用户请求(GET、POST 等)甚至格式(返回 HTML 与 JSON)而表现不同。

首先,将 ArticleDetailView 重命名为 CommentGet,因为它处理 GET 请求但不处理 POST 请求。然后创建一个新的 CommentPost 视图,暂时为空。最后,将 CommentPostCommentGet 组合成一个新的 ArticleDetailView,它继承自 View——所有其他基于类的视图所基于的基础类。

# articles/views.py
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.views import View                                                # 新增
from django.views.generic import ListView, DetailView
...

class CommentGet(DetailView):                                                # 新增
    model = Article
    template_name = "article_detail.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["form"] = CommentForm()
        return context

class CommentPost():                                                         # 新增
    pass

class ArticleDetailView(LoginRequiredMixin, View):                           # 新增
    def get(self, request, *args, **kwargs):
        view = CommentGet.as_view()
        return view(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        view = CommentPost.as_view()
        return view(request, *args, **kwargs)
...

回到浏览器中,导航到主页,然后重新加载带有评论的文章页面。一切应该和之前一样。

我们已经准备好编写 CommentPost 并完成向网站添加评论的任务。就差最后一步了!

[FormView](https://docs.djangoproject.com/en/5.0/ref/class-based-views/generic-editing/#django.views.generic.edit.FormView) 是一个内置视图,用于显示表单、验证错误,并重定向到新的 URL。我们将它与 SingleObjectMixin 配合使用,以将当前文章与我们的表单关联起来;换句话说,如果你在 articles/4/ 有一条评论(就像截图中的那样),那么 SingleObjectMixin 会获取 4,这样我们的评论就会保存到主键为 4 的文章上。

以下是完整的代码,我们将逐行解释。

# articles/views.py
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.views import View
from django.views.generic import ListView, DetailView, FormView            # 新增
from django.views.generic.detail import SingleObjectMixin                  # 新增
from django.views.generic.edit import UpdateView, DeleteView, CreateView
from django.urls import reverse_lazy, reverse                              # 新增
from .forms import CommentForm
from .models import Article
...

class CommentPost(SingleObjectMixin, FormView):                            # 新增
    model = Article
    form_class = CommentForm
    template_name = "article_detail.html"

    def post(self, request, *args, **kwargs):
        self.object = self.get_object()
        return super().post(request, *args, **kwargs)

    def form_valid(self, form):
        comment = form.save(commit=False)
        comment.article = self.object
        comment.author = self.request.user
        comment.save()
        return super().form_valid(form)

    def get_success_url(self):
        article = self.object
        return reverse("article_detail", kwargs={"pk": article.pk})
...

在顶部,导入 FormViewSingleObjectMixinreverseFormView 依赖 form_class 来设置我们使用的表单名称 CommentForm。首先是 post():我们使用 SingleObjectMixin 中的 get_object() 从 URL 中获取文章的主键。接下来是 form_valid(),它在表单验证成功后调用。在将评论保存到数据库之前,必须指定它所属的文章。我们先将表单保存但设置 commit=False,因为下一行我们将正确的文章关联到表单对象。同时还将 Comment 模型中的 author 字段设置为当前用户。然后保存表单。最后,将其作为 form_valid() 的一部分返回。最后一个模块 get_success_url() 在表单数据保存后被调用;我们将用户重定向回当前页面。

大功告成!现在加载你的文章页面,刷新页面,然后尝试提交第二条评论。

在表单中提交评论

页面会自动重新加载并显示新评论,如下所示:

评论已显示

新评论链接

虽然用户可以从文章详情页面添加评论,但读者更可能想从所有文章页面开始评论。如果他们知道点击详情链接是可以的,但这并不是一个好的用户体验设计。让我们为每篇文章添加一个”新评论”链接;它会导航到详情页面并允许添加评论。在 card-body 部分的 if/endif 循环之外添加一行代码。

<!-- templates/article_list.html -->
...
<div class="card-body">
    <p>{{ article.body }}</p>
    {% if article.author.pk == request.user.pk %}
        <a href="{% url 'article_edit' article.pk %}">编辑</a>
        <a href="{% url 'article_delete' article.pk %}">删除</a>
    {% endif %}
    <a href="{{ article.get_absolute_url }}">新评论</a> <!-- 新增 -->
</div>

刷新所有文章页面查看更改,然后点击”新评论”链接确认其正常工作。

所有文章页面上的新评论链接

Git

本章我们添加了大量代码。在进入最后一章之前,请确保保存我们的工作。

(.venv) $ git status
(.venv) $ git add -A
(.venv) $ git commit -m "comments app"
(.venv) $ git push origin main

总结

我们的 Newspaper 应用现在已经完成。它拥有强大的用户认证流程,使用了自定义用户模型、文章、评论以及通过 Bootstrap 改进的样式。我们甚至还初步涉及了权限和授权。

剩下的任务就是将其部署上线。在下一章中,我们将学习如何使用环境变量、PostgreSQL 和其他设置来正确部署 Django 网站。