跳转至

第 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

关于应用名称,一般最佳实践是使用复数形式,如 pages 或 posts,除非你的应用名称作为复数没有意义,比如 blog,所以我们在这里使用单数形式。为确保 Django 知道我们的新应用,打开你的文本编辑器,将新应用添加到 django_project/settings.py 文件中的 INSTALLED_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",  # new
]

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

(.venv) $ python manage.py runserver

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

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

博客帖子模型

在我们编写 Django 模型的代码之前,让我们花一点时间来可视化我们希望数据库中信息的结构方式。在我们之前的示例留言板应用中,我们只有一个内容字段。在这里,我们想要一个名为 Post 的数据库表,包含三个字段:Title(标题)、Author(作者)和 Body(正文)。带有列和行的实际数据库表看起来像这样:

Post Database Table
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 模块,然后创建了一个扩展它的类 Post。Post 类有三个字段(可以将它们视为列):title、author 和 body。每个字段必须有适当的字段类型。前两个使用 CharField,表示最大字符长度为 200 的字符字段,而第三个使用 TextField,用于大量文本。

添加 __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 管理界面并向博客帖子模型添加数据。然而,在构建博客应用的其余部分之前,还有两个更重要的概念——主键和外键——必须先介绍。

由于关系型数据库中的表之间存在关系,因此需要一种简单的方式让它们进行通信。解决方案是添加一个列——称为主键——包含唯一值。当两个表之间存在关系时,主键就是维持一致关系的链接。如果我们回顾简单的 Post 模式,它应该包含另一个"Primary Key"字段:

Post Schema
Post
--------
primary_key
title
author
body

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

Post Schema
Post
--------
post_id
title
author
body

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

Post Database Table
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 user 模型,它附带了各种字段。如果我们现在可视化 Post 和 User 模型的模式,它看起来像这样:

Post and User Schema
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 字段来实现。这样的链接称为外键关系。一个表中的外键始终对应于另一个表的主键。因此,在我们的博客应用中为作者和用户建立外键关系意味着 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(  # new
        "auth.User",
        on_delete=models.CASCADE,
    )
    body = models.TextField()

    def __str__(self):
        return self.title

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 和我们创建的扩展 ModelAdmin 类 PostAdmin。

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

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

admin.site.register(Post, PostAdmin)  # new

如果你刷新管理界面更改列表页面,所有三个模型字段都将是可见的。

包含所有字段的管理界面更改列表

现在我们的数据库模型已经完成,我们必须创建必要的视图、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,使用默认模型管理器名称 objects 和内置的 all() 方法使其等于包含数据库中所有 Post 对象的 QuerySet。最后,我们使用 render 返回请求对象,指定模板,并添加一个名为 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  # new

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

我们在第二行添加了 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"],  # new
        ...
    },
]

并按如下方式更新 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

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

包含两个帖子的博客主页

但它看起来很糟糕。让我们通过添加一些样式来修复它。

静态文件

静态文件是 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"]  # new

接下来,在 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  # new
from .models import Post

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

def post_detail(request, pk):  # new
    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>/ 处添加一个 URL 路由。

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

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

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

最后一步是在你的文本编辑器中添加一个名为 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 已经在我们的 post 对象上创建并包含了这个 pk 字段,但我们必须通过将其作为 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  # new

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):  # new
        return reverse("post_detail", kwargs={"pk": self.pk})

在顶部,我们导入了 reverse(),一个用于 URL 的工具函数。然后,我们定义 get_absolute_url,使用 self 作为第一个参数,引用调用该方法的模型实例。这是 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> <!-- new -->
        <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,然后添加了 TestCase 和 Post 模型。我们的类 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 存在并返回 200 状态码
  • URL 名称有效并返回 200 状态码
  • 使用了正确的模板名称
  • 输出了正确的模板内容

所有四个测试都需要包含在内。我们可以有八个新的单元测试,每个页面四个,或者我们可以将它们合并一些。这里没有对错之分,只要实施了测试来测试功能,并且从它们的名称中可以清楚地看出如果出错时哪里出了问题。

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

# blog/tests.py
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse  # new
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):  # new
        response = self.client.get("/")
        self.assertEqual(response.status_code, 200)

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

    def test_post_listview(self):  # new
        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):  # new
        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,我们必须将测试帖子的 pk 传递给响应。使用了相同的模板,我们添加了我们不想看到的内容的新测试。例如,我们不希望在 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 相同的顶层目录中——并添加这三行。

# .gitignore
.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 管理界面来进行这些更改。