Django个人任务板完整实践 · tasks/admin.py至版本与资料边界
适用:Python 3.12、Django 5.2.18(5.2 LTS)。先掌握函数、模块、虚拟环境和 HTTP 的请求/响应。示例未执行;本轮不安装依赖、不启动服务、不运行框架或迁移。
tasks/admin.py
py
from django.contrib import admin
from .models import Task
@admin.register(Task)
class TaskAdmin(admin.ModelAdmin):
list_display = ["title", "owner", "done", "created_at"]
list_filter = ["done"]
search_fields = ["title", "owner__username"]tasks/migrations/init.py
py
# Migration package.tasks/migrations/0001_initial.py
py
# Hand-authored migration, not produced or run by this delivery.
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
operations = [migrations.CreateModel(
name="Task",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("title", models.CharField(max_length=120)),
("done", models.BooleanField(default=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("owner", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={"ordering": ["-created_at", "-pk"], "constraints": [
models.UniqueConstraint(fields=("owner", "title"), name="task_owner_title_unique")
]},
)]templates/base.html
html
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>个人任务板</title></head>
<body><header><a href="{% url 'tasks:list' %}">任务板</a>
{% if user.is_authenticated %}<span>{{ user.username }}</span>
<form method="post" action="{% url 'logout' %}">{% csrf_token %}<button>退出</button></form>{% endif %}
</header><main>{% block content %}{% endblock %}</main></body></html>templates/registration/login.html
html
{% extends "base.html" %}
{% block content %}<h1>登录任务板</h1>
<form method="post">{% csrf_token %}{{ form.as_p }}
<input type="hidden" name="next" value="{{ next }}"><button>登录</button></form>{% endblock %}templates/tasks/list.html
html
{% extends "base.html" %}
{% block content %}<h1>我的任务</h1>
<nav><a href="?state=all">全部</a> <a href="?state=open">未完成</a> <a href="?state=done">已完成</a> <a href="{% url 'tasks:create' %}">新建</a></nav>
<ul>{% for task in tasks %}<li>{{ task.title }} — {{ task.done|yesno:"已完成,未完成" }}
<a href="{% url 'tasks:edit' task.pk %}">编辑</a>
<form method="post" action="{% url 'tasks:delete' task.pk %}">{% csrf_token %}<button>删除</button></form>
</li>{% empty %}<li>当前筛选没有任务。</li>{% endfor %}</ul>{% endblock %}templates/tasks/form.html
html
{% extends "base.html" %}
{% block content %}<h1>{% if task %}编辑任务{% else %}新建任务{% endif %}</h1>
<form method="post">{% csrf_token %}{{ form.as_p }}<button>保存</button></form>
<a href="{% url 'tasks:list' %}">返回列表</a>{% endblock %}tasks/tests.py
py
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from .models import Task
class TaskboardTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.alice = get_user_model().objects.create_user(username="alice", password="local-test-password")
cls.bob = get_user_model().objects.create_user(username="bob", password="local-test-password")
cls.foreign = Task.objects.create(owner=cls.bob, title="Bob private")
def setUp(self):
self.client.force_login(self.alice)
def test_anonymous_redirects(self):
self.client.logout()
response = self.client.get(reverse("tasks:list"))
self.assertEqual(response.status_code, 302)
self.assertIn(reverse("login"), response.url)
def test_crud_and_filters(self):
response = self.client.post(reverse("tasks:create"), {"title": "Read ORM"})
self.assertRedirects(response, reverse("tasks:list"))
task = Task.objects.get(owner=self.alice)
self.assertContains(self.client.get(reverse("tasks:list")), "Read ORM")
self.assertNotContains(self.client.get(reverse("tasks:list")), "Bob private")
self.client.post(reverse("tasks:edit", args=[task.pk]), {"title": "Read forms", "done": "on"})
task.refresh_from_db()
self.assertTrue(task.done)
self.assertEqual(task.title, "Read forms")
self.assertNotContains(self.client.get(reverse("tasks:list"), {"state": "open"}), "Read forms")
self.assertContains(self.client.get(reverse("tasks:list"), {"state": "done"}), "Read forms")
self.assertEqual(self.client.get(reverse("tasks:delete", args=[task.pk])).status_code, 405)
self.assertRedirects(self.client.post(reverse("tasks:delete", args=[task.pk])), reverse("tasks:list"))
self.assertFalse(Task.objects.filter(pk=task.pk).exists())
def test_other_owner_cannot_read_edit_delete(self):
for name in ["tasks:edit", "tasks:delete"]:
url = reverse(name, args=[self.foreign.pk])
self.assertEqual(self.client.post(url, {"title": "stolen"}).status_code, 404)
self.assertEqual(self.client.get(reverse("tasks:edit", args=[self.foreign.pk])).status_code, 404)
self.foreign.refresh_from_db()
self.assertEqual(self.foreign.title, "Bob private")
def test_invalid_duplicate_and_forged_owner(self):
url = reverse("tasks:create")
self.assertEqual(self.client.post(url, {}).status_code, 200)
for title in [" ", "x" * 121]:
self.assertEqual(self.client.post(url, {"title": title}).status_code, 200)
self.assertEqual(Task.objects.filter(owner=self.alice).count(), 0)
self.client.post(url, {"title": "Unique", "owner": self.bob.pk})
self.assertTrue(Task.objects.filter(title="Unique", owner=self.alice).exists())
self.assertContains(self.client.post(url, {"title": "Unique"}), "保存冲突")
self.assertEqual(Task.objects.filter(owner=self.alice).count(), 1)
self.assertEqual(self.client.get(reverse("tasks:list")).status_code, 200)
task = Task.objects.get(owner=self.alice, title="Unique")
edit_url = reverse("tasks:edit", args=[task.pk])
self.assertRedirects(self.client.post(edit_url, {"title": "Unique"}), reverse("tasks:list"))
Task.objects.create(owner=self.bob, title="Unique")
self.assertRedirects(self.client.post(edit_url, {"title": "Unique"}), reverse("tasks:list"))
Task.objects.create(owner=self.alice, title="Taken")
self.assertContains(self.client.post(edit_url, {"title": "Taken"}), "保存冲突")
task.refresh_from_db()
self.assertEqual(task.title, "Unique")
self.assertEqual(Task.objects.filter(owner=self.alice).count(), 2)
self.assertEqual(self.client.get(reverse("tasks:list")).status_code, 200)
def test_csrf_rejected_and_valid_token_accepted(self):
client = Client(enforce_csrf_checks=True)
client.force_login(self.alice)
url = reverse("tasks:create")
self.assertEqual(client.post(url, {"title": "No token"}).status_code, 403)
self.assertFalse(Task.objects.filter(title="No token").exists())
client.get(url)
token = client.cookies["csrftoken"].value
self.assertEqual(client.post(url, {"title": "With token", "csrfmiddlewaretoken": token}).status_code, 302)
self.assertTrue(Task.objects.filter(title="With token", owner=self.alice).exists())
def test_login_and_logout_methods(self):
self.client.logout()
self.assertTrue(self.client.login(username="alice", password="local-test-password"))
self.assertEqual(self.client.get(reverse("logout")).status_code, 405)
self.assertRedirects(self.client.post(reverse("logout")), reverse("login"))README.md
md
# Django 5.2 个人任务板
示例未执行。交付仅做 AST、文件引用、Markdown/Vue 模板和测试规格静态检查,没有安装 Django,没有创建数据库或启动服务。目标 Python 3.12、Django 5.2.18、SQLite;后续运行前自行核对补丁维护状态。
功能:内置用户登录与 POST 退出,个人任务新增、列表、完成状态筛选、编辑和 POST 删除;所有读写通过 owner 过滤;标题必填、最多120字符,同一用户标题唯一。后台仅用于可信管理员;没有开放注册或多租户管理员隔离。重复标题返回表单错误,不保存第二条。
## 未来自行运行(本轮未执行)
PowerShell,在本目录:
```powershell
py -3.12 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
$env:DJANGO_SECRET_KEY = 'replace-with-long-local-random-value'
$env:DJANGO_DEBUG = '1'
$env:DJANGO_ALLOWED_HOSTS = '127.0.0.1,localhost,testserver'
.\.venv\Scripts\python.exe manage.py check
.\.venv\Scripts\python.exe manage.py makemigrations --check --dry-run
.\.venv\Scripts\python.exe manage.py migrate
.\.venv\Scripts\python.exe manage.py createsuperuser
.\.venv\Scripts\python.exe manage.py test
.\.venv\Scripts\python.exe manage.py runserver 127.0.0.1:8000
```
macOS/Linux Bash/Zsh,在本目录:
```bash
python3.12 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
export DJANGO_SECRET_KEY='replace-with-long-local-random-value'
export DJANGO_DEBUG=1
export DJANGO_ALLOWED_HOSTS='127.0.0.1,localhost,testserver'
.venv/bin/python manage.py check
.venv/bin/python manage.py makemigrations --check --dry-run
.venv/bin/python manage.py migrate
.venv/bin/python manage.py createsuperuser
.venv/bin/python manage.py test
.venv/bin/python manage.py runserver 127.0.0.1:8000
```
`.env.example` 仅示意变量,代码不自动读取 `.env`。`migrate` 会创建本目录 `db.sqlite3`,迁移源文件手写,需自行用 `makemigrations --check --dry-run` 核对漂移。开发服务器只绑定回环地址。
浏览器打开 http://127.0.0.1:8000/ 应跳转登录;使用创建的用户登录,然后新增“Read ORM”,编辑为完成,筛选完成列表并删除。可通过 admin 的 Users 创建第二个普通用户(设置密码),两用户互不可见任务。管理员后台有跨用户管理能力,不等同普通任务页。
预期测试:六个测试方法全部成功;覆盖 CRUD、筛选、匿名、所有权、空POST/空白/超长、新增和编辑重复冲突、保留原标题与跨用户同名、伪造 owner、CSRF 缺失及有效 token、退出方法限制。普通 TestCase 的客户端默认不强制 CSRF,因此专门建立 enforce_csrf_checks 客户端。测试数据库由 Django 测试运行器管理;本轮未创建。
这不是生产部署配置:部署前提供 WSGI/ASGI 宿主、静态资源、HTTPS、Cookie 策略、数据库备份和密钥管理,运行部署检查并补齐部署环境测试。本工程未验证并发容量、跨平台运行或真实数据库迁移。人工验收用例和预期页面
| 操作 | 预期 | 必须额外核对 |
|---|---|---|
| 未登录打开首页 | 跳转登录并保留 next | 不展示任务内容 |
| Alice新增标题 | 返回列表 | owner由服务器指定 |
| 提交空白或121字符标题 | 停留表单并显示错误 | 不新增记录 |
| Alice新增已有标题 | 显示保存冲突 | 原任务保留、仍可查询 |
| Bob访问Alice编辑/删除地址 | 404 | 原任务未改、未删 |
| GET请求删除或退出 | 405 | 状态不变 |
| 缺失CSRF的有效登录POST | 403 | 未写数据 |
默认测试用户仅在测试数据库内创建;README 的 createsuperuser 用于未来人工登录。源码没有固定生产账号或密码,测试密码只用于未执行规格。列表不分页,适合小型教学数据;扩大数据量应先定义分页顺序、并发更新与审计要求,再修改实现。
独立练习
不用改 owner 字段实现用户隔离,先独立写出“保留原标题编辑自身成功”的规格,再对照tasks/tests.py本轮补充的断言;同时说明另一个用户的同名任务为何不应冲突。解释为什么重复标题表单错误后应仍能查列表。
先写出判断依据,再核对下节。
参考解答
编辑先按 owner 取实例,再绑定原实例;同一条记录保持标题应允许,另一个用户的同名标题也允许。事务异常已离开 atomic 才处理,连接恢复可继续查询。
版本与资料边界
核对日期2026-10-17。官方5.2.18发布页标注2026-10-06,官方支持表列5.2 LTS扩展支持至2028年4月。这里固定教学补丁,不声称以后仍是最新版本;未来自跑应先检查受支持补丁并重新验收。Windows PowerShell、macOS/Linux Bash/Zsh的命令已在完整工程README分开给出,三平台均未运行。
继续阅读
- 知识专题
- 排错
- 排错