本系列目录

本系列以 Django 5.2 LTS 和个人博客为例。Django1—Django8 是文章序号。

  1. Django1:部署——从本地启动到服务器运行
  2. Django2:配置——看懂项目结构与请求流程
  3. Django3:数据库——模型设计、关系与迁移
  4. Django4:ORM——增删改查、事务与查询优化
  5. Django5:页面——视图、模板、搜索与分页
  6. Django6:使用——表单、文章管理与 Admin
  7. Django7:权限——登录、会话与访问控制
  8. Django8:维护——测试、日志、备份与升级

下载全部教程与可运行示例(ZIP)。解压后进入 django-series/example/,按第一篇运行。

查看完整项目源码 · 查看示例验证范围

博客能打开,并不代表它可以长期稳定运行。一次修改可能让草稿出现在搜索结果中,也可能让 Bob 意外编辑 Alice 的文章。数据库备份文件即使存在,也不代表一定能恢复。本篇把这些问题拆成可重复的检查和维护步骤。

沿用前七篇的项目。开发命令在 manage.py 所在目录执行,容器命令在 compose.yaml 所在目录执行;本例两个目录相同。请把练习数据库与真实生产数据库分开。

1. 应该优先测试哪些行为

优先选择出错后会改变数据或权限的规则:草稿不可公开,作者不能越权,提交空表单要有错误,GET 删除不能真的删除,创建文章时标签应一起保存。

这些测试关注外部行为和数据结果。它们能在修改视图、调整路由或替换表单实现后继续提供价值。

配套 blog/tests.py 包含 18 项测试,覆盖公开查询、认证与作者权限、CSRF、表单验证、创建/编辑/删除、HTML 转义、分页、数据库约束和关联查询数量。

2. 一份可以直接运行的测试

下面列出完整测试文件,方便从空项目搭建的读者直接保存:

from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction
from django.test import Client, TestCase
from django.urls import reverse

from .forms import PostForm
from .models import Category, Post, Tag


class BlogTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        User = get_user_model()
        cls.alice = User.objects.create_user(username="alice", password="TestOnly-Password-42")
        cls.bob = User.objects.create_user(username="bob", password="TestOnly-Password-43")
        cls.category = Category.objects.create(name="技术")
        cls.tag = Tag.objects.create(name="Django")
        cls.public = Post.objects.create(
            title="第一篇公开文章", slug="first-public-post", body="Django 学习记录",
            author=cls.alice, category=cls.category, status=Post.Status.PUBLISHED,
        )
        cls.public.tags.add(cls.tag)
        cls.draft = Post.objects.create(
            title="尚未公开的草稿", slug="private-draft", body="private-draft-marker",
            author=cls.alice,
        )

    def payload(self, **changes):
        data = {
            "title": "通过表单创建的文章", "slug": "new-post", "body": "正文内容",
            "category": self.category.pk, "tags": [self.tag.pk], "status": "draft",
        }
        data.update(changes)
        return data

    def test_public_list_hides_drafts(self):
        response = self.client.get(reverse("blog:list"))
        self.assertContains(response, self.public.title)
        self.assertNotContains(response, self.draft.title)

    def test_draft_detail_is_404_even_for_owner(self):
        self.client.force_login(self.alice)
        self.assertEqual(self.client.get(self.draft.get_absolute_url()).status_code, 404)

    def test_search_does_not_leak_drafts(self):
        response = self.client.get(reverse("blog:list"), {"q": "private-draft-marker"})
        self.assertEqual(response.context["page_obj"].paginator.count, 0)

    def test_login_is_required(self):
        response = self.client.get(reverse("blog:create"))
        self.assertEqual(response.status_code, 302)
        self.assertIn("/accounts/login/?next=", response.url)

    def test_create_sets_current_author_and_saves_tags(self):
        self.client.force_login(self.alice)
        response = self.client.post(reverse("blog:create"), self.payload(author=self.bob.pk))
        self.assertRedirects(response, reverse("blog:mine"))
        post = Post.objects.get(slug="new-post")
        self.assertEqual(post.author, self.alice)
        self.assertEqual(list(post.tags.all()), [self.tag])

    def test_owner_can_edit(self):
        self.client.force_login(self.alice)
        response = self.client.post(reverse("blog:update", args=[self.public.pk]), self.payload(slug=self.public.slug))
        self.assertRedirects(response, reverse("blog:mine"))
        self.public.refresh_from_db()
        self.assertEqual(self.public.title, "通过表单创建的文章")

    def test_other_author_cannot_edit_or_delete(self):
        self.client.force_login(self.bob)
        for action in ["update", "delete"]:
            url = reverse(f"blog:{action}", args=[self.public.pk])
            self.assertEqual(self.client.get(url).status_code, 404)
            self.assertEqual(self.client.post(url, self.payload()).status_code, 404)
        self.assertTrue(Post.objects.filter(pk=self.public.pk).exists())

    def test_mine_only_contains_own_posts(self):
        self.client.force_login(self.bob)
        response = self.client.get(reverse("blog:mine"))
        self.assertEqual(response.context["page_obj"].paginator.count, 0)

    def test_delete_get_only_shows_confirmation(self):
        self.client.force_login(self.alice)
        url = reverse("blog:delete", args=[self.public.pk])
        self.assertEqual(self.client.get(url).status_code, 200)
        self.assertTrue(Post.objects.filter(pk=self.public.pk).exists())
        self.assertRedirects(self.client.post(url), reverse("blog:mine"))
        self.assertFalse(Post.objects.filter(pk=self.public.pk).exists())

    def test_csrf_token_is_required(self):
        client = Client(enforce_csrf_checks=True)
        client.force_login(self.alice)
        self.assertEqual(client.post(reverse("blog:create"), self.payload()).status_code, 403)
        client.get(reverse("blog:create"))
        token = client.cookies["csrftoken"].value
        response = client.post(reverse("blog:create"), self.payload(csrfmiddlewaretoken=token))
        self.assertEqual(response.status_code, 302)

    def test_form_rejects_short_title_and_duplicate_slug(self):
        form = PostForm(self.payload(title="短", slug=self.public.slug))
        self.assertFalse(form.is_valid())
        self.assertIn("title", form.errors)
        self.assertIn("slug", form.errors)

    def test_empty_post_still_shows_validation_errors(self):
        self.client.force_login(self.alice)
        response = self.client.post(reverse("blog:create"), {})
        self.assertTrue(response.context["form"].is_bound)
        self.assertIn("title", response.context["form"].errors)

    def test_body_is_escaped(self):
        self.public.body = "<script>alert('xss')</script>"
        self.public.save()
        response = self.client.get(self.public.get_absolute_url())
        self.assertContains(response, "&lt;script&gt;")
        self.assertNotContains(response, "<script>")

    def test_pagination_accepts_invalid_page(self):
        for i in range(11):
            Post.objects.create(title=f"分页测试文章 {i}", slug=f"page-{i}", body="正文", author=self.alice, status="published")
        response = self.client.get(reverse("blog:list"), {"page": "bad"})
        self.assertEqual(response.context["page_obj"].number, 1)
        self.assertEqual(len(response.context["page_obj"]), 10)
        response = self.client.get(reverse("blog:list"), {"page": "999"})
        self.assertEqual(response.context["page_obj"].number, 2)

    def test_category_deletion_preserves_posts(self):
        self.category.delete()
        self.public.refresh_from_db()
        self.assertIsNone(self.public.category)

    def test_database_rejects_invalid_status(self):
        with self.assertRaises(IntegrityError):
            with transaction.atomic():
                Post.objects.filter(pk=self.public.pk).update(status="invalid")

    def test_related_objects_need_three_queries(self):
        for i in range(4):
            post = Post.objects.create(title=f"关联查询测试 {i}", slug=f"related-{i}", body="正文", author=self.alice, category=self.category, status="published")
            post.tags.add(self.tag)
        from django.core.paginator import Paginator
        posts = Post.objects.filter(status="published").select_related("author", "category").prefetch_related("tags")
        with self.assertNumQueries(3):
            page = Paginator(posts, 10).get_page(1)
            for post in page:
                _ = post.author.username, post.category.name, [tag.name for tag in post.tags.all()]

    def test_login_and_post_logout(self):
        response = self.client.post(reverse("login"), {"username": "alice", "password": "TestOnly-Password-42"})
        self.assertRedirects(response, reverse("blog:mine"))
        self.assertEqual(self.client.get(reverse("logout")).status_code, 405)
        self.assertRedirects(self.client.post(reverse("logout")), reverse("blog:list"))

这些测试里的密码只属于测试数据库中的虚构账号,不用于真实部署。setUpTestData() 为测试类建立初始数据;每个测试获得隔离的数据库状态,所以一个测试删除文章,不会让后面的测试缺少初始对象。

force_login() 适合直接进入“已经认证”的业务测试,它不验证密码登录流程;因此文件另有一个实际提交用户名密码的登录测试。CSRF 测试使用 Client(enforce_csrf_checks=True),因为普通测试客户端默认不强制执行全部 CSRF 检查。Django 测试工具

3. 运行测试与结构检查

python manage.py test -v 2
python manage.py check
python manage.py makemigrations --check --dry-run

三个命令分别检查行为、项目配置/模型问题、以及是否存在忘记生成的迁移。预期测试末尾显示 OK,系统检查没有错误,迁移检查显示 No changes detected

测试使用独立测试数据库,不依赖你手工创建的管理员账号。默认 SQLite 测试可以直接运行;在 PostgreSQL 上测试时,Django 通常需要创建测试数据库的权限。生产应用账号 blog_app 故意不授予 CREATEDB,不要为了方便测试给生产账号随意提权;应配置独立测试数据库和测试凭据。

本次实际执行结果及环境见 验证记录。SQLite 下通过的结果不能证明 PostgreSQL 行锁、备份恢复和容器网络都正确,这些需要在对应环境继续验收。

4. 日志:先记录能定位问题的信息

本项目将应用日志写到控制台,Gunicorn 的访问日志和错误日志也输出到容器日志。新建文章时记录 post_iduser_id,便于查找某次操作;没有记录密码、会话 Cookie 或完整请求正文。

docker compose ps
docker compose logs --tail 100 web
docker compose logs --tail 100 nginx
docker compose logs --tail 100 db
现象 排查路径
连接超时 DNS、网络、防火墙、外层代理
502 Nginx 是否能连接 web,Gunicorn 是否启动或崩溃
400 / DisallowedHost Host 与 ALLOWED_HOSTS
403 CSRF Cookie、token、Origin、HTTPS 协议传递
404 路由是否匹配,对象是否存在且满足权限/发布条件
500 应用堆栈、数据库错误、配置缺失

/healthz/ 只做应用存活检查。数据库停了,它仍可能返回 200。监控需要区分存活、依赖就绪和真实业务读写能力;并限制探针访问范围与返回信息。

日志会增长,应在宿主机或容器平台配置轮转与保留策略。需要跨多个服务定位同一请求时,再加入请求 ID 和集中日志收集。本例只演示基础控制台日志,不声称已经具备完整审计和告警能力。

5. SQLite 备份:使用数据库备份接口

不要一边写入数据库,一边随意复制一个可能配合 WAL 文件工作的 db.sqlite3。Python 自带的 SQLite 备份接口可以生成一致的数据库副本:

from datetime import datetime, timezone
from pathlib import Path
import sqlite3

backup_dir = Path("backups")
backup_dir.mkdir(mode=0o700, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
target = backup_dir / f"blog-{stamp}.sqlite3"

source = sqlite3.connect("file:db.sqlite3?mode=ro", uri=True)
destination = sqlite3.connect(target)
try:
    source.backup(destination)
    result = destination.execute("PRAGMA integrity_check").fetchone()[0]
    if result != "ok":
        raise RuntimeError(result)
finally:
    destination.close()
    source.close()
target.chmod(0o600)
print(target)

在项目目录保存或执行这段代码,预期生成带 UTC 时间的备份文件并通过完整性检查。使用只读 URI 打开源数据库,可避免路径错误时悄悄创建一个空源库。恢复练习应先复制整个项目到独立目录,将备份作为那里使用的 db.sqlite3,再检查文章数量、登录和页面;不要直接覆盖正在运行的实例。Python SQLite 备份 API

6. PostgreSQL 备份:生成可恢复的归档

下面命令使用第一篇默认数据库名 blog 和应用角色 blog_app。如果修改了名称,需要同步替换。数据库容器应已经运行。

mkdir -p backups
chmod 700 backups
umask 077
backup_stamp=$(date -u +%Y%m%dT%H%M%SZ)
backup_partial="backups/blog-${backup_stamp}.dump.partial"
backup_final="backups/blog-${backup_stamp}.dump"

if docker compose exec -T db pg_dump -U blog_app -d blog -Fc > "$backup_partial"; then
  mv "$backup_partial" "$backup_final"
  echo "备份完成:$backup_final"
else
  echo "备份失败,保留 .partial 文件供排查;不能把它作为有效备份。" >&2
fi

-Fc 生成 PostgreSQL 自定义归档,后续交给 pg_restore-T 关闭伪终端,适合重定向二进制输出。使用 .partial 区分尚未确认成功的输出,避免把失败时生成的空文件当作备份。

pg_dump 为单个数据库提供一致性导出,但不会自动备份整个集群角色、应用密钥、证书和文件存储。把数据库归档、配置恢复办法、依赖/镜像版本和所需文件一起纳入备份计划。备份应有异地副本和保留周期。PostgreSQL pg_dump

7. 恢复到独立数据库做演练

不要把第一次恢复尝试放在生产库。先创建一个新的演练数据库:

docker compose exec db createdb -U postgres -O blog_app blog_restore_check

假设选中的归档为 backups/blog-20260922T010000Z.dump,将下面文件名替换为真实备份:

docker compose exec -T db pg_restore --list < backups/blog-20260922T010000Z.dump
docker compose exec -T db pg_restore \
  -U blog_app -d blog_restore_check --no-owner --no-acl --exit-on-error \
  < backups/blog-20260922T010000Z.dump

docker compose run --rm -e POSTGRES_DB=blog_restore_check web \
  python manage.py shell -c 'from blog.models import Post; print(Post.objects.count())'

--list 只说明归档可读,真正导入和业务抽查才接近恢复验证。对照备份时的文章、用户与标签数量,再在独立应用环境验证登录和页面。演练库名固定时只能首次创建;重复演练应选新名字,避免覆盖上次结果。PostgreSQL pg_restore

第一篇将 postgres 初始化管理员和 blog_app 应用角色分开。恢复到新数据库时由管理员创建数据库,再让应用角色恢复对象。生产密码变更也必须在数据库角色上实际执行并同步应用环境,单改 .env 不会修改既有卷中的角色。

8. 一次有维护窗口的发布流程

下面按单机博客可接受短暂停机的方式说明。它不是滚动无停机发布方案。

  1. 在开发或预发布环境运行测试,检查新迁移和依赖变化。
  2. 构建新镜像,记录当前可回退的代码版本和镜像,准备维护窗口。
  3. 暂停写入或把外层入口切换到维护页,完成发布前备份。
  4. 停止旧应用,运行迁移并收集静态文件。
  5. 启动新应用及代理,验证首页、登录、写入和资源加载。

本例核心命令如下,执行前应已经完成前面的备份:

docker compose build web
docker compose stop nginx web
docker compose run --rm web python manage.py migrate --plan
docker compose run --rm web python manage.py migrate
docker compose run --rm web python manage.py collectstatic --noinput
docker compose run --rm web python manage.py check --deploy
docker compose up -d --force-recreate web nginx

每步成功后再执行下一步。迁移失败时,先确认是否有部分结构变更及其数据库事务能力,再决定修复,不应直接继续启动新版本。

代码回退和数据库回退是两件事。旧代码未必兼容新字段约束;删除列后的数据也不会因为回退镜像自动出现。可逆迁移应提前演练,破坏性变更通常优先考虑分阶段发布和向前修复。数据库备份恢复还可能丢失备份之后的新增数据,需要评估恢复点。

9. 常用命令速查

目标 命令
本地启动 python manage.py runserver
创建管理员 python manage.py createsuperuser
生成迁移 python manage.py makemigrations blog
执行迁移 python manage.py migrate
查看迁移计划 python manage.py migrate --plan
进入项目 shell python manage.py shell
运行测试 python manage.py test
检查生产配置 python manage.py check --deploy
收集静态文件 python manage.py collectstatic --noinput
查看容器日志 docker compose logs --tail 100 web
清理过期会话 python manage.py clearsessions

clearsessions 清理过期会话,不等于清空用户。docker compose down 停止并移除服务容器,默认保留命名卷;加上 -v 会涉及持久化卷删除,不应作为普通重启命令。

10. 接下来可以怎样扩展

已有基础足够支持进一步学习:为文章增加版本号解决编辑冲突;为公开搜索引入 PostgreSQL 全文检索;以 Django REST Framework 构建独立 API;增加图片上传后设计媒体存储和访问规则;需要后台任务时再引入适合的任务队列。

每次扩展都从明确的业务场景开始,先定义数据、权限和验收结果,再选择实现方式。系列索引与完整源码入口见 Django 系列目录

示例验证范围

2026-09-22 使用 Python 3.13.3、Django 5.2.17 和 SQLite 完成验证:18 项行为测试通过;35 个 Python 正文代码块通过语法检查;12 个数据库练习片段按顺序执行成功;SQLite 备份及数据导出/恢复、静态文件收集、WSGI 入口和 Compose 配置解析均通过。

Dockerfile 采用 Python 3.12。容器构建运行、PostgreSQL 角色初始化、跨数据库迁移、行锁、pg_dump/pg_restore 和实际 HTTPS 链路尚未实机验证,正文相关内容为操作指南与预期结果。

附录:完整项目源码

以下文件均相对于项目根目录,也就是 manage.py 所在目录。逐个按路径保存,空的 __init__.py 文件保持为空,然后按第一篇安装依赖、执行迁移并启动。正文中的源码链接会直接跳到对应文件。

.dockerignore

.venv
.env
.git
db.sqlite3
__pycache__
*.pyc
staticfiles
backups

.env.example

DJANGO_DEBUG=0
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,blog.example.com
DJANGO_CSRF_TRUSTED_ORIGINS=https://blog.example.com
DJANGO_HTTPS=0
DJANGO_TRUST_PROXY=0
DJANGO_HSTS_SECONDS=0
POSTGRES_DB=blog
POSTGRES_USER=blog_app
POSTGRES_PASSWORD=replace-with-a-random-database-password
POSTGRES_ADMIN_PASSWORD=replace-with-a-different-random-admin-password

.gitignore

.venv/
.env
db.sqlite3
__pycache__/
*.pyc
staticfiles/
backups/

Dockerfile

FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
    && useradd --create-home --uid 10001 app
COPY --chown=app:app . .
RUN mkdir -p /app/staticfiles && chown app:app /app/staticfiles
USER app
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-"]

README.md

# Django 实战博客:配套示例

完整文章从上一级 [系列目录](https://www.cjanus.top/archives/django-1-deployment) 开始。示例对应 Django 5.2 LTS,建议使用 Python 3.12 或 3.13。

## 本地运行

在本目录执行,以下命令适用于 macOS/Linux:

```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver 127.0.0.1:8000
```

Windows 可以使用 `py -3.12 -m venv .venv`,之后直接用 `.\.venv\Scripts\python.exe` 执行 Python 命令。项目已经创建完毕,不需要执行 `startproject` 或 `startapp`。

打开 [本地博客](http://127.0.0.1:8000/) 或 [管理后台](http://127.0.0.1:8000/admin/)。先在后台创建分类、标签和普通作者账号,再在前台登录写文章。网址标识使用唯一的 ASCII slug,例如 `my-first-post`;草稿通过“我的文章”编辑,已发布文章才出现在公开首页。

## 功能与规则

- 游客搜索、分页阅读已发布文章。
- 作者新建文章,编辑/发布/删除自己的文章。
- 管理员在 Admin 管理账号、分类、标签与内容。
- 前台不开放注册;普通作者不需要 `is_staff`。
- 正文使用纯文本,保留换行;没有富文本、上传和评论功能。
- 阅读计数字段仅用于 ORM 练习,访问页面不会自动计数。

## 验证

```bash
python manage.py test -v 2
python manage.py check
python manage.py makemigrations --check --dry-run
```

测试默认使用独立 SQLite 测试数据库。完整验证范围见 [VALIDATION.md](https://www.cjanus.top/archives/django-8-testing-maintenance#validation)。

## 容器部署

按 [Django1:部署](https://www.cjanus.top/archives/django-1-deployment) 完成 `.env`、镜像构建、数据库迁移、静态文件收集和 HTTPS 代理配置。Compose 默认只向宿主机 `127.0.0.1:8088` 提供入口。数据库使用持久化卷;应用角色与初始化管理员分开。

`.env.example` 中都是占位值,使用前必须生成自己的密钥。Compose 会读取 `.env` 并显式注入所需变量;直接执行 `manage.py` 不会自动读取 `.env`。

本例保留了生产配置与部署说明,但容器镜像构建、PostgreSQL 运行及 HTTPS 接入未在本次本地环境实测。

blog/__init__.py

此文件保持为空。

blog/admin.py

from django.contrib import admin
from .models import Category, Post, Tag


@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ("title", "author", "category", "status", "created_at")
    list_filter = ("status", "category")
    search_fields = ("title", "body", "author__username")
    prepopulated_fields = {"slug": ("title",)}
    filter_horizontal = ("tags",)
    readonly_fields = ("created_at", "updated_at", "views")
    list_select_related = ("author", "category")


admin.site.register(Category)
admin.site.register(Tag)

blog/apps.py

from django.apps import AppConfig


class BlogConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "blog"
    verbose_name = "博客"

blog/forms.py

from django import forms
from .models import Post


class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "slug", "body", "category", "tags", "status"]
        widgets = {"body": forms.Textarea(attrs={"rows": 12})}

    def clean_title(self):
        title = self.cleaned_data["title"].strip()
        if len(title) < 5:
            raise forms.ValidationError("标题至少需要 5 个字符。")
        return title

blog/migrations/0001_initial.py

# Generated by Django 5.2.17 on 2026-09-21 17:11

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

    initial = True

    dependencies = [
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
    ]

    operations = [
        migrations.CreateModel(
            name='Category',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=50, unique=True, verbose_name='名称')),
            ],
        ),
        migrations.CreateModel(
            name='Tag',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=30, unique=True, verbose_name='名称')),
            ],
        ),
        migrations.CreateModel(
            name='Post',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('title', models.CharField(max_length=200, verbose_name='标题')),
                ('slug', models.SlugField(max_length=200, unique=True, verbose_name='网址标识')),
                ('body', models.TextField(verbose_name='正文')),
                ('status', models.CharField(choices=[('draft', '草稿'), ('published', '已发布')], default='draft', max_length=10, verbose_name='状态')),
                ('views', models.PositiveIntegerField(default=0, verbose_name='阅读计数')),
                ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
                ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
                ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='posts', to=settings.AUTH_USER_MODEL, verbose_name='作者')),
                ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='posts', to='blog.category', verbose_name='分类')),
                ('tags', models.ManyToManyField(blank=True, related_name='posts', to='blog.tag', verbose_name='标签')),
            ],
            options={
                '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')],
            },
        ),
    ]

blog/migrations/__init__.py

此文件保持为空。

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})

blog/static/blog/style.css

:root { color-scheme: light; font-family: system-ui, sans-serif; line-height: 1.7; }
body { max-width: 900px; margin: 2rem auto; padding: 0 1rem; color: #243044; background: #fafafa; }
header, nav { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }
header { justify-content: space-between; border-bottom: 1px solid #ccd3db; padding-bottom: 1rem; }
a { color: #185a9d; }
article { border-bottom: 1px solid #ddd; padding: 1rem 0; overflow-wrap: anywhere; }
input, select, textarea, button { font: inherit; max-width: 100%; box-sizing: border-box; }
textarea { width: 100%; }
label { display: block; }
button { cursor: pointer; }
.inline { display: inline; }
.errorlist { color: #aa1632; }

blog/tests.py

from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction
from django.test import Client, TestCase
from django.urls import reverse

from .forms import PostForm
from .models import Category, Post, Tag


class BlogTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        User = get_user_model()
        cls.alice = User.objects.create_user(username="alice", password="TestOnly-Password-42")
        cls.bob = User.objects.create_user(username="bob", password="TestOnly-Password-43")
        cls.category = Category.objects.create(name="技术")
        cls.tag = Tag.objects.create(name="Django")
        cls.public = Post.objects.create(
            title="第一篇公开文章", slug="first-public-post", body="Django 学习记录",
            author=cls.alice, category=cls.category, status=Post.Status.PUBLISHED,
        )
        cls.public.tags.add(cls.tag)
        cls.draft = Post.objects.create(
            title="尚未公开的草稿", slug="private-draft", body="private-draft-marker",
            author=cls.alice,
        )

    def payload(self, **changes):
        data = {
            "title": "通过表单创建的文章", "slug": "new-post", "body": "正文内容",
            "category": self.category.pk, "tags": [self.tag.pk], "status": "draft",
        }
        data.update(changes)
        return data

    def test_public_list_hides_drafts(self):
        response = self.client.get(reverse("blog:list"))
        self.assertContains(response, self.public.title)
        self.assertNotContains(response, self.draft.title)

    def test_draft_detail_is_404_even_for_owner(self):
        self.client.force_login(self.alice)
        self.assertEqual(self.client.get(self.draft.get_absolute_url()).status_code, 404)

    def test_search_does_not_leak_drafts(self):
        response = self.client.get(reverse("blog:list"), {"q": "private-draft-marker"})
        self.assertEqual(response.context["page_obj"].paginator.count, 0)

    def test_login_is_required(self):
        response = self.client.get(reverse("blog:create"))
        self.assertEqual(response.status_code, 302)
        self.assertIn("/accounts/login/?next=", response.url)

    def test_create_sets_current_author_and_saves_tags(self):
        self.client.force_login(self.alice)
        response = self.client.post(reverse("blog:create"), self.payload(author=self.bob.pk))
        self.assertRedirects(response, reverse("blog:mine"))
        post = Post.objects.get(slug="new-post")
        self.assertEqual(post.author, self.alice)
        self.assertEqual(list(post.tags.all()), [self.tag])

    def test_owner_can_edit(self):
        self.client.force_login(self.alice)
        response = self.client.post(reverse("blog:update", args=[self.public.pk]), self.payload(slug=self.public.slug))
        self.assertRedirects(response, reverse("blog:mine"))
        self.public.refresh_from_db()
        self.assertEqual(self.public.title, "通过表单创建的文章")

    def test_other_author_cannot_edit_or_delete(self):
        self.client.force_login(self.bob)
        for action in ["update", "delete"]:
            url = reverse(f"blog:{action}", args=[self.public.pk])
            self.assertEqual(self.client.get(url).status_code, 404)
            self.assertEqual(self.client.post(url, self.payload()).status_code, 404)
        self.assertTrue(Post.objects.filter(pk=self.public.pk).exists())

    def test_mine_only_contains_own_posts(self):
        self.client.force_login(self.bob)
        response = self.client.get(reverse("blog:mine"))
        self.assertEqual(response.context["page_obj"].paginator.count, 0)

    def test_delete_get_only_shows_confirmation(self):
        self.client.force_login(self.alice)
        url = reverse("blog:delete", args=[self.public.pk])
        self.assertEqual(self.client.get(url).status_code, 200)
        self.assertTrue(Post.objects.filter(pk=self.public.pk).exists())
        self.assertRedirects(self.client.post(url), reverse("blog:mine"))
        self.assertFalse(Post.objects.filter(pk=self.public.pk).exists())

    def test_csrf_token_is_required(self):
        client = Client(enforce_csrf_checks=True)
        client.force_login(self.alice)
        self.assertEqual(client.post(reverse("blog:create"), self.payload()).status_code, 403)
        client.get(reverse("blog:create"))
        token = client.cookies["csrftoken"].value
        response = client.post(reverse("blog:create"), self.payload(csrfmiddlewaretoken=token))
        self.assertEqual(response.status_code, 302)

    def test_form_rejects_short_title_and_duplicate_slug(self):
        form = PostForm(self.payload(title="短", slug=self.public.slug))
        self.assertFalse(form.is_valid())
        self.assertIn("title", form.errors)
        self.assertIn("slug", form.errors)

    def test_empty_post_still_shows_validation_errors(self):
        self.client.force_login(self.alice)
        response = self.client.post(reverse("blog:create"), {})
        self.assertTrue(response.context["form"].is_bound)
        self.assertIn("title", response.context["form"].errors)

    def test_body_is_escaped(self):
        self.public.body = "<script>alert('xss')</script>"
        self.public.save()
        response = self.client.get(self.public.get_absolute_url())
        self.assertContains(response, "&lt;script&gt;")
        self.assertNotContains(response, "<script>")

    def test_pagination_accepts_invalid_page(self):
        for i in range(11):
            Post.objects.create(title=f"分页测试文章 {i}", slug=f"page-{i}", body="正文", author=self.alice, status="published")
        response = self.client.get(reverse("blog:list"), {"page": "bad"})
        self.assertEqual(response.context["page_obj"].number, 1)
        self.assertEqual(len(response.context["page_obj"]), 10)
        response = self.client.get(reverse("blog:list"), {"page": "999"})
        self.assertEqual(response.context["page_obj"].number, 2)

    def test_category_deletion_preserves_posts(self):
        self.category.delete()
        self.public.refresh_from_db()
        self.assertIsNone(self.public.category)

    def test_database_rejects_invalid_status(self):
        with self.assertRaises(IntegrityError):
            with transaction.atomic():
                Post.objects.filter(pk=self.public.pk).update(status="invalid")

    def test_related_objects_need_three_queries(self):
        for i in range(4):
            post = Post.objects.create(title=f"关联查询测试 {i}", slug=f"related-{i}", body="正文", author=self.alice, category=self.category, status="published")
            post.tags.add(self.tag)
        from django.core.paginator import Paginator
        posts = Post.objects.filter(status="published").select_related("author", "category").prefetch_related("tags")
        with self.assertNumQueries(3):
            page = Paginator(posts, 10).get_page(1)
            for post in page:
                _ = post.author.username, post.category.name, [tag.name for tag in post.tags.all()]

    def test_login_and_post_logout(self):
        response = self.client.post(reverse("login"), {"username": "alice", "password": "TestOnly-Password-42"})
        self.assertRedirects(response, reverse("blog:mine"))
        self.assertEqual(self.client.get(reverse("logout")).status_code, 405)
        self.assertRedirects(self.client.post(reverse("logout")), reverse("blog:list"))

blog/urls.py

from django.urls import path
from . import views

app_name = "blog"
urlpatterns = [
    path("", views.post_list, name="list"),
    path("healthz/", views.health, name="health"),
    path("my/posts/", views.post_mine, name="mine"),
    path("posts/new/", views.post_create, name="create"),
    path("posts/<int:pk>/edit/", views.post_update, name="update"),
    path("posts/<int:pk>/delete/", views.post_delete, name="delete"),
    path("p/<slug:slug>/", views.post_detail, name="detail"),
]

blog/views.py

import logging

from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_GET, require_http_methods

from .forms import PostForm
from .models import Post

logger = logging.getLogger(__name__)


@require_GET
def health(request):
    return JsonResponse({"status": "ok"})


@require_GET
def post_list(request):
    q = request.GET.get("q", "").strip()[:100]
    posts = Post.objects.filter(status=Post.Status.PUBLISHED)
    if q:
        posts = posts.filter(Q(title__icontains=q) | Q(body__icontains=q))
    posts = posts.select_related("author", "category").prefetch_related("tags")
    page_obj = Paginator(posts, 10).get_page(request.GET.get("page"))
    return render(request, "blog/post_list.html", {"page_obj": page_obj, "q": q})


@require_GET
def post_detail(request, slug):
    post = get_object_or_404(
        Post.objects.select_related("author", "category").prefetch_related("tags"),
        slug=slug, status=Post.Status.PUBLISHED,
    )
    return render(request, "blog/post_detail.html", {"post": post})


@login_required
@require_GET
def post_mine(request):
    posts = Post.objects.filter(author=request.user)
    page_obj = Paginator(posts, 10).get_page(request.GET.get("page"))
    return render(request, "blog/post_mine.html", {"page_obj": page_obj})


@login_required
@require_http_methods(["GET", "POST"])
def post_create(request):
    form = PostForm(request.POST if request.method == "POST" else None)
    if request.method == "POST" and form.is_valid():
        with transaction.atomic():
            post = form.save(commit=False)
            post.author = request.user
            post.save()
            form.save_m2m()
        messages.success(request, "文章已保存。")
        logger.info("post_created post_id=%s user_id=%s", post.pk, request.user.pk)
        return redirect("blog:mine")
    return render(request, "blog/post_form.html", {"form": form, "heading": "写文章"})


@login_required
@require_http_methods(["GET", "POST"])
def post_update(request, pk):
    post = get_object_or_404(Post, pk=pk, author=request.user)
    form = PostForm(request.POST if request.method == "POST" else None, instance=post)
    if request.method == "POST" and form.is_valid():
        with transaction.atomic():
            form.save()
        messages.success(request, "修改已保存。")
        return redirect("blog:mine")
    return render(request, "blog/post_form.html", {"form": form, "heading": "编辑文章"})


@login_required
@require_http_methods(["GET", "POST"])
def post_delete(request, pk):
    post = get_object_or_404(Post, pk=pk, author=request.user)
    if request.method == "POST":
        post.delete()
        messages.success(request, "文章已删除。")
        return redirect("blog:mine")
    return render(request, "blog/post_confirm_delete.html", {"post": post})

compose.yaml

name: django-blog-tutorial

services:
  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env}
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:?set POSTGRES_ADMIN_PASSWORD in .env}
      POSTGRES_APP_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env}
      POSTGRES_APP_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./deploy/init-db.sh:/docker-entrypoint-initdb.d/10-app.sh:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 5s
      retries: 12

  web:
    build: .
    restart: unless-stopped
    environment:
      DJANGO_DEBUG: "0"
      DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:?set DJANGO_SECRET_KEY in .env}
      DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-127.0.0.1,localhost}
      DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS:-}
      DJANGO_HTTPS: ${DJANGO_HTTPS:-0}
      DJANGO_TRUST_PROXY: ${DJANGO_TRUST_PROXY:-0}
      DJANGO_HSTS_SECONDS: ${DJANGO_HSTS_SECONDS:-0}
      DB_ENGINE: postgres
      POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env}
      POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
      POSTGRES_HOST: db
      POSTGRES_PORT: "5432"
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - static_data:/app/staticfiles

  nginx:
    image: nginx:stable
    restart: unless-stopped
    ports:
      - "127.0.0.1:8088:80"
    volumes:
      - ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - static_data:/static:ro
    depends_on:
      - web

volumes:
  postgres_data:
  static_data:

config/__init__.py

此文件保持为空。

config/asgi.py

import os
from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application()

config/settings.py

import os
from pathlib import Path

from django.core.exceptions import ImproperlyConfigured

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = os.getenv("DJANGO_DEBUG", "1") == "1"
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "")
if not SECRET_KEY:
    if not DEBUG:
        raise ImproperlyConfigured("生产环境必须设置 DJANGO_SECRET_KEY")
    SECRET_KEY = "dev-only-key-for-local-learning-do-not-use-in-production"


def env_list(name, default=""):
    return [item.strip() for item in os.getenv(name, default).split(",") if item.strip()]


ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "127.0.0.1,localhost")
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "blog.apps.BlogConfig",
]
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [{
    "BACKEND": "django.template.backends.django.DjangoTemplates",
    "DIRS": [BASE_DIR / "templates"],
    "APP_DIRS": True,
    "OPTIONS": {"context_processors": [
        "django.template.context_processors.request",
        "django.contrib.auth.context_processors.auth",
        "django.contrib.messages.context_processors.messages",
    ]},
}]
WSGI_APPLICATION = "config.wsgi.application"
ASGI_APPLICATION = "config.asgi.application"

if os.getenv("DB_ENGINE", "sqlite") == "postgres":
    DATABASES = {"default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ["POSTGRES_DB"],
        "USER": os.environ["POSTGRES_USER"],
        "PASSWORD": os.environ["POSTGRES_PASSWORD"],
        "HOST": os.getenv("POSTGRES_HOST", "127.0.0.1"),
        "PORT": os.getenv("POSTGRES_PORT", "5432"),
        "CONN_MAX_AGE": 60,
        "CONN_HEALTH_CHECKS": True,
    }}
else:
    DATABASES = {"default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    }}

AUTH_PASSWORD_VALIDATORS = [
    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
    {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "blog:mine"
LOGOUT_REDIRECT_URL = "blog:list"

# 仅在已接入可信 HTTPS 反向代理时设为 1。
HTTPS_ENABLED = os.getenv("DJANGO_HTTPS", "0") == "1"
SECURE_SSL_REDIRECT = HTTPS_ENABLED
SESSION_COOKIE_SECURE = HTTPS_ENABLED
CSRF_COOKIE_SECURE = HTTPS_ENABLED
SECURE_HSTS_SECONDS = int(os.getenv("DJANGO_HSTS_SECONDS", "0"))
if os.getenv("DJANGO_TRUST_PROXY", "0") == "1":
    SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "root": {"handlers": ["console"], "level": "INFO"},
}

config/urls.py

from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("accounts/login/", auth_views.LoginView.as_view(), name="login"),
    path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"),
    path("", include("blog.urls")),
]

config/wsgi.py

import os
from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()

deploy/init-db.sh

#!/bin/sh
set -eu
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
  --set=app_user="$POSTGRES_APP_USER" \
  --set=app_password="$POSTGRES_APP_PASSWORD" \
  --set=db_name="$POSTGRES_DB" <<'SQL'
CREATE ROLE :"app_user" LOGIN PASSWORD :'app_password';
GRANT CONNECT ON DATABASE :"db_name" TO :"app_user";
GRANT USAGE, CREATE ON SCHEMA public TO :"app_user";
SQL

deploy/nginx.conf

# 内层仅监听容器网络;宿主机只发布 127.0.0.1:8088。
# 接入 HTTPS 后,只有可信外层代理能访问它,并覆盖 X-Forwarded-Proto。
map $http_x_forwarded_proto $original_scheme {
    default $scheme;
    https https;
}

server {
    listen 80;
    server_name _;
    client_max_body_size 2m;

    location /static/ {
        alias /static/;
        expires 1h;
    }

    location / {
        proxy_pass http://web:8000;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $original_scheme;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

manage.py

#!/usr/bin/env python
import os
import sys

if __name__ == "__main__":
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
    from django.core.management import execute_from_command_line
    execute_from_command_line(sys.argv)

requirements.txt

Django>=5.2,<5.3
psycopg[binary]>=3.2,<4
gunicorn>=23,<24

templates/base.html

{% load static %}
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{% block title %}Django 实战博客{% endblock %}</title>
  <link rel="stylesheet" href="{% static 'blog/style.css' %}">
</head>
<body>
  <header>
    <a href="{% url 'blog:list' %}">Django 实战博客</a>
    <nav>
      {% if user.is_authenticated %}
        <a href="{% url 'blog:mine' %}">我的文章</a>
        <a href="{% url 'blog:create' %}">写文章</a>
        <span>{{ user.username }}</span>
        <form method="post" action="{% url 'logout' %}" class="inline">
          {% csrf_token %}<button type="submit">退出</button>
        </form>
      {% else %}
        <a href="{% url 'login' %}">登录</a>
      {% endif %}
    </nav>
  </header>
  <main>
    {% for message in messages %}<p role="status">{{ message }}</p>{% endfor %}
    {% block content %}{% endblock %}
  </main>
</body>
</html>

templates/blog/pagination.html

<nav aria-label="分页">
  {% if page_obj.has_previous %}
    <a href="?q={{ q|default:''|urlencode }}&amp;page={{ page_obj.previous_page_number }}">上一页</a>
  {% endif %}
  <span>第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页</span>
  {% if page_obj.has_next %}
    <a href="?q={{ q|default:''|urlencode }}&amp;page={{ page_obj.next_page_number }}">下一页</a>
  {% endif %}
</nav>

templates/blog/post_confirm_delete.html

{% extends 'base.html' %}
{% block content %}
<h1>删除文章</h1>
<p>确认删除《{{ post.title }}》?删除后无法在页面恢复。</p>
<form method="post">
  {% csrf_token %}
  <button type="submit">确认删除</button>
  <a href="{% url 'blog:mine' %}">取消</a>
</form>
{% endblock %}

templates/blog/post_detail.html

{% extends 'base.html' %}
{% block title %}{{ post.title }} · Django 实战博客{% endblock %}
{% block content %}
<article>
  <h1>{{ post.title }}</h1>
  <p>{{ post.author.username }} · {{ post.created_at|date:'Y-m-d H:i' }}</p>
  <div>{{ post.body|linebreaks }}</div>
  <p>{% for tag in post.tags.all %}<span>#{{ tag.name }} </span>{% endfor %}</p>
</article>
{% endblock %}

templates/blog/post_form.html

{% extends 'base.html' %}
{% block content %}
<h1>{{ heading }}</h1>
<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">保存</button>
</form>
{% endblock %}

templates/blog/post_list.html

{% extends 'base.html' %}
{% block content %}
<h1>最新文章</h1>
<form method="get">
  <label for="q">搜索标题或正文</label>
  <input id="q" name="q" value="{{ q }}" maxlength="100">
  <button type="submit">搜索</button>
</form>
{% for post in page_obj %}
  <article>
    <h2><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2>
    <p>{{ post.author.username }} · {{ post.category|default:'未分类' }} · {{ post.created_at|date:'Y-m-d H:i' }}</p>
    <p>{{ post.body|truncatechars:120 }}</p>
    <p>{% for tag in post.tags.all %}<span>#{{ tag.name }} </span>{% endfor %}</p>
  </article>
{% empty %}
  <p>暂时没有符合条件的文章。</p>
{% endfor %}
{% include 'blog/pagination.html' %}
{% endblock %}

templates/blog/post_mine.html

{% extends 'base.html' %}
{% block content %}
<h1>我的文章</h1>
{% for post in page_obj %}
<article>
  <h2>{{ post.title }}</h2>
  <p>{{ post.get_status_display }}</p>
  {% if post.status == 'published' %}<a href="{{ post.get_absolute_url }}">查看</a>{% endif %}
  <a href="{% url 'blog:update' post.pk %}">编辑</a>
  <a href="{% url 'blog:delete' post.pk %}">删除</a>
</article>
{% empty %}<p>还没有文章,点击“写文章”开始。</p>{% endfor %}
{% include 'blog/pagination.html' %}
{% endblock %}

templates/registration/login.html

{% extends 'base.html' %}
{% block content %}
<h1>登录</h1>
<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <input type="hidden" name="next" value="{{ next }}">
  <button type="submit">登录</button>
</form>
{% endblock %}

上一篇:Django7:权限——登录、会话与访问控制