第 15 章:评论¶
我们可以通过两种方式为 Newspaper 网站添加评论功能。第一种是创建一个专门的 comments 应用并将其与 articles 关联;但这似乎过度设计了。相反,我们可以在 articles 应用中添加一个名为 Comment 的模型,并通过外键将其与 Article 模型关联。我们将采用这种更简单的方式,因为以后随时可以增加复杂度。
Django 的整体结构——一个项目包含多个较小的应用——旨在帮助开发者更好地理解网站。计算机并不关心代码是如何组织的。将功能拆分成更小的部分有助于我们——以及未来的队友——理解 Web 应用中的逻辑。但你不需要过早优化。如果最终的评论逻辑变得很复杂,那么完全可以将其拆分为独立的 comments 应用。首先要做的是让代码能运行,确保性能良好,并以易于理解的方式组织它,这样几个月后你或其他人都能看懂。
我们需要什么来为网站添加评论功能呢?我们已经知道它将涉及 models、URLs、views、templates,以及在本例中还有 forms。最终解决方案需要这些全部到位,但我们处理它们的顺序在很大程度上取决于我们自己。许多 Django 开发者发现按照这个顺序——models -> URLs -> views -> templates/forms——效果最好,所以我们将在这里采用这种方式。在本章结束时,用户将能够为我们网站上的任何现有文章添加评论。
Model¶
让我们首先在现有数据库中添加一个名为 Comment 的新表。这个模型将与 Article 建立多对一的外键关系:一篇文章可以有多条评论,但反过来不行。传统上,外键字段的名称就是它所链接的模型名称,因此该字段将命名为 article。另外两个字段分别是 comment 和 author。
打开 articles/models.py 文件,在现有代码下方添加以下内容。注意我们包含了 __str__ 和 get_absolute_url 方法作为最佳实践。
# articles/models.py
...
class Comment(models.Model): # new
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")
既然我们更新了 models,是时候创建一个新的迁移文件并应用它了。注意,通过在 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
Admin¶
创建新模型后,在将其显示到网站之前,先在 admin 应用中试用一下是个好主意。将 Comment 添加到 admin.py 文件中,使其可见。
# articles/admin.py
from django.contrib import admin
from .models import Article, Comment # new
class ArticleAdmin(admin.ModelAdmin):
list_display = [
"title",
"body",
"author",
]
admin.site.register(Article, ArticleAdmin)
admin.site.register(Comment) # new
然后使用 python manage.py runserver 启动服务器,并导航到我们的主管理页面 http://127.0.0.1:8000/admin/。

在"Articles"应用下,你会看到我们的两个表:Comments 和 Articles。点击 Comments 旁边的"+ Add"。这里有 Article 和 Author 的下拉菜单,以及 Comment 旁边的文本字段。

选择一篇文章,写一条评论,然后选择一个不是你的超级用户的作者,比如像我一样选择 testuser。然后点击"Save"按钮。

接下来你应该能在 admin 的"Comments"页面上看到你的评论。

此时,我们可以添加一个额外的 admin 字段来在此页面上同时查看评论和文章。但如果能在一个地方看到与单个 Article 模型相关的所有 Comment 模型岂不是更好?我们可以使用 Django admin 的一个名为 inlines 的功能,它以可视化的方式显示外键关系。
这里使用了两种主要的 inline 视图:TabularInline 和 StackedInline。两者之间唯一的区别是显示信息的模板。在 TabularInline 中,所有模型字段显示在一行;在 StackedInline 中,每个字段各占一行。我们将同时实现这两种,以便你决定更喜欢哪一种。
在文本编辑器中更新 articles/admin.py,添加 StackedInline 视图。
# articles/admin.py
from django.contrib import admin
from .models import Article, Comment
class CommentInline(admin.StackedInline): # new
model = Comment
class ArticleAdmin(admin.ModelAdmin): # new
inlines = [
CommentInline,
]
list_display = [
"title",
"body",
"author",
]
admin.site.register(Article, ArticleAdmin) # new
admin.site.register(Comment)
现在访问管理主页面 http://127.0.0.1:8000/admin/,点击"Articles"。选择你刚刚评论过的文章。

好多了,对吧?我们可以在一个地方查看和修改所有相关的文章和评论。注意,默认情况下,Django admin 会在这里显示三个空行。你可以使用 extra 字段更改默认显示的数量。所以如果你希望默认不显示额外字段,代码如下:
# articles/admin.py
...
class CommentInline(admin.StackedInline):
model = Comment
extra = 0 # new

不过我个人更喜欢使用 TabularInline,因为它在更少的空间中显示更多信息:评论、作者等都在同一行。要切换到它,我们只需要将 CommentInline 从 admin.StackedInline 改为 admin.TabularInline。
# articles/admin.py
from django.contrib import admin
from .models import Article, Comment
class CommentInline(admin.TabularInline): # new
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 管理页面,你会看到新的变化:每个模型的所有字段都显示在同一行。

好多了。现在我们需要通过更新模板来在网站上显示评论。
模板¶
我们希望评论出现在文章列表页面上,并允许已登录用户在文章详情页上添加评论。这意味着需要更新模板文件 article_list.html 和 article_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 class 之后开始。
<!-- 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 }} ·
</span>
{{ comment }}
</p>
{% endfor %}
</div>
</div>
<br/>
{% endfor %}
{% endblock content %}
如果你刷新文章页面 http://127.0.0.1:8000/articles/,我们可以看到页面上的新评论。

让我们也在每篇文章的详情页上添加评论。我们将使用相同的反向关系访问技术,将评论作为 article 模型的外键来访问。
<!-- 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>
<!-- Changes start here! -->
<hr>
<h4>Comments</h4>
{% for comment in article.comment_set.all %}
<p>{{ comment.author }} · {{ comment }}</p>
{% endfor %}
<hr>
<!-- Changes end here! -->
{% if article.author.pk == request.user.pk %}
<p><a href="{% url 'article_edit' article.pk %}">Edit</a>
<a href="{% url 'article_delete' article.pk %}">Delete</a>
</p>
{% endif %}
<p>Back to <a href="{% url 'article_list' %}">All Articles</a>.</p>
{% endblock content %}
导航到你的文章详情页(含评论的),所有评论都将可见。

我们不会因为这个布局赢得任何设计奖项,但这是一本关于 Django 的书,所以我们的目标是输出正确的内容。
评论表单¶
评论现在可见了,但我们需要添加一个表单,以便用户可以将它们添加到网站上。Web 表单是一个非常复杂的话题,因为安全性至关重要:每当你接受来自用户的数据并将其存储在数据库中时,你必须高度谨慎。好消息是 Django forms 为我们处理了大部分工作。
ModelForm 是一个辅助类,可以将数据库模型转换为表单。我们可以用它来创建一个名为 CommentForm 的表单。我们可以将这个表单放在现有的 articles/models.py 文件中,但通常最佳实践是将所有表单放在应用内专门的 forms.py 文件中。这就是我们将要采用的方式。
用文本编辑器创建一个名为 articles/forms.py 的新文件。在顶部,导入 forms(它包含 ModelForm 模块)。然后导入我们的模型 Comment,因为我们也需要它。最后,创建 CommentForm 类,指定底层模型和要公开的特定字段 comment。当我们创建相应的视图时,我们将自动将 author 设置为当前登录的用户。
# 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 来向模板添加信息,context 是一个字典对象,包含模板中所有可用的变量名和值。出于性能原因,Django 模板只编译一次;如果我们希望某些内容在模板中可用,它必须在开始时加载到 context 中。
在这种情况下我们想添加什么?当然是我们的 CommentForm。由于 context 是一个字典,我们还必须指定一个变量名。用 form 怎么样?以下是 articles/views.py 中的新代码。
# articles/views.py
...
from .models import Article
from .forms import CommentForm # new
class ArticleDetailView(LoginRequiredMixin, DetailView):
model = Article
template_name = "article_detail.html"
def get_context_data(self, **kwargs): # new
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,创建一个标准的 post 表单,使用 csrf_token 保证安全,并通过 {{ form|crispy }} 显示我们的表单字段。
<!-- templates/article_detail.html -->
{% extends "base.html" %}
{% load crispy_forms_tags %} <!-- new! -->
{% block content %}
<div class="article-entry">
<h2>{{ object.title }}</h2>
<p>by {{ object.author }} | {{ object.date }}</p>
<p>{{ object.body }}</p>
</div>
<hr>
<h4>Comments</h4>
{% for comment in article.comment_set.all %}
<p>{{ comment.author }} · {{ comment }}</p>
{% endfor %}
<hr>
<!-- Changes start here! -->
<h4>Add a comment</h4>
<form action="" method="post">{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success ms-2" type="submit">Save</button>
</form>
<!-- Changes end here! -->
<div>
{% if article.author.pk == request.user.pk %}
<p><a href="{% url 'article_edit' article.pk %}">Edit</a>
<a href="{% url 'article_delete' article.pk %}">Delete</a>
</p>
{% endif %}
<p>Back to <a href="{% url 'article_list' %}">All Articles</a>.</p>
</div>
{% endblock content %}
如果你刷新详情页,表单现在会以熟悉的 Bootstrap 和 crispy forms 样式显示。

成功了!然而,我们只完成了一半。如果你尝试提交表单,你会收到一个错误,因为我们的视图还不支持任何 POST 方法!
评论 POST 视图¶
我们最终需要一个视图,根据表单是应该仅被显示还是能够被提交,分别处理 GET 和 POST 请求。我们可以使用 FormMixin 将两者合并到 ArticleDetailView 中,但正如 Django 文档所清楚说明的,这种方法存在风险。
为了避免 DetailView 和 FormMixin 之间的微妙交互,我们将 GET 和 POST 变体分离到各自专用的视图中。然后我们可以将 ArticleDetailView 转变为一个包装视图,将它们组合在一起。这是更高级的 Django 开发中非常常见的模式,因为单个 URL 通常需要根据用户请求(GET、POST 等)甚至格式(返回 HTML 还是 JSON)表现出不同的行为。
让我们首先将 ArticleDetailView 重命名为 CommentGet,因为它处理 GET 请求但不处理 POST 请求。然后我们将创建一个暂时为空的 CommentPost 视图。我们可以将 CommentPost 和 CommentGet 组合到一个新的 ArticleDetailView 中,它继承自 View——所有其他基于类的视图都建立在这个基础类之上。
# articles/views.py
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.views import View # new
from django.views.generic import ListView, DetailView
...
class CommentGet(DetailView): # new
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(): # new
pass
class ArticleDetailView(LoginRequiredMixin, View): # new
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)
...
在 Web 浏览器中导航回主页,然后重新加载带有评论的文章页面。一切应该像以前一样工作。
我们准备编写 CommentPost 并完成向网站添加评论的任务。我们快完成了!
FormView 是一个内置视图,可以显示表单、任何验证错误并重定向到新的 URL。我们将它与 SingleObjectMixin 一起使用,以将当前文章与我们的表单关联;换句话说,如果你在 articles/4/ 有一条评论(就像我的截图中那样),那么 SingleObjectMixin 将获取 4,以便我们的评论被保存到 pk 为 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 # new
from django.views.generic.detail import SingleObjectMixin # new
from django.views.generic.edit import UpdateView, DeleteView, CreateView
from django.urls import reverse_lazy, reverse # new
from .forms import CommentForm
from .models import Article
...
class CommentPost(SingleObjectMixin, FormView): # new
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})
...
在顶部,导入 FormView、SingleObjectMixin 和 reverse。FormView 依赖 form_class 来设置我们使用的表单名称 CommentForm。首先是 post():我们使用 SingleObjectMixin 的 get_object() 从 URL 获取文章的 pk。接下来是 form_valid(),它在表单验证成功后被调用。在将评论保存到数据库之前,我们必须指定它所属的文章。首先,我们保存表单但将 commit 设为 False,因为我们在下一行中将正确的文章与表单对象关联。我们还将 Comment 模型中的 author 字段设置为当前用户。在接下来的一行中,我们保存表单。最后,我们将其作为 form_valid() 的一部分返回。最后一个模块 get_success_url() 在表单数据保存后被调用;我们将用户重定向到当前页面。
我们完成了!现在去加载你的文章页面,刷新页面,然后尝试提交第二条评论。

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

新评论链接¶
虽然你可以从文章详情页为文章添加评论,但读者更可能想从所有文章页面评论某些内容。如果他们知道点击详情链接可以做到,但那不是很好的用户设计。让我们在列出的每篇文章上添加一个"New Comment"链接;它将导航到详情页并允许该功能。通过在 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 %}">Edit</a>
<a href="{% url 'article_delete' article.pk %}">Delete</a>
{% endif %}
<a href="{{ article.get_absolute_url }}">New Comment</a> <!-- new -->
</div>
刷新所有文章网页以查看更改,然后点击"New Comment"链接确认它按预期工作。

Git¶
我们在本章中添加了不少代码。让我们在即将到来的最后一章之前确保保存我们的工作。
(.venv) $ git status
(.venv) $ git add -A
(.venv) $ git commit -m "comments app"
(.venv) $ git push origin main
总结¶
我们的 Newspaper 应用现在已经完成了。它拥有一个强大的用户认证流程,使用了自定义用户模型、文章、评论,以及通过 Bootstrap 改进的样式。我们甚至还涉足了权限和授权。
剩下的任务是将其部署到线上。在下一章中,我们将了解如何使用环境变量、PostgreSQL 和其他设置来正确部署 Django 站点。