第 13 章:文章应用

是时候构建我们的 Newspaper 应用了。我们将创建一个文章页面,让记者可以发布文章;设置权限以确保只有文章的作者才能编辑或删除它;最后添加让其他用户对每篇文章发表评论的功能。

文章应用

首先,创建一个文章应用并定义数据库模型。关于应用命名的规则没有硬性规定,唯一的限制是不能使用内置应用的名称。查看 django_project/settings.py 中的 INSTALLED_APPS 部分,你可以看到哪些应用名称是禁用的:

  • admin
  • auth
  • contenttypes
  • sessions
  • messages
  • staticfiles

一般的经验法则是使用应用名称的复数形式:postspaymentsusers 等。唯一的例外是当使用复数形式明显错误时,比如 blogs,这时使用单数形式 blog 更合理。

先创建新的 articles 应用。

(.venv) $ python manage.py startapp articles

然后将其添加到 INSTALLED_APPS 中,并在 settings 中更新时区 TIME_ZONE(较低的位置),因为我们需要为文章添加时间戳。你可以在这个 Wikipedia 列表中找到你的时区(https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)。例如,我住在马萨诸塞州波士顿,属于美国东部时区,因此我的时区设置为 America/New_York

# django_project/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # 3rd Party
    "crispy_forms",
    "crispy_bootstrap5",
    # Local
    "accounts",
    "pages",
    "articles",  # new
]

TIME_ZONE = "America/New_York"  # new

接下来,定义我们的数据库模型,包含四个字段:titlebodydateauthor。我们让 Django 根据 TIME_ZONE 设置自动设置时间和日期。对于 author 字段,我们引用自定义用户模型 "accounts.CustomUser"(已在 django_project/settings.py 中通过 AUTH_USER_MODEL 设置)。我们还将实现最佳实践,定义 get_absolute_url 方法和 __str__ 方法,以便在管理界面中查看模型。

# articles/models.py
from django.conf import settings
from django.db import models
from django.urls import reverse


class Article(models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()
    date = models.DateTimeField(auto_now_add=True)
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )

    def __str__(self):
        return self.title

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

有两种方式引用自定义用户模型:AUTH_USER_MODELget_user_model()https://docs.djangoproject.com/en/5.0/topics/auth/customizing/#django.contrib.auth.get_user_model)。一般建议如下:

  • AUTH_USER_MODEL 适用于 models.py 文件中的引用
  • get_user_model() 推荐在其他地方使用,例如视图、测试等

由于我们有了新的应用和模型,是时候创建迁移文件并将其应用到数据库了。

(.venv) $ python manage.py makemigrations articles
Migrations for 'articles':
  articles/migrations/0001_initial.py
    - Create model Article

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

此时,我喜欢先进入管理后台试用一下模型,然后再构建显示数据所需的 URL/视图/模板。但首先,我们需要更新 articles/admin.py,让我们的新应用显示出来。

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

admin.site.register(Article)

现在启动服务器。

(.venv) $ python manage.py runserver

访问 http://127.0.0.1:8000/admin/ 并登录。

管理页面

如果你点击顶部的”文章”旁边的”+ 添加”,可以输入一些示例数据。你可能有三个可用用户:超级用户、testusertestuser2。使用你的超级用户账户作为作者创建新文章。我已经添加了三篇新文章,如下图所示。

管理后台三篇文章

但如果能在管理后台看到每篇文章的更多信息就更好了,对吧?我们可以通过更新 articles/admin.py 并使用 list_displayhttps://docs.djangoproject.com/en/5.0/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_display)来快速实现。

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


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

admin.site.register(Article, ArticleAdmin)

我们继承了 ModelAdmin(一个代表管理界面中模型的类),并通过 list_display 指定了要列出的字段。在文件底部,我们注册了 ArticleAdmin 以及顶部导入的 Article 模型。Django 管理后台还有许多可用的自定义选项,所以官方文档值得仔细阅读。

带描述的三篇文章

如果你点击某篇文章,会看到标题、正文和作者都已显示,但日期没有显示——尽管我们在模型中定义了 date 字段。这是因为日期是由 Django 自动添加的,因此在管理后台中无法更改。我们可以让日期可编辑——在更复杂的应用中,通常同时包含 created_atupdated_at 属性——但为简单起见,我们暂时让 Django 在创建时自动设置日期。虽然日期在这里没有显示,但我们仍然可以在模板中访问它,用于在网页上显示。

URL 和视图

下一步是配置 URL 和视图。让我们的文章出现在 articles/ 路径下。在 django_project/urls.py 文件中添加文章的 URL 模式。

# django_project/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("accounts/", include("accounts.urls")),
    path("accounts/", include("django.contrib.auth.urls")),
    path("articles/", include("articles.urls")),  # new
    path("", include("pages.urls")),
]

接下来,在文本编辑器中创建一个新的 articles/urls.py 文件,并填入路由。让我们从 articles/ 路径下的文章列表页面开始,它将使用 ArticleListView 视图。

# articles/urls.py
from django.urls import path
from .views import ArticleListView

urlpatterns = [
    path("", ArticleListView.as_view(), name="article_list"),
]

现在使用 Django 内置的通用 ListView 创建视图。我们只需要指定两个属性:模型 Article 和模板名称 article_list.html

# articles/views.py
from django.views.generic import ListView
from .models import Article


class ArticleListView(ListView):
    model = Article
    template_name = "article_list.html"

最后一步是在文本编辑器中创建一个名为 templates/article_list.html 的新模板文件。Bootstrap 有一个名为卡片https://getbootstrap.com/docs/5.3/components/card/)的内置组件,我们可以为每篇文章定制它。回想一下,ListView 返回一个名为 <model_name>_list 的对象,我们可以使用 for 循环对其进行遍历。

我们显示每篇文章的标题、正文、作者和日期。甚至可以提供指向”详情”、“编辑”和”删除”页面的链接——尽管这些页面我们还没有构建。

<!-- templates/article_list.html -->
{% extends "base.html" %}
{% block title %}Articles{% endblock title %}
{% block content %}
{% for article in article_list %}
<div class="card">
  <div class="card-header">
    <span class="fw-bold">
      <a href="#">{{ article.title }}</a>
    </span> &middot;
    <span class="text-muted">by {{ article.author }} |
      {{ article.date }}</span>
  </div>
  <div class="card-body">
    {{ article.body }}
  </div>
  <div class="card-footer text-center text-muted">
    <a href="#">Edit</a> <a href="#">Delete</a>
  </div>
</div>
<br />
{% endfor %}
{% endblock content %}

重新启动服务器,访问 http://127.0.0.1:8000/articles/ 查看效果。

文章页面

不错吧?如果我们想做得更花哨一些,可以创建一个自定义模板过滤器,让日期以秒、分钟或天为单位显示。这可以通过一些 if/else 逻辑和 Django 的日期选项来实现,但我们在这里不进行实现。

如果你想深入了解,可以查看 Django 文档中关于自定义模板过滤器https://docs.djangoproject.com/en/5.0/howto/custom-template-tags/)和日期选项https://docs.djangoproject.com/en/5.0/ref/templates/builtins/#date)的详细说明。

详情/编辑/删除

下一步是添加文章的详情、编辑和删除功能。这意味着需要新的 URL、视图和模板。先从 URL 开始。Django ORM 会自动为每个数据库条目添加一个主键,这意味着第一篇文章的 pk 值为 1,第二篇为 2,依此类推。我们可以利用这一点来构建 URL 路径。

对于详情页面,我们希望路由为 articles/<int:pk>。这里的 int 是一个路径转换器https://docs.djangoproject.com/en/5.0/topics/http/urls/#path-converters),告诉 Django 我们希望将这个值视为整数,而不是字符串等其他数据类型。因此,第一篇文章的 URL 路由将是 articles/1/。由于我们在 articles 应用中,所有 URL 路由都会以 articles/ 为前缀(因为我们在 django_project/urls.py 中设置了这一点)。我们只需要在这里添加 <int:pk> 部分。

接下来是编辑和删除路由,它们也使用主键。它们的 URL 路由分别是 articles/1/edit/articles/1/delete/(主键为 1 时)。以下是更新后的 articles/urls.py 文件。

# articles/urls.py
from django.urls import path
from .views import (
    ArticleListView,
    ArticleDetailView,    # new
    ArticleUpdateView,    # new
    ArticleDeleteView,    # new
)

urlpatterns = [
    path("<int:pk>/", ArticleDetailView.as_view(),
         name="article_detail"),                      # new
    path("<int:pk>/edit/", ArticleUpdateView.as_view(),
         name="article_edit"),                        # new
    path("<int:pk>/delete/", ArticleDeleteView.as_view(),
         name="article_delete"),                      # new
    path("", ArticleListView.as_view(),
         name="article_list"),
]

我们将使用 Django 的通用类视图:DetailViewUpdateViewDeleteView。详情视图只需要指定模型和模板名称。更新/编辑视图还需要添加可以修改的具体属性(titlebody)。对于删除视图,我们必须添加一个重定向,指定删除条目后将用户发送到何处。这需要导入 reverse_lazy 并指定 success_url 及对应的命名 URL。

# articles/views.py
from django.views.generic import ListView, DetailView               # new
from django.views.generic.edit import UpdateView, DeleteView         # new
from django.urls import reverse_lazy                                 # new
from .models import Article


class ArticleListView(ListView):
    model = Article
    template_name = "article_list.html"


class ArticleDetailView(DetailView):                                 # new
    model = Article
    template_name = "article_detail.html"


class ArticleUpdateView(UpdateView):                                 # new
    model = Article
    fields = (
        "title",
        "body",
    )
    template_name = "article_edit.html"


class ArticleDeleteView(DeleteView):                                 # new
    model = Article
    template_name = "article_delete.html"
    success_url = reverse_lazy("article_list")

如果你还记得 CRUD(创建-读取-更新-删除)这个缩写,你会发现我们在这里实现了四种功能中的三种。我们将在本章后面添加第四种——创建功能。几乎所有网站都使用 CRUD,在使用 Django 或其他任何 Web 框架时,这种模式很快就会变得很自然。

URL 路径和视图已经完成,最后一步是添加模板。在你的文本编辑器中创建三个新的模板文件:

  • templates/article_detail.html
  • templates/article_edit.html
  • templates/article_delete.html

先从详情页面开始,它显示标题、日期、正文和作者,并提供编辑和删除的链接。它还提供了返回所有文章的链接。DetailView 会自动将上下文对象命名为 object 或模型名称的小写形式。回想一下,Django 模板语言的 url 标签需要 URL 名称,任何参数都会依次传入。

编辑路由的名称是 article_edit,需要传入主键 article.pk。删除路由的名称是 article_delete,同样需要主键 article.pk。文章页面是一个 ListView,因此不需要传入任何额外参数。

<!-- 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>
<div>
  <p><a href="{% url 'article_edit' article.pk %}">Edit</a>
    <a href="{% url 'article_delete' article.pk %}">Delete</a>
  </p>
  <p>Back to <a href="{% url 'article_list' %}">All Articles</a>.</p>
</div>
{% endblock content %}

对于编辑和删除页面,我们可以使用 Bootstrap 的按钮样式https://getbootstrap.com/docs/5.3/components/buttons/),让编辑按钮显示浅蓝色,删除按钮显示红色。

<!-- templates/article_edit.html -->
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<h1>Edit</h1>
<form action="" method="post">{% csrf_token %}
  {{ form|crispy }}
  <button class="btn btn-info ms-2" type="submit">Update</button>
</form>
{% endblock content %}
<!-- templates/article_delete.html -->
{% extends "base.html" %}
{% block content %}
<h1>Delete</h1>
<form action="" method="post">{% csrf_token %}
  <p>Are you sure you want to delete "{{ article.title }}"?</p>
  <button class="btn btn-danger ms-2" type="submit">Confirm</button>
</form>
{% endblock content %}

最后一步,在 article_list.html 中,我们可以为详情、编辑和删除页面添加 URL 路由,替换掉现有的 <a href="#"> 占位符。我们可以使用模型中定义的 get_absolute_url 方法作为详情页面的链接。对于编辑和删除链接,我们使用 urlhttps://docs.djangoproject.com/en/5.0/ref/templates/builtins/#url)模板标签、URL 名称和每篇文章的 pk

<!-- templates/article_list.html -->
{% extends "base.html" %}
{% block title %}Articles{% endblock title %}
{% block content %}
{% for article in article_list %}
<div class="card">
  <div class="card-header">
    <span class="fw-bold">
      <!-- add link here! -->
      <a href="{{ article.get_absolute_url }}">{{ article.title }}</a>
    </span> &middot;
    <span class="text-muted">by {{ article.author }} |
      {{ article.date }}</span>
  </div>
  <div class="card-body">
    {{ article.body }}
  </div>
  <div class="card-footer text-center text-muted">
    <!-- new links here! -->
    <a href="{% url 'article_edit' article.pk %}">Edit</a>
    <a href="{% url 'article_delete' article.pk %}">Delete</a>
  </div>
</div>
<br />
{% endfor %}
{% endblock content %}

好了,我们准备查看成果了。使用 python manage.py runserver 启动服务器,访问文章列表页面 http://127.0.0.1:8000/articles/。点击第一篇文章旁边的”编辑”链接,你将被重定向到 http://127.0.0.1:8000/articles/1/edit/

编辑页面

如果你在标题末尾添加”(edited)“并点击”更新”,你将被重定向到详情页面,显示新的更改。

详情页面

如果你点击”删除”链接,你将被重定向到删除页面。

删除页面

点击可怕的红色”确认”按钮。你将被重定向到文章页面,现在只剩下两篇文章。

两篇文章页面

创建页面

最后一步是创建一个用于发布新文章的页面,我们可以使用 Django 内置的 CreateView 来实现。我们的三个步骤是创建视图、URL 和模板。这个流程现在应该已经很熟悉了。

articles/views.py 文件中,将 CreateView 添加到顶部的导入中,并在文件底部创建一个名为 ArticleCreateView 的新类,指定模型、模板和可用字段。

# articles/views.py
...
from django.views.generic.edit import (
    UpdateView, DeleteView, CreateView  # new
)
...

class ArticleCreateView(CreateView):             # new
    model = Article
    template_name = "article_new.html"
    fields = (
        "title",
        "body",
        "author",
    )

注意,fields 属性中包含 author,因为我们要将新文章与作者关联;但是,一旦文章创建完成,我们不希望用户能够更改作者,这就是为什么 ArticleUpdateView 只有 ['title', 'body'] 属性。

现在,用新路由更新 articles/urls.py 文件。

# articles/urls.py
from django.urls import path
from .views import (
    ArticleListView,
    ArticleDetailView,
    ArticleUpdateView,
    ArticleDeleteView,
    ArticleCreateView,  # new
)

urlpatterns = [
    path("<int:pk>/",
         ArticleDetailView.as_view(), name="article_detail"),
    path("<int:pk>/edit/",
         ArticleUpdateView.as_view(), name="article_edit"),
    path("<int:pk>/delete/",
         ArticleDeleteView.as_view(), name="article_delete"),
    path("new/", ArticleCreateView.as_view(), name="article_new"),  # new
    path("", ArticleListView.as_view(), name="article_list"),
]

为了完成新的创建功能,添加一个名为 templates/article_new.html 的模板,并用以下 HTML 代码更新它。

<!-- templates/article_new.html -->
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<h1>New article</h1>
<form action="" method="post">{% csrf_token %}
  {{ form|crispy }}
  <button class="btn btn-success ms-2" type="submit">Save</button>
</form>
{% endblock content %}

额外链接

我们应该在导航栏中添加创建新文章的 URL 链接,以便登录用户可以在站点的任何位置访问它。

<!-- templates/base.html -->
...
{% if user.is_authenticated %}
<li><a href="{% url 'article_new' %}"
   class="nav-link px-2 link-dark">+ New</a></li>
...

刷新网页并点击”+ New”将重定向到创建新文章页面。

新文章页面

最后一个需要添加的链接是让文章列表页面可以从首页访问。用户需要知道或猜测其地址是 http://127.0.0.1:8000/articles/,但我们可以通过在 templates/home.html 文件中添加一个按钮链接来解决这个问题。

<!-- templates/home.html -->
{% extends "base.html" %}
{% block title %}Home{% endblock title %}
{% block content %}
{% if user.is_authenticated %}
Hi {{ user.username }}! You are {{ user.age }} years old.
<form action="{% url 'logout' %}" method="post">
  {% csrf_token %}
  <button type="submit">Log Out</button>
</form>
<br/>
<p><a class="btn btn-primary" href="{% url 'article_list' %}"
      role="button">View all articles</a></p>  <!-- new -->
{% else %}
<p>You are not logged in</p>
<a href="{% url 'login' %}">Log In</a> |
<a href="{% url 'signup' %}">Sign Up</a>
{% endif %}
{% endblock content %}

刷新首页,按钮将会出现并按预期工作。

带所有文章链接的首页

如果你需要确认你的 HTML 文件是否准确,请参考官方源代码https://github.com/wsvincent/djangoforbeginners/)。你也可以查看完整的代码https://github.com/wsvincent/djangoforbeginners/)。

Git

本章添加了相当多的新代码,所以在进入下一章之前,让我们用 Git 保存进度。

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

小结

我们现在已经创建了一个专用的文章应用,具备 CRUD 功能。文章可以被创建、读取、更新、删除,甚至以列表形式查看。但目前还没有任何权限或授权机制,这意味着任何人都可以做任何事情!如果未登录的用户知道正确的 URL,他们就可以编辑或删除现有文章——即使那不是他们的文章!在下一章中,我们将为项目添加权限和授权功能来解决这个问题。