Django3:模型与迁移——把博客的数据存进数据库
Django 实战:做一个个人博客 · 第 3 / 8 篇(展开目录)
全系列用 Django 5.2 LTS 做一个个人博客:游客看文章,作者登录后写文章,管理员维护分类和账号。Django1—Django8 是文章序号,不是 Django 版本号。
本篇目标: 写出博客的数据模型,用迁移把它变成数据库表,并在 shell 中插入第一篇文章。
完成标志: showmigrations blog 显示 [X] 0001_initial,shell 里能打印出文章地址 /p/first-django-post/。
1. 先写规则,再写字段
设计模型前,先用中文把业务规则写清楚:
- 一篇文章有一位作者;一位作者可以写多篇文章。
- 一篇文章最多属于一个分类,也可以暂时不分类。
- 一篇文章可以有多个标签;一个标签也可以标记多篇文章。
- 新文章默认是草稿,游客看不到草稿。
把规则翻译成关系:
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 篇旧文章该填什么。稳妥做法分三步:
- 先加允许为空的字段;
- 用数据迁移或脚本给旧文章补上摘要;
- 确认都有值之后,再收紧为必填。
写数据迁移时,用 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 决定的。
- 感谢你赐予我前进的力量

