Django 实战:做一个个人博客 · 第 3 / 8 篇(展开目录)

全系列用 Django 5.2 LTS 做一个个人博客:游客看文章,作者登录后写文章,管理员维护分类和账号。Django1—Django8 是文章序号,不是 Django 版本号。

  1. Django1:起步——安装、运行与项目结构
  2. Django2:配置与路由——一个请求在 Django 里怎么走
  3. Django3:模型与迁移——把博客的数据存进数据库
  4. Django4:ORM——查询、更新、统计与事务
  5. Django5:页面——视图、模板、搜索与分页
  6. Django6:表单与后台——让作者在网页上写文章
  7. Django7:登录与权限——谁能改哪篇文章
  8. Django8:上线与维护——测试、部署、备份与发布

下载可运行的示例项目(ZIP)

约 20 分钟 · 本地使用 SQLite

本篇目标: 写出博客的数据模型,用迁移把它变成数据库表,并在 shell 中插入第一篇文章。

完成标志: showmigrations blog 显示 [X] 0001_initial,shell 里能打印出文章地址 /p/first-django-post/。

1. 先写规则,再写字段

设计模型前,先用中文把业务规则写清楚:

  1. 一篇文章有一位作者;一位作者可以写多篇文章。
  2. 一篇文章最多属于一个分类,也可以暂时不分类。
  3. 一篇文章可以有多个标签;一个标签也可以标记多篇文章。
  4. 新文章默认是草稿,游客看不到草稿。

把规则翻译成关系:

User(用户)     1 ──── N  Post(文章)
Category(分类) 1 ──── N  Post(文章,可为空)
Post(文章)     N ──── N  Tag(标签)   ← Django 自动建中间表
数据 字段类型 为什么
标题 CharField 有长度上限
网址标识 SlugField(unique=True) 用在 URL 里,必须唯一
正文 TextField 长文本
作者 ForeignKey 规则 1:多对一
分类 可空的 ForeignKey 规则 2:可以不分类
标签 ManyToManyField 规则 3:多对多
状态 CharField + 枚举 规则 4:只有草稿/已发布两种
时间 DateTimeField 记录创建和修改

2. 完整模型代码

保存到 blog/models.py:

from django.conf import settings
from django.db import models
from django.urls import reverse


class Category(models.Model):
    name = models.CharField("名称", max_length=50, unique=True)

    def __str__(self):
        return self.name


class Tag(models.Model):
    name = models.CharField("名称", max_length=30, unique=True)

    def __str__(self):
        return self.name


class Post(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "草稿"
        PUBLISHED = "published", "已发布"

    title = models.CharField("标题", max_length=200)
    slug = models.SlugField("网址标识", max_length=200, unique=True)
    body = models.TextField("正文")
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
        related_name="posts", verbose_name="作者",
    )
    category = models.ForeignKey(
        Category, on_delete=models.SET_NULL, null=True, blank=True,
        related_name="posts", verbose_name="分类",
    )
    tags = models.ManyToManyField(Tag, blank=True, related_name="posts", verbose_name="标签")
    status = models.CharField("状态", max_length=10, choices=Status.choices, default=Status.DRAFT)
    views = models.PositiveIntegerField("阅读计数", default=0)
    created_at = models.DateTimeField("创建时间", auto_now_add=True)
    updated_at = models.DateTimeField("更新时间", auto_now=True)

    class Meta:
        ordering = ["-created_at", "-pk"]
        indexes = [models.Index(fields=["status", "-created_at", "-id"], name="post_status_created_idx")]
        constraints = [models.CheckConstraint(
            condition=models.Q(status__in=["draft", "published"]),
            name="post_status_valid",
        )]

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("blog:detail", kwargs={"slug": self.slug})

几个值得注意的地方:

  • __str__() 决定对象在后台和调试输出里显示成什么。
  • get_absolute_url() 用路由名生成地址,不在模型里写死 /p/。
  • Meta.ordering 默认按时间倒序;追加 -pk 让同一时间创建的文章顺序也稳定(分页需要)。
  • views(阅读计数) 只用于第 4 篇的并发更新练习,页面不会自动累加它。
  • 每条数据库记录还有一个自增的 id(即 pk)。编辑、删除地址用 pk,公开地址用 slug。

3. 容易混淆的字段参数

参数 管哪一层 本例用法
null=True 数据库:允许存 NULL 分类可以为空
blank=True 表单验证:允许不填 分类、标签可以不选
choices 表单下拉选项 + 模型验证 状态只能选两种
CheckConstraint 数据库约束 即使绕过表单直接写 SQL,状态也不能乱填
unique=True 数据库唯一约束 网址标识不能重复

文本字段一般不设 null=True

字符串的“空”用空字符串 "" 表示即可。同时允许 NULL 和 "",会出现两种“空”,查询时容易漏掉一种。

save() 不会自动做完整验证

表单会帮你调用验证;但在 shell 或脚本里直接 save(),choices、max_length 等规则不会被检查。需要时先调用 obj.full_clean()。数据库约束则始终生效。

4. 删除关联对象会发生什么

on_delete 决定被引用的对象删除时怎么办:

关系 策略 结果
文章 → 作者 PROTECT 还有文章的作者不能删除,会抛 ProtectedError
文章 → 分类 SET_NULL 删除分类后,文章保留,变成“未分类”
文章 ↔ 标签 多对多 删除标签只解除关系,不删文章

related_name="posts" 提供反向查询:user.posts.all() 取某人的全部文章,category.posts.all() 取某分类下的文章。

关联用户时写 settings.AUTH_USER_MODEL 而不是直接导入 User,这样以后换成自定义用户模型也不用改这里(第 7 篇会讨论)。

5. 迁移:把模型变成表

修改 models.py 不会自动改数据库。需要两步:

python manage.py makemigrations blog
python manage.py migrate
命令 作用
makemigrations 对比模型和已有迁移,生成变更文件(如 0001_initial.py)
sqlmigrate blog 0001 查看这个迁移会执行的 SQL
migrate 把还没执行的迁移应用到数据库
showmigrations blog 查看每个迁移是否已执行

完整示例已经自带 0001_initial.py,所以运行 makemigrations 显示 No changes detected 是正常的。执行 migrate 后,showmigrations blog 应显示 [X] 0001_initial。

不要删迁移文件“解决”报错

迁移文件是项目历史,要和代码一起提交。数据库里的 django_migrations 表记录了哪些迁移已经执行。随意删除迁移、清空记录或使用 --fake,会让 Django 以为的表结构和真实表结构对不上。

6. 在 shell 里插入第一篇文章

先确认已用 createsuperuser 创建了名为 admin 的账号(如果用了别的名字,替换下面的用户名)。进入 shell:

python manage.py shell

逐段执行:

from django.contrib.auth import get_user_model
from blog.models import Category, Post, Tag

author = get_user_model().objects.get(username="admin")
category, _ = Category.objects.get_or_create(name="技术")
tag, _ = Tag.objects.get_or_create(name="Django")

post, created = Post.objects.get_or_create(
    slug="first-django-post",
    defaults={
        "title": "我的第一篇 Django 文章",
        "body": "今天完成了数据库模型和迁移。",
        "author": author,
        "category": category,
        "status": Post.Status.PUBLISHED,
    },
)
post.tags.add(tag)
print(post.pk, post.title, post.get_absolute_url())

应该打印出 /p/first-django-post/。

  • get_or_create() 找到同 slug 的文章就直接返回它,所以重复执行不会报错,也不会用 defaults 覆盖已有内容。
  • tags.add() 必须在文章保存(有了主键)之后调用,因为中间表要引用文章的 id。

7. 修改已有表:给文章加摘要

这是独立练习

示例项目的模型里没有这个字段。练习完可以把字段删掉、再生成一次迁移恢复原状。

假设数据库里已经有 100 篇文章,现在要加一个可选的摘要字段:

summary = models.CharField("摘要", max_length=300, blank=True, default="")
python manage.py makemigrations blog
python manage.py migrate --plan
python manage.py migrate

--plan 先预览要执行的操作,确认无误再真正执行。

如果摘要将来必须填写呢? 不要一步到位加一个“非空且没有默认值”的字段——数据库不知道 100 篇旧文章该填什么。稳妥做法分三步:

  1. 先加允许为空的字段;
  2. 用数据迁移或脚本给旧文章补上摘要;
  3. 确认都有值之后,再收紧为必填。

写数据迁移时,用 apps.get_model("blog", "Post") 获取当时的历史模型,不要直接 from blog.models import Post。

8. 常见报错

报错 含义
no such table: blog_post 当前数据库没有执行迁移,或者连到了另一个数据库文件
UNIQUE constraint failed 唯一值冲突,比如重复的 slug
ProtectedError 想删除还有文章的作者——正是 PROTECT 在起作用
database is locked SQLite 同时有多个写入或长事务;先缩短事务

小练习

1. 想让“分类”变成必选,需要改哪些参数?已有的未分类文章怎么办?

去掉 null=True, blank=True,并把 on_delete=models.SET_NULL 改成 PROTECT 之类(SET_NULL 要求字段可空)。旧的未分类文章要先补上分类(比如统一设为“未分类”这个分类),否则迁移无法给 NULL 行赋值。按第 7 节的三步法来。

2. 在 shell 里删除“技术”分类,属于它的文章会怎样?

文章保留,category 变成 None,页面上显示“未分类”。这是 on_delete=models.SET_NULL 决定的。