第 6 章:博客网站

第 6 章:博客网站

在本章中,我们将开始构建一个博客应用,允许用户阅读、创建、编辑和删除文章。这种 CRUD(创建-读取-更新-删除)功能是大多数网站的主导模式。想想 Facebook、Instagram 或 Reddit,你所做的不过是阅读帖子,有时创建、编辑或删除它们。这就是我们将在博客应用中实现的内容——一个列出所有文章的首页,以及每个文章的独立页面。我们还将引入 CSS 进行样式美化,学习静态文件,并编写更高级的测试来确保一切按预期工作。

初始设置

这个项目的设置与本书前面的示例类似:

  • 为我们的代码创建一个名为 blog 的新目录
  • 在新的虚拟环境 .venv 中安装 Django
  • 创建一个名为 django_project 的新 Django 项目
  • 创建一个名为 blog 的新应用
  • 执行迁移来设置数据库
  • 更新 django_project/settings.py

现在在命令行终端中来实现它们。首先创建新目录,添加新的虚拟环境并激活它。

# Windows
$ cd onedrive\desktop\code
$ mkdir blog
$ cd blog
$ python -m venv .venv
$ .venv\Scripts\Activate.ps1
(.venv) $

# macOS
$ cd ~/desktop/code
$ mkdir blog
$ cd blog
$ python3 -m venv .venv
$ source .venv/bin/activate
(.venv) $

然后,安装 Django 和 Black,创建一个名为 django_project 的新项目,创建一个名为 blog 的新应用,并迁移初始数据库。

(.venv) $ python -m pip install django~=5.0.0
(.venv) $ python -m pip install black
(.venv) $ django-admin startproject django_project .
(.venv) $ python manage.py startapp blog
(.venv) $ python manage.py migrate

关于应用名称,通常的最佳实践是使用复数形式,如 pagesposts,除非应用名称作为复数形式不合理,比如 blog,所以我们在这里使用单数形式。为了确保 Django 知道我们的新应用,打开文本编辑器并将新应用添加到 django_project/settings.pyINSTALLED_APPS 中:

# django_project/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "blog",  # 新增
]

使用 runserver 命令启动本地服务器。

(.venv) $ python manage.py runserver

如果你在浏览器中导航到 http://127.0.0.1:8000/,应该会看到友好的 Django 欢迎页面。

初始安装完成了!接下来,我们将进一步了解数据库和 Django 的 ORM,然后为博客应用创建数据库模型。

博客文章模型

在编写 Django 模型的代码之前,让我们花点时间想象一下我们希望数据库中的信息如何结构化。在我们之前的示例(留言板应用)中,我们只有一个 content 字段。在这里,我们想要一个名为 Post 的数据库表,包含三个字段:Title(标题)、Author(作者)和 Body(正文)。实际的数据库表包含列和行,应该如下所示:

Post 数据库表
Post
--------
TITLE       AUTHOR  BODY
Hello, World!  wsv    My first blog post. Woohoo!
Goals Today    wsv    Learn Django and build a blog application.
3rd Post       wsv    This is my 3rd entry.

记住,models.py 文件是关于数据的唯一、决定性信息来源,包含存储数据的必要字段和行为。我们可以在 models.py 文件中编写 Python 代码,Django ORM 会将其转换为 SQL。在 blog/models.py 中编写以下代码:

# blog/models.py
from django.db import models

class Post(models.Model):
    title = models.CharField(max_length=200)
    author = models.CharField(max_length=200)
    body = models.TextField()

    def __str__(self):
        return self.title

在文件顶部,我们导入 models 模块,然后创建一个继承它的类 PostPost 类有三个字段(可以把它们看作列):titleauthorbody。每个字段必须有一个适当的字段类型。前两个使用 CharField,表示最大字符长度为 200 的字符字段,第三个使用 TextField,用于大量文本。有关完整的字段类型列表,请参阅 Django 文档中的 field type

添加 __str__ 方法在技术上不是必需的,但正如我们在上一章中看到的,它是一个最佳实践,可以确保我们的模型对象在 Django 管理后台中具有人类可读的表现形式。在这里,它将显示任何博客文章的 title 字段。

现在我们的新数据库模型已经存在,我们需要创建一个新的迁移文件并执行迁移来将其应用到数据库。按 Control + c 停止服务器。你可以通过以下命令完成这个两步过程:

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

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

数据库现已配置完成,blog 应用目录下包含一个记录了我们更改的新 migrations 目录。

主键和外键

我们可以直接跳转到 Django 管理后台向博客文章模型添加数据。然而,在构建博客应用的其余部分之前,还有两个重要概念需要介绍——主键和外键。

由于关系型数据库在不同表之间有关系,它们需要一种简单的方式进行通信。解决方案是添加一个包含唯一值的列——称为主键(primary key)。当两个表之间存在关系时,主键就是链接,维持着一致的关系。如果我们回头看简单的 Post 模式,它应该包含另一个”主键”字段:

Post 模式
Post
--------
primary_key
title
author
body

主键是关系型数据库设计的标准组成部分。因此,Django 会自动为我们的数据库模型添加一个自增的主键。它的值从 1 开始,然后依次递增到 2、3 等。命名约定是 <table>_id,这意味着对于 Post 模型,主键列名为 post_id

Post 模式
Post
--------
post_id
title
author
body

因此,在底层,我们现有的 Post 数据库表有四个列/字段。

Post 数据库表
Post
--------
POST_ID  TITLE           AUTHOR  BODY
1        Hello, World!   wsv     My first blog post. Woohoo!
2        Goals Today     wsv     Learn Django and build a blog application.
3        3rd Post        wsv     This is my 3rd entry.

现在我们已经了解了主键,接下来看看它们是如何用于链接表的。当有多个表时,每个表都会包含一个从 1 开始依次递增的主键列,就像我们的 Post 模型示例一样。在我们的博客模型中,我们有一个 author 字段,但在实际的博客应用中,我们希望用户能够登录并创建博客文章。这意味着我们需要第二个表来链接用户和我们现有的博客文章表。幸运的是,用户认证是一个常见——且难以实现好——的网站功能,Django 拥有完整的、内置的用户认证系统可供我们使用。在稍后的章节中,我们将使用它来添加注册、登录、注销、密码重置等功能。但现在,我们可以使用 Django 的 auth 用户模型,它自带各种字段。如果我们现在可视化 PostUser 模型的模式,它看起来是这样的:

Post 和 User 模式
Post           User
--------       --------
post_id        user_id
title          username
author         first_name
body           last_name
               email
               password
               groups
               user_permissions
               is_staff
               is_active
               is_superuser
               last_login
               date_joined

我们如何将这两个表链接在一起,使它们之间建立关系?我们希望 Post 中的 author 字段链接到 User 模型,这样每篇文章都有一个对应用户的作者。我们可以通过将 User 模型的主键 user_id 链接到 Post.author 字段来实现。这种链接称为外键(foreign key)关系。一个表中的外键始终对应另一个不同表的主键。因此,在我们的博客应用中为作者和用户建立外键关系意味着,Post 模型的 author 字段将包含对应作者在 User 模型中的主键。在我们的示例中,wsv(其在 User 模型中的主键是 1)创建了三篇文章,所以同样的主键 1 被列为 Post 模型中三篇文章的 Author 列的外键。

下面是在代码中的实现。我们只需要更改 Post 模型中的 author 字段。

# blog/models.py
from django.db import models

class Post(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(  # 新增
        "auth.User",
        on_delete=models.CASCADE,
    )
    body = models.TextField()

    def __str__(self):
        return self.title

[ForeignKey](https://docs.djangoproject.com/en/5.0/ref/models/fields/#django.db.models.ForeignKey) 字段默认为多对一关系,意味着一个用户可以是多篇不同博客文章的作者,但反过来不成立。

值得说明的是,有三种类型的外键关系:多对一、多对多和一对一。多对一关系,正如我们在 Post 模型中的那样,是最常见的。多对多关系会存在于跟踪作者和书籍的数据库中:每个作者可以写多本书,每本书也可以有多个作者。一对一关系会存在于跟踪人和护照的数据库中:一个人只能有一本护照。

注意,当被 ForeignKey 引用的对象被删除时,必须设置额外的 on_delete 参数。完全理解 on_delete 是一个高级主题,但选择 CASCADE 通常是安全的,正如我们这里所做的那样。

由于我们再次更新了数据库模型,我们应该创建新的迁移文件,然后迁移数据库以应用更改。

(.venv) $ python manage.py makemigrations blog
Migrations for 'blog':
  blog/migrations/0002_alter_post_author.py
    - Alter field author on post

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

第二个迁移文件现在会出现在 blog/migrations 目录中,记录了这个更改。

管理后台

我们需要一种访问数据的方式:Django 管理后台登场!首先,输入以下命令创建一个超级用户账户,并根据提示设置用户名、电子邮件和密码。注意,出于安全原因,输入密码时不会在屏幕上显示。

(.venv) $ python manage.py createsuperuser
Username (leave blank to use 'wsv'): wsv
Email:
Password:
Password (again):
Superuser created successfully.

现在使用 python manage.py runserver 重新运行 Django 服务器,并导航到管理后台 http://127.0.0.1:8000/admin/。使用新的超级用户账户登录。

哎呀!我们的新 Post 模型在哪里?我们忘记更新 blog/admin.py 了,现在来更新它。

# blog/admin.py
from django.contrib import admin
from .models import Post

admin.site.register(Post)

如果你刷新页面,你会看到更新后的效果。

管理后台首页

让我们添加两篇博客文章,这样我们就有一些样本数据了。点击 Posts 旁边的 “+ Add” 按钮来创建新条目。确保为每篇文章添加”author”(作者),因为所有模型字段默认都是必填的。

如果你尝试输入没有作者的文章,将会看到错误。要改变这一点,我们可以向模型添加字段选项,使给定字段变为可选的,或默认为指定的值。

管理后台包含两篇文章的变更列表

虽然我们的模型有三个字段,但 Django 管理后台默认在列表视图中显示 __str__ 方法中的内容。在我们的例子中,那就是 title 字段。不过,进一步定制管理后台以显示所有三个字段是非常简单的。

为此,我们可以通过创建一个新的 PostAdmin 类来扩展 ModelAdmin。在其中,我们可以设置 list_display 来控制管理后台变更列表页面上显示的内容。我们还必须在文件底部同时注册模型 Post 和我们创建的扩展 ModelAdminPostAdmin

# blog/admin.py
from django.contrib import admin
from .models import Post

class PostAdmin(admin.ModelAdmin):  # 新增
    list_display = (  # 新增
        "title",
        "author",
        "body",
    )

admin.site.register(Post, PostAdmin)  # 新增

如果你刷新管理后台的变更列表页面,所有三个模型字段都会可见。

显示所有字段的管理后台变更列表

现在我们的数据库模型已经完成,我们需要创建必要的视图、URL 和模板来在 Web 应用中显示信息。

视图

我们的视图需要列出所有可用的博客文章。我们可以将其编写为基于函数的视图;代码与我们在上一章中用于留言板的完全相同。在 Web 开发中,我们经常重复执行类似的任务。这正是通用基于类视图被开发出来的原因!

# blog/views.py
from django.shortcuts import render
from .models import Post

def post_list(request):
    posts = Post.objects.all()
    return render(request, "home.html", {"posts": posts})

简要回顾一下,我们在文件顶部导入了 render() 快捷函数和 Post 模型。然后我们创建了一个函数 post_list。第一个参数按照惯例命名为 request,代表触发该视图的 HttpRequest 实例。然后,我们设置一个变量 posts,它等于一个包含数据库中所有 Post 对象的 QuerySet,使用默认的模型管理器名称 objects 和内置的 all() 方法。最后,我们使用 render 返回 request 对象,指定模板,并添加一个上下文字典 posts,其值等于包含所有博客文章的变量 posts

URL

我们希望将博客文章显示在首页上,因此我们将首先配置应用级别的 blog/urls.py 文件,然后配置项目级别的 django_project/urls.py 文件来实现这一点。在文本编辑器中,在 blog 应用内创建一个名为 urls.py 的新文件,并更新为以下代码:

# blog/urls.py
from django.urls import path
from .views import post_list

urlpatterns = [
    path("", post_list, name="home"),
]

在顶部,我们导入 path 模块和我们的视图 post_list。然后我们设置一个路由,使用空字符串 "",它匹配我们网站的根 URL。我们传入视图 post_list 作为第二个参数,然后添加一个可选的名称”home”,稍后将在模板中派上用场。

我们还应该更新 django_project/urls.py 文件,让它知道将所有请求直接转发到 blog 应用。

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

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("blog.urls")),  # 新增
]

我们在第二行添加了 include,并使用一个空字符串正则表达式 "" 的 URL 模式,表明 URL 请求应按原样重定向到 blog 的 URL 进行进一步处理。

模板

URL 和视图已经完成,现在只缺第三块拼图:模板。让我们使用模板继承来避免重复代码,先创建一个 base.html 文件,再创建一个继承它的 home.html 文件。稍后,当我们添加用于创建和编辑博客文章的模板时,它们也可以从 base.html 继承。

首先添加新的 templates 目录。

(.venv) $ mkdir templates

在文本编辑器中创建两个新模板文件:templates/base.htmltemplates/home.html。然后更新 django_project/settings.py,让 Django 知道在那里查找模板。

# django_project/settings.py
TEMPLATES = [
    {
        ...
        "DIRS": [BASE_DIR / "templates"],  # 新增
        ...
    },
]

然后按如下方式更新 base.html 模板:

<!-- templates/base.html -->
<html>
<head>
    <title>Django blog</title>
</head>
<body>
    <header>
        <h1><a href="{% url 'home' %}">Django blog</a></h1>
    </header>
    <div>
        {% block content %}
        {% endblock content %}
    </div>
</body>
</html>

链接到 url 'home' 意味着我们期望一个名为”home”的 URL 来驱动我们的首页。{% block content %}{% endblock content %} 之间的代码设计为被其他模板填充。说到这里,下面是 home.html 的代码:

<!-- templates/home.html -->
{% extends "base.html" %}

{% block content %}
{% for post in posts %}
<div class="post-entry">
    <h2><a href="">{{ post.title }}</a></h2>
    <p>Author: {{ post.author }}</p>
    <p>{{ post.body }}</p>
</div>
{% endfor %}
{% endblock content %}

在顶部,我们注明该模板扩展了 base.html,然后将我们想要的代码包裹在 content 块中。我们使用 Django 模板语言设置了一个简单的 for 循环来遍历每篇博客文章。我们循环遍历上下文字典 posts(我们在视图中定义的),并将每个项目命名为 post。然后,我们可以使用点号表示法显示 post.titlepost.authorpost.body 等字段。

如果再次启动 Django 服务器(python manage.py runserver)并刷新首页,我们可以看到它正在工作。

包含两篇博客文章的首页

但看起来太简陋了。让我们通过添加一些样式来改进它。

静态文件

静态文件是 Django 社区对网站上常见的额外文件的术语,如 CSS、字体、图片和 JavaScript。尽管我们还没有向项目中添加任何静态文件,但我们已经在依赖 Django 核心的静态文件——自定义 CSS、字体、图片和 JavaScript——来驱动 Django 管理后台的外观和感觉。

在生产环境中,情况更复杂,我们将在本书的部署部分进行适当介绍。需要理解的核心概念是,在生产环境中将 Django 项目中的所有静态文件合并到一个单一位置要高效得多。如果你查看现有的 django_project/settings.py 文件底部,已经有一个 STATIC_URL 的配置,它指向生产环境中所有静态文件的 URL 位置。换句话说,如果我们网站有 URL example.com,所有静态文件将在 example.com/static/ 上可用。

# django_project/settings.py
STATIC_URL = "static/"

对于本地开发,我们不必担心静态文件,因为通过 runserver 命令运行的 Web 服务器会自动找到并提供它们。

添加新的静态文件时的第一个问题是放在哪里。默认情况下,Django 会在每个应用中查找名为”static”的文件夹;也就是说,一个名为 blog/static/ 的文件夹。如果你还记得,这与模板的处理方式类似。

随着 Django 项目随着时间的推移变得复杂并拥有多个应用,将静态文件存储在单一的、项目级别的目录中通常更易于管理。这就是我们将采用的方法。

Control + c 退出本地服务器,在与 manage.py 相同的文件夹中创建一个新的 static 目录。

(.venv) $ mkdir static

STATICFILES_DIRS 定义了内置的 staticfiles 应用在 app/static/ 文件夹之外还应该搜索的额外位置。我们需要将项目级别的 static 文件夹添加到此配置中。

# django_project/settings.py
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]  # 新增

接下来,在 static 中创建一个 css 目录。

(.venv) $ mkdir static/css

在文本编辑器中,在此目录下添加一个名为 static/css/base.css 的新文件。我们应该在文件中放什么呢?不如把标题变成红色?

/* static/css/base.css */
header h1 a {
    color: red;
}

最后一步是将静态文件添加到我们模板中,在 base.html 顶部添加 {% load static %}。因为我们的其他模板继承自 base.html,我们只需要添加一次。在 <head></head> 代码底部添加一行明确引用我们新的 base.css 文件的代码。

<!-- templates/base.html -->
{% load static %}
<html>
<head>
    <title>Django blog</title>
    <link rel="stylesheet" href="{% static 'css/base.css' %}">
</head>
...

呼!虽然有点麻烦,但这是一次性的工作。我们可以向 static 目录添加静态文件,它们会自动出现在我们所有的模板中。

再次使用 python manage.py runserver 启动服务器,看看更新后的首页 http://127.0.0.1:8000/

红色标题的博客首页

如果你看到错误 TemplateSyntaxError at /,说明你忘记在顶部添加 {% load static %} 行了。即使我使用 Django 多年,仍然会经常犯这个错误!幸运的是,Django 的错误信息会提示:“Invalid block tag on line 4: ‘static’. Did you forget to register or load this tag?”。非常准确地描述了问题,不是吗?

即使有了这个新的样式,我们还可以做得更好一些。让我们添加一个自定义字体和更多 CSS。由于本书不是关于 CSS 的,我们可以在 <head></head> 标签之间插入以下内容来添加 Source Sans Pro(Google 提供的免费字体)。

<!-- templates/base.html -->
{% load static %}
<html>
<head>
    <title>Django blog</title>
    <link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400"
          rel="stylesheet">
    <link href="{% static 'css/base.css' %}" rel="stylesheet">
</head>
...

然后,复制粘贴以下代码来更新我们的 CSS 文件:

/* static/css/base.css */
body {
    font-family: 'Source Sans Pro', sans-serif;
    font-size: 18px;
}

header {
    border-bottom: 1px solid #999;
    margin-bottom: 2rem;
    display: flex;
}

header h1 a {
    color: red;
    text-decoration: none;
}

.nav-left {
    margin-right: auto;
}

.nav-right {
    display: flex;
    padding-top: 2rem;
}

.post-entry {
    margin-bottom: 2rem;
}

.post-entry h2 {
    margin: 0.5rem 0;
}

.post-entry h2 a,
.post-entry h2 a:visited {
    color: blue;
    text-decoration: none;
}

.post-entry p {
    margin: 0;
    font-weight: 400;
}

.post-entry h2 a:hover {
    color: red;
}

刷新首页 http://127.0.0.1:8000/,你应该会看到以下内容:

添加 CSS 后的博客首页

独立博客页面

现在,我们可以添加独立博客页面的功能了。我们需要创建一个新的视图、URL 和模板来实现这一点。希望你已经注意到 Django 开发中的这种模式!

从视图开始。在视图文件的顶部,导入快捷函数 get_object_or_404(),它调用了 get QuerySet 方法来返回一个对象,如果不成功则引发 Http404 错误。

我们将视图函数命名为 post_detail,因为它代表博客文章的详细视图。它接受两个参数:第一个 requestHttpRequest 对象的实例;第二个 pk 是从 URL 中提取的参数,用于通过主键标识要显示的特定博客文章。

让我们花点时间仔细理解最后一句话,因为它涉及理解此函数的核心。要指定一篇独立的博客文章,我们需要一种方法在数据库中的所有博客文章列表中选择这特定的一篇。最简单且默认的方法是使用与每条记录相关联的主键 ID。例如,第一条条目的主键(PK)为 1,第二条为 2,依此类推,由 Django ORM 自动设置。

post_detail 函数体内有两行代码。首先,我们在 Post 模型上调用 get_object_or_404,并指定主键 pk 与 URL 中的参数 pk 匹配。如果我们想要 URL /1,我们的函数将调用主键为 1 的博客文章。当你实际看到它运行时,会更清楚。我们将变量 post 设置为等于这篇特定的博客文章。然后,我们使用 render() 函数返回响应,第一个参数是 request 变量,第二个是模板 post_detail.html,第三个是模板上下文,我们创建了一个与上一行的 post 变量匹配的变量 post

# blog/views.py
from django.shortcuts import render, get_object_or_404  # 新增
from .models import Post

def post_list(request):
    posts = Post.objects.all()
    return render(request, "home.html", {"posts": posts})

def post_detail(request, pk):  # 新增
    post = get_object_or_404(Post, pk=pk)
    return render(request, "post_detail.html", {"post": post})

这种从数据库中访问信息列表或单个项目的模式在 Django Web 开发中反复出现,所以如果你此刻有些困惑,不用担心。

视图创建完成后,接下来的两个步骤是添加 URL 路由和模板。在 blog/urls.py 文件中,导入新视图 post_detail,并创建一个新路由 post/<int:pk>/

这个模式一开始看起来有点奇怪,因为这是我们第一次使用 Django 的路径转换器。到目前为止,我们一直硬编码 URL 路由,但更常见的是使用变量。在这里,我们指定独立文章将从 post/ 开始,然后使用 int 来指定从 URL 中捕获的值应被视为整数,并且传递给视图的变量名是 pk。第二个参数是视图名称 post_detail,我们还添加了可选的第三个参数,名称也是 post_detail

# blog/urls.py
from django.urls import path
from .views import post_list, post_detail  # 新增

urlpatterns = [
    path("post/<int:pk>/", post_detail, name="post_detail"),  # 新增
    path("", post_list, name="home"),
]

最后一步是在文本编辑器中添加一个名为 templates/post_detail.html 的新模板文件。然后输入以下代码:

<!-- templates/post_detail.html -->
{% extends "base.html" %}

{% block content %}
<div class="post-entry">
    <h2>{{ post.title }}</h2>
    <p>{{ post.body }}</p>
</div>
{% endblock content %}

在顶部,我们指定此模板继承自 base.html。模板上下文变量 post 包含这篇特定博客文章的信息,我们可以使用 post.titlepost.body 来显示各个字段。

如果你使用 python manage.py runserver 启动服务器,你将在 http://127.0.0.1:8000/post/1/ 看到第一篇博客文章的专门页面。

第一篇博客文章详情

哦!你也可以访问 http://127.0.0.1:8000/post/2/ 查看第二条目。

第二篇博客文章详情

为了让我们的生活更方便,我们应该更新首页上的链接,以便可以直接从那里访问独立博客文章。将当前的空链接 <a href=""> 替换为 <a href="{% url 'post_detail' post.pk %}">

<!-- templates/home.html -->
{% extends "base.html" %}

{% block content %}
{% for post in posts %}
<div class="post-entry">
    <h2><a href="{% url 'post_detail' post.pk %}">{{ post.title }}</a></h2>
    <p>{{ post.body }}</p>
</div>
{% endfor %}
{% endblock content %}

我们首先使用 Django 的 url 模板标签,并指定 URL 模式名称为 post_detail。如果你查看 URL 文件中的 post_detail,它期望接收一个参数 pk,代表博客文章的主键。幸运的是,Django 已经创建并将这个 pk 字段包含在我们的 post 对象上,但我们必须通过将其作为 post.pk 添加到模板中来传递到 URL 中。

为了确认一切正常,刷新主页面 http://127.0.0.1:8000/,然后点击每篇博客文章的标题来确认新链接是否有效。

get_absolute_url()

目前,我们使用 url 模板标签,这意味着每次我们想在此模板或其他模板中显示一篇独立博客文章时,都必须重复 {% url 'post_detail' post.pk %} 这个模式。如果 URL 模式发生变化,我们需要更新每个构建 URL 的模板,这会增加出错的风险。

更好的方法是使用内置的 get_absolute_url() 方法,它告诉 Django 如何计算模型对象的规范 URL。在 blog/models.py 文件中,添加一个新方法来获取 get_absolute_url

# blog/models.py
from django.db import models
from django.urls import reverse  # 新增

class Post(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(
        "auth.User",
        on_delete=models.CASCADE,
    )
    body = models.TextField()

    def __str__(self):
        return self.title

    def get_absolute_url(self):  # 新增
        return reverse("post_detail", kwargs={"pk": self.pk})

在顶部,我们导入 reverse()——一个用于 URL 的实用函数。然后,我们使用 self 作为第一个参数来定义 get_absolute_url,该参数指代调用该方法的模型实例。这是 Python 中实例方法的标准做法。reverse() 函数接受 URL 名称和关键字参数(或”kwargs”)。在这里,我们将变量 pk 设置为等于模型实例的主键。

我们不需要更新迁移文件,因为我们并没有改变数据库模式。只有在模型更改影响数据库模式时才需要迁移,例如添加或删除字段、更改字段类型或修改模型之间的关系。

在模板文件中,将 href 链接更新为使用 post.get_absolute_url

<!-- templates/home.html -->
{% extends "base.html" %}

{% block content %}
{% for post in posts %}
<div class="post-entry">
    <h2><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2> <!-- 新 -->
    <p>{{ post.body }}</p>
</div>
{% endfor %}
{% endblock content %}

URL 路径在项目的生命周期中可以而且确实会发生变化。使用以前的方法,如果我们更改了文章详情视图和 URL 路径,就必须遍历所有的 HTML 和模板来更新代码,这是一个非常容易出错且难以维护的过程。而使用 get_absolute_url() 替代,我们在 models.py 文件中有一个地方设置了规范 URL,因此我们的模板不必更改。

刷新主页面 http://127.0.0.1:8000/,然后点击每篇博客文章的标题来确认链接仍然按预期工作。

测试

我们的博客项目添加了本章之前没有见过或测试过的新功能。Post 模型有多个字段;我们第一次有了用户;还有列出所有博客文章的列表视图和每篇博客文章的详细视图。需要测试的东西相当多!

首先,我们可以设置测试数据并检查 Post 模型的内容。示例如下:

# blog/tests.py
from django.contrib.auth import get_user_model
from django.test import TestCase
from .models import Post

class BlogTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = get_user_model().objects.create_user(
            username="testuser", email="test@email.com", password="secret"
        )
        cls.post = Post.objects.create(
            title="A good title",
            body="Nice body content",
            author=cls.user,
        )

    def test_post_model(self):
        self.assertEqual(self.post.title, "A good title")
        self.assertEqual(self.post.body, "Nice body content")
        self.assertEqual(self.post.author.username, "testuser")
        self.assertEqual(str(self.post), "A good title")
        self.assertEqual(self.post.get_absolute_url(), "/post/1/")

在顶部,我们导入 get_user_model() 来引用我们的 User,然后添加 TestCasePost 模型。我们的类 BlogTests 包含测试用户和测试文章的设置数据。目前,所有测试都集中在 Post 模型上,所以我们把测试命名为 test_post_model。它检查所有三个模型字段是否返回预期的值。我们的模型还新增了对 __str__get_absolute_url 方法的测试。

继续运行测试。

(.venv) $ python manage.py test
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.088s
OK
Destroying test database for alias 'default'...

还有什么要添加的?我们现在有两种类型的页面:一个列出所有博客文章的首页,和每个博客文章包含其主键在 URL 中的详细页面。在前两章中,我们实现了测试来检查:

  • 预期的 URL 存在并返回 200 状态码
  • URL 名称有效并返回 200 状态码
  • 使用了正确的模板名称
  • 输出了正确的模板内容

所有四个测试都需要包含。我们可以有八个新的单元测试,每个页面四个,或者我们可以稍微组合一下。这里没有完全正确或错误的答案,只要实现了测试来检查功能,并且从它们的名称就能清楚地知道如果出现错误发生了什么,就可以了。

以下是将这些检查添加到我们代码中的一种方式:

# blog/tests.py
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse  # 新增
from .models import Post

class BlogTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = get_user_model().objects.create_user(
            username="testuser", email="test@email.com", password="secret"
        )
        cls.post = Post.objects.create(
            title="A good title",
            body="Nice body content",
            author=cls.user,
        )

    def test_post_model(self):
        self.assertEqual(self.post.title, "A good title")
        self.assertEqual(self.post.body, "Nice body content")
        self.assertEqual(self.post.author.username, "testuser")
        self.assertEqual(str(self.post), "A good title")
        self.assertEqual(self.post.get_absolute_url(), "/post/1/")

    def test_url_exists_at_correct_location_listview(self):  # 新增
        response = self.client.get("/")
        self.assertEqual(response.status_code, 200)

    def test_url_exists_at_correct_location_detailview(self):  # 新增
        response = self.client.get("/post/1/")
        self.assertEqual(response.status_code, 200)

    def test_post_listview(self):  # 新增
        response = self.client.get(reverse("home"))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "Nice body content")
        self.assertTemplateUsed(response, "home.html")

    def test_post_detailview(self):  # 新增
        response = self.client.get(
            reverse("post_detail", kwargs={"pk": self.post.pk})
        )
        no_response = self.client.get("/post/100000/")
        self.assertEqual(response.status_code, 200)
        self.assertEqual(no_response.status_code, 404)
        self.assertContains(response, "A good title")
        self.assertTemplateUsed(response, "post_detail.html")

首先,我们检查两个视图的 URL 是否存在于正确的位置。然后,我们在顶部导入 reverse,并创建 test_post_listview 来确认命名 URL 被使用、返回 200 状态码、包含预期内容以及使用 home.html 模板。

对于 test_post_detailview,我们必须将测试文章的主键传递给响应。使用了相同的模板,并且我们添加了新的测试来检查我们不期望看到的情况。例如,我们不希望在 URL /post/100000/ 处有响应,因为我们还没有创建那么多文章!我们也不希望得到 404 HTTP 状态响应。通过使用 no_response 方法来包含错误测试的示例总是一个好习惯,以确保你的测试不会因为某种原因而盲目通过。

运行新的测试来确认一切正常。

(.venv) $ python manage.py test
Found 5 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.095s
OK
Destroying test database for alias 'default'...

一个常见的测试错误是忘记在 URL 中包含前导斜杠 /。例如,如果在 test_url_exists_at_correct_location_detailview 中检查的响应是 "post/1/",这将抛出 404 错误。然而,如果你检查 "/post/1/",它将返回 200 状态响应。

Git

现在也是我们进行第一次 Git 提交的好时机。初始化我们的目录并通过检查状态来审查所有添加的内容。

(.venv) $ git init
(.venv) $ git status

糟糕,我们不想包含 .venv 目录和 SQLite 数据库!可能还有一个 __pycache__ 目录。要删除这三个,在文本编辑器中创建一个项目级别的 .gitignore 文件——与 manage.py 相同的顶层目录——并添加这三行。

.venv/
__pycache__/
db.sqlite3

再次运行 git status 来确认 .venv 目录不再被包含。然后,添加其余的工作并附上提交信息。

(.venv) $ git status
(.venv) $ git add -A
(.venv) $ git commit -m "initial commit"

结论

我们现在已经从头开始构建了一个基本的博客应用!我们可以使用 Django 管理后台创建、编辑或删除内容,首页会列出所有文章,并且每个文章都有独立的页面。我们第一次了解了静态文件,并使用 CSS 添加了一些样式。我们还学习了在模板中使用 get_absolute_url 的最佳实践,并在测试套件上取得了实质性进展。

在下一节中,我们将转向通用基于类视图,并添加表单来创建、更新和删除博客文章,这样我们就不必使用 Django 管理后台来进行这些更改了。

参考链接