Django任务管理系统开发实战:从ORM模型到Linux部署全记录

Django任务管理系统开发实战:从ORM模型到Linux部署全记录 简介这是一套基于Django框架开发的轻量级任务管理系统面向Python Web初学者、Django入门开发者及小型团队日常事务管理需求解决个人或协作场景下的任务创建、跟踪、提醒与状态归档问题。资源包共83个文件涵盖29个核心Python模块含models、views、forms、signals等完整Django应用结构、11个HTML模板实现任务列表分页、详情渲染、用户中心等前端视图、2个CSS样式文件与1个JS脚本辅以34张界面截图含登录、任务编辑、钉钉提醒配置等关键操作实拍整体压缩包仅3.55MB结构清晰、开箱即用。已有249人学习下载配套提供requirements.txt依赖清单、crontab.sh定时任务脚本、LICENSE授权说明及README.md项目文档所有功能均经测试验证支持SQLite快速部署并集成钉钉机器人API实现任务到期自动推送是理解Django ORM、模板渲染、会话管理与第三方API对接的优质实践案例。 如果你刚好需要一个能快速跑起来的内部任务管理系统又不想继续在Excel里手动填状态、催进度Django是我目前试过最稳的选择。这篇文章不是官方文档的复述而是我把一个完整的任务管理系统打包成zip之后从模型设计到部署上线的整个过程中的记录和复盘。它适合有Python语法基础、想完整走一遍Django开发流程的人也适合刚接手类似项目、面对“任务管理”四个字不知道从哪下手的开发者。整个系统的核心并不复杂创建任务、分配负责人、设置截止时间、跟踪状态、按条件筛选。但就是这套看起来基础的功能背后牵扯到数据库建模、表单校验、状态流转、模板渲染、权限控制、静态文件处理还有最后一步的Linux部署。把这些串起来才算真正把一个Django项目从“能跑”变成“能用”。1. 技术选型Django在任务管理场景下的底气和边界1.1 对比Flask和FastAPI为什么最终留下Django我最早犹豫过要不要用Flask毕竟任务管理系统的API逻辑很少用Flask写起来似乎更轻。但后来我在纸上列了一下需求任务列表、任务详情、新建和编辑、删除、按状态筛选、用户登录后才看到自己的任务。这些功能如果全部手写Flask里要自己集成SQLAlchemy、Flask-Login、Flask-WTF、模板引擎一会儿装这个库一会儿配那个扩展还不如直接选一个“全家桶”。FastAPI在接口开发和异步场景里很舒服但任务管理这类系统更依赖服务端渲染页面FastAPI的异步优势在这个场景里几乎没有发挥空间。加上我还要用Django自带的后台管理去维护用户和任务数据Admin在内部工具里真的能省掉一大把重复开发时间。所以在技术选型上我的结论是纯API后端、高并发接口选FastAPI小工具、自定义路由很自由选Flask但如果是“需要一个正经业务系统、有用户体系、有后台管理、希望结构清晰”Django就是那个最不值得犹豫的选项。国内虽然Java和Node在业务系统里占据主流但Django在数据分析平台、内部运营后台、自动化运维前端这些场景里一直有稳定份额网上搜“python django国内使用广泛么”还能看到不少争论我的体感是你只要不指望用它撑起C端百万流量在中小型业务系统里它完全够用。1.2 Django的ORM和管理后台在内部工具里的真实收益选择Django的一个重要原因是ORM。任务管理系统的核心就是对任务做增删改查Django的ORM用Python类描述表结构生成迁移文件的时候自动同步数据库整个过程可以减少大量手写SQL。对于不会写复杂SQL的同事来说后续维护成本低很多。比如我用一行Task.objects.filter(assigneerequest.user, statustodo)就能查出“我名下的待办任务”换成原生SQL光是拼接条件就够烦的。另一个收益是Admin后台。很多人觉得Django Admin只是学习Demo里出现的玩具但实际上对内部系统来说Admin是免费的“数据后台”。任务状态被异常改乱、用户权限需要调整、某条任务需要直接该数据库字段进Admin页面几十秒就搞定不用专程写管理页面。Admin不是用来给普通用户用的而是给管理员兜底的。1.3 一个被很多人忽略的硬代价学习曲线和项目复杂度Django的缺点是项目结构感知门槛高。新手创建完项目会看到settings.py、urls.py、models.py、views.py、migrations目录每一个文件都有自己职责但刚接触时很难理解“为什么一个简单的任务列表要拆这么多文件”。我在开发过程中也一度觉得繁琐但等系统功能慢慢增加才发现这些约定是在帮我们控制混乱。如果你只是做一个十几个页面的小网站Django确实显得重但任务管理系统这种“看起来小、实际上会持续加功能”的项目Django的目录规范会在三个月后救你一次。我见过很多Flask项目写到后来所有路由堆在一个py文件里几千行没法看。Django的app机制强制你按业务模块拆分这本身就是一种收益。2. 项目骨架搭建从零到第一个可跑通的任务app2.1 环境准备虚拟环境、Python版本和Django版本的选择我开发时用的是Python 3.10和Django 4.2 LTS版。LTS版本官方会提供更长时间的安全维护对内部系统很重要。虚拟环境是必须的直接用python -m venv venv创建然后在Windows下执行venv\Scripts\activate在Linux下执行source venv/bin/activate。python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate pip install django4.2.*为什么不用最新的Django 5.x因为我在用的部分第三方库还没有完全跟上LTS版本跑生产更稳妥。等Django 5.2 LTS发版后再升级也不迟。2.2 startproject和startapp之后settings里必须改的几个地方创建项目和执行任务的app两条命令django-admin startproject task_manager cd task_manager python manage.py startapp tasksstartapp之后第一步不是写模型而是去settings.py里把tasks加进INSTALLED_APPS。这一步漏掉的话后续所有模型和迁移都不会生效而且报错提示还不太明显。INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, tasks, # 新增 ]接着要配置时区。任务管理系统的截止时间如果有时差问题会被同事骂死。settings里我设置成LANGUAGE_CODE zh-hans TIME_ZONE Asia/Shanghai USE_TZ True这里有个关键理解USE_TZTrue时Django往数据库里存的时间是UTC时间展示到模板时才按当前时区转换。所以查数据库看到时间比本地时间少8小时是正常现象不要急着改USE_TZFalse后面部署到服务器上你会更头疼。2.3 用一条命令确认开发服务器正常runserver的边界配置完成后执行python manage.py runserver能跑起来只能说明环境没问题不代表项目结构就没问题。开发服务器的runserver是单进程、自动重载适合本地调试完全不适合生产。我在开发初期喜欢在浏览器里访问http://127.0.0.1:8000确认页面正常但心里清楚这只是一个很薄的“开发态”。Django项目骨架搭建完成后我先做了一件事把默认的db.sqlite3删掉重新执行python manage.py migrate确保数据库迁移能反复执行。这个习惯帮我后续在部署时避免了很多“为什么我本地能跑服务器上不行”的尴尬。3. 任务模型与数据库操作把字段设计成能用十年的模样3.1 模型字段设计任务标题、优先级、状态、截止时间的取舍任务管理系统的核心是任务表我在tasks/models.py里先定义了一个枚举状态和一个基础任务模型。不要把一个任务的所有信息都塞进一个长文本字段里尽量保持字段单一职责。from django.db import models from django.contrib.auth.models import User from django.utils import timezone class Task(models.Model): class Status(models.TextChoices): TODO todo, 待办 DOING doing, 进行中 DONE done, 已完成 ARCHIVED archived, 已归档 class Priority(models.TextChoices): LOW low, 低 MEDIUM medium, 中 HIGH high, 高 title models.CharField(任务标题, max_length200) description models.TextField(任务描述, blankTrue) status models.CharField(状态, max_length20, choicesStatus.choices, defaultStatus.TODO) priority models.CharField(优先级, max_length20, choicesPriority.choices, defaultPriority.MEDIUM) assignee models.ForeignKey( User, verbose_name负责人, on_deletemodels.SET_NULL, nullTrue, blankTrue, related_nametasks, ) creator models.ForeignKey( User, verbose_name创建人, on_deletemodels.PROTECT, related_namecreated_tasks, ) due_date models.DateTimeField(截止时间, nullTrue, blankTrue) created_at models.DateTimeField(创建时间, auto_now_addTrue) updated_at models.DateTimeField(更新时间, auto_nowTrue) class Meta: ordering [created_at]这里有几个设计细节值得说明。assignee使用SET_NULL而不是CASCADE如果用户被删除已分配任务不能跟着消失否则整个历史记录都丢了。creator使用PROTECT创建人不允许直接删除防止有人误删数据导致关联任务全部失效。related_name别偷懒否则多对一反向查询时只能用task_set这种默认名字可读性很差。优先级字段一开始我纠结过要不要做成整数后来选了字符串枚举。原因是字符串比数字在代码里可读性好看到statustodo你就知道是待办看到status1还得去查表。虽然整数字段存储更省空间但对这种级别的系统存储成本完全可以忽略。3.2 auto_now_add、DateTimeField和时区你踩过的坑都在这里created_at我用的是auto_now_addTrue它会在创建对象时自动填入当前时间。注意它和defaulttimezone.now的区别auto_now_add在表单提交时不会出现在字段里而且后续Update不会变defaulttimezone.now是可以被你自己覆盖的。如果你需要支持“手动录入一个历史创建时间”就不能用auto_now_add必须改成defaulttimezone.now。due_date用DateTimeField(nullTrue, blankTrue)表示截止时间允许为空。你会发现数据库设计里“空值”和“空字符串”完全两码事。对时间字段空字符串会导致数据库报错所以我同时加了nullTrue, blankTrue。这是一个很多人会踩的坑null是数据库层面的blank是表单校验层面的两者都要写清楚。时区问题在开发时最容易让人怀疑人生。有一次我创建任务后看到列表页显示的时间比电脑时间快了8小时后来才发现是浏览器时区设置和系统时区不一致导致的。Django的解决方案是数据库只存UTC模板渲染时自动转换到TIME_ZONE指定的时区。所以调试时不要死盯数据库里的时间而是看页面渲染后的时间。3.3 查询、更新和删除ORM操作任务数据的正确姿势任务系统每天最多的操作就是查询。用ORM写查询时我总结出几个常用套路。查当前用户的所有待办任务按优先级排序tasks Task.objects.filter(assigneerequest.user, statusTask.Status.TODO).order_by(-priority, due_date)获取单条任务如果找不到就404from django.shortcuts import get_object_or_404 task get_object_or_404(Task, pktask_id, assigneerequest.user)这里把assigneerequest.user也加进去是为了做权限控制普通用户只能操作自己的任务即使知道别人的任务ID也拿不到数据。更新任务状态task.status Task.Status.DOING task.save(update_fields[status, updated_at])update_fields可以限定更新哪些字段避免执行全量UPDATE也减少以后无意中覆盖其他字段的风险。删除任务用的就是task.delete()。但真要大规模清理任务时注意删除操作会触发数据库级联外键关联的数据可能一并被删。我在Admin后台处理过一次批量删除差点把某用户的历史任务关联记录全清掉。所以后来我做了“归档”功能把不需要的任务状态改为ARCHIVED而不是真正物理删除。物理删除对数据审计不友好能不做就不做。热搜词里的“django执行查询-删除对象”其实就是QuerySet的filter().delete()。比如清理三个月前已完成且无关联的记录Task.objects.filter(statusTask.Status.DONE, completed_at__lttimezone.now() - timedelta(days90)).delete()这种操作要先在事务里看影响行数我习惯先执行count()确认范围再执行delete()。4. 业务逻辑组织视图、表单与状态流转的约束4.1 用ModelForm替代手写表单校验代码至少减半创建一个任务页面如果手写HTML表单和校验逻辑会非常啰嗦。Django的ModelForm能根据模型字段自动生成表单控件和校验规则。from django import forms from .models import Task class TaskForm(forms.ModelForm): class Meta: model Task fields [title, description, status, priority, assignee, due_date] widgets { due_date: forms.DateTimeInput(attrs{type: datetime-local}), description: forms.Textarea(attrs{rows: 4}), } def clean_due_date(self): due_date self.cleaned_data.get(due_date) if due_date and due_date timezone.now(): raise forms.ValidationError(截止时间不能早于当前时间) return due_date注意widgets里给截止时间加了一个datetime-local输入类型浏览器会弹出本地时间选择器。但datetime-local提交的格式是2025-03-15T10:30不是Django默认的%Y-%m-%d %H:%M:%S所以要在表单初始化时做个转换或者用自定义的日期格式。我这边的做法是直接给表单字段设置input_formatsdue_date forms.DateTimeField( label截止时间, requiredFalse, widgetforms.DateTimeInput( attrs{type: datetime-local}, format%Y-%m-%dT%H:%M, ), input_formats[%Y-%m-%dT%H:%M, %Y-%m-%d %H:%M:%S], )这个细节如果不处理前端提交后大概率会抛“Enter a valid date/time”错误。很多教程没说这个但实际开发中一定会遇到。4.2 状态流转只允许待办到进行中再到完成怎么在代码里守住任务管理系统中状态不应该允许随意跳转。比如“已完成”的任务不应该再变回“待办”除非走返工流程。我在模型里直接提供了状态流转校验方法class Task(models.Model): # ...字段省略 def can_transition_to(self, new_status): allowed { self.Status.TODO: {self.Status.DOING, self.Status.ARCHIVED}, self.Status.DOING: {self.Status.DONE, self.Status.TODO}, self.Status.DONE: {self.Status.ARCHIVED}, self.Status.ARCHIVED: set(), } return new_status in allowed[self.status]然后视图里更新状态前先做校验if task.can_transition_to(new_status): task.status new_status task.save(update_fields[status, updated_at]) else: messages.error(request, 不允许从当前状态变更为该状态)有人说视图层写if判断就够了但把状态流转规则放到模型层好处是所有入口都会遵守Admin后台直接改状态时也绕不开校验只要在Admin里重写save_model。我后来还在Admin里避免直接裸改状态因为员工可能会跳过正常流转流程。4.3 FBV还是CBV任务系统里我的真实选择函数视图FBV和类视图CBV之争是Django社区的老问题。在任务管理系统里我大部分视图用了FBV因为逻辑直接、调试方便。举个例子任务列表页如果还要处理搜索关键词、状态筛选、分页FBV里一段代码就能看明白def task_list(request): tasks Task.objects.all() status request.GET.get(status, ) keyword request.GET.get(q, ).strip() if status: tasks tasks.filter(statusstatus) if keyword: tasks tasks.filter(title__icontainskeyword) paginator Paginator(tasks, 10) page_number request.GET.get(page) page_obj paginator.get_page(page_number) return render(request, tasks/task_list.html, {page_obj: page_obj})CBV的ListView写起来更短但一旦加多个筛选条件还是需要重写get_queryset可读性并不一定比FBV好。创建和编辑任务我用了CreateView和UpdateView因为这两个页面逻辑高度重复CBV能省不少样板代码。我的原则是页面逻辑简单且标准化用CBV有复杂业务判断用FBV。别为了用CBV而用CBV。5. 让页面真正好用模板继承、任务列表和交互细节5.1 模板继承和基础布局一个页面骨架撑起所有页面Django模板系统最大的价值就是继承。我建了一个base.html作为所有页面的骨架!DOCTYPE html html langzh-hans head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title{% block title %}任务管理系统{% endblock %}/title {% load static %} link relstylesheet href{% static css/style.css %} /head body nav a href{% url task_list %}任务列表/a a href{% url task_create %}新建任务/a {% if user.is_authenticated %} span当前用户{{ user.username }}/span a href{% url logout %}退出/a {% else %} a href{% url login %}登录/a {% endif %} /nav main {% if messages %} ul classmessages {% for message in messages %} li{{ message }}/li {% endfor %} /ul {% endif %} {% block content %}{% endblock %} /main /body /html子模板只需要写block content其他结构不会重复。第一次做项目时我曾经在每个页面复制同样的导航栏结果改了导航栏就要改五个文件后来才觉悟模板继承的重要性。5.2 任务列表页筛选、排序和分页的实现任务列表页是最核心的展现层。我没有直接渲染所有任务而是先做一个状态的Tab切换全部、待办、进行中、已完成、已归档。每个Tab对应一个URL参数?statustodo在模板里根据当前参数高亮div classfilter-tabs a href{% url task_list %} class{% if not request.GET.status %}active{% endif %}全部/a a href{% url task_list %}?statustodo class{% if request.GET.status todo %}active{% endif %}待办/a a href{% url task_list %}?statusdoing class{% if request.GET.status doing %}active{% endif %}进行中/a a href{% url task_list %}?statusdone class{% if request.GET.status done %}active{% endif %}已完成/a /div排序我允许用户点击表头切换排序字段和方向URL参数类似?sortdue_dateorderasc。当时想得很简单但实际做下来要考虑防攻击不能直接拿sort参数拼进order_by必须做一个白名单映射。sort_mapping { due_date: due_date, priority: priority, created_at: created_at, updated_at: updated_at, } sort_field sort_mapping.get(request.GET.get(sort, due_date), due_date) if request.GET.get(order) desc: sort_field f-{sort_field} tasks tasks.order_by(sort_field)分页我用了Django内置的Paginator每页10条。page_obj对象可以很方便地输出上一页、下一页页码。内部任务系统数据量不大分页主要是防止页面一次性渲染太多导致卡顿也方便定位。5.3 交互细节表单提交、CSRF、提示消息和后端验证新建任务和编辑任务页面的表单提交必须带上{% csrf_token %}否则Django会直接拒绝请求。这件事新手经常忘一提交就403而且错误提示页还是英文的容易一头雾水。我在模板里固定写上form methodpost {% csrf_token %} {{ form.as_p }} button typesubmit保存/button /form保存成功后我用Django的messages框架给用户一个轻提示同时跳回任务列表页task form.save() messages.success(request, 任务创建成功) return redirect(task_list)交互上有一处容易被忽略编辑任务时表单字段会回显当前值但状态字段如果被当前用户无权修改应该用readonly或直接隐藏。我在表单里加了一段逻辑只有创建人或者管理员才能改状态其他人只能改描述。实现方式是在视图中判断用户角色后动态移除字段form TaskForm(request.POST or None, instancetask) if not request.user.is_superuser and not request.user task.creator: form.fields[status].disabled Truedisabled字段会阻止前端提交也不用担心恶意请求绕过HTML直接改POST参数Django会忽略被禁用的字段。6. 数据迁移与部署从Windows开发机到Linux服务器的实录6.1 收集依赖和迁移数据库打包前必须做的三件事本地开发环境是Windows部署目标是一台Linux服务器。很多新手直接把项目目录拷过去然后发现跑不起来。这里不是代码问题而是环境和依赖问题。第一件事生成requirements.txtpip freeze requirements.txt但pip freeze会有很多冗余包建议你自己维护一个干净的列表。我的requirements.txt大概是这样Django4.2.* gunicorn21.2.0 whitenoise6.6.0第二件事检查本地是否漏掉了迁移文件。开发过程中我新加了模型字段如果没有生成migrations里的新文件部署后的数据库同步就会失败。在Windows上执行python manage.py makemigrations python manage.py migrate然后把migrations目录整个打进zip包不要漏掉。第三件事把DEBUG改成False并设置ALLOWED_HOSTSDEBUG False ALLOWED_HOSTS [your-server-ip, your-domain.com]如果这里不配好部署后访问会出现DisallowedHost错误。本地开发从来没遇到过因为在DEBUGTrue时会自动允许localhost。这个改动必须在打包前完成否则到服务器上改完再重启又容易出幺蛾子。6.2 静态文件和媒体文件部署后页面变丑的根因本地开发时Django会自己处理静态文件runserver能直接从static目录加载CSS和JS。但部署到Linux上跑生产模式时Django默认不会托管静态文件页面就会变丑有时连CSS都加载不出来。我用的是whitenoise它能在纯Python环境下托管静态文件不需要额外配置Nginx静态目录。流程很简单安装whitenoise并在MIDDLEWARE里加一个位置靠前的中间件。执行python manage.py collectstatic把各个app的静态文件收集到STATIC_ROOT目录。MIDDLEWARE [ django.middleware.security.SecurityMiddleware, whitenoise.middleware.WhiteNoiseMiddleware, # ...其他中间件 ] STATIC_ROOT BASE_DIR / staticfiles STATIC_URL static/收集静态文件时要注意本地可能有多个app都有同名static目录collectstatic会覆盖或提示冲突。解决办法是在每个app下建static/app_name/来区分模板里引用时写static/tasks/css/style.css。媒体文件比如用户上传的头像、附件和静态文件不一样需要单独配置MEDIA_ROOT和MEDIA_URL生产环境一般由Nginx指向这个目录。任务管理系统中如果不用上传附件可以暂时忽略但如果你后续加了附件功能必须处理路径问题否则服务器重启后文件会丢。6.3 在NAS/服务器上跑起来系统d服务、Nginx反代和本地运行这次部署的目标Linux服务器我最终采用了“系统d服务 Nginx反向代理”的组合。任务管理系统不追求性能极致但需要稳定和方便维护。生产环境我用gunicorn作为WSGI服务器。启动命令gunicorn task_manager.wsgi:application --bind 127.0.0.1:8000 --workers 3--workers 3一般够用了不要盲目调高。每个worker都会占内存内部系统并发量不大3个进程足够。然后创建系统d服务文件/etc/systemd/system/task_manager.service[Unit] DescriptionTask Manager Django Application Afternetwork.target [Service] Userwww-data Groupwww-data WorkingDirectory/var/www/task_manager ExecStart/var/www/task_manager/venv/bin/gunicorn task_manager.wsgi:application --bind 127.0.0.1:8000 --workers 3 Restartalways [Install] WantedBymulti-user.target启用服务sudo systemctl daemon-reload sudo systemctl enable task_manager sudo systemctl start task_managerNginx配置文件里把外部80端口转发到127.0.0.1:8000同时托管静态文件server { listen 80; server_name your-domain.com; location /static/ { alias /var/www/task_manager/staticfiles/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }如果只是临时在NAS上试跑也可以直接用python manage.py runserver 0.0.0.0:8000但仅限于开发调试不建议长期跑。我在一台低功耗NAS上用虚拟环境跑过一段时间数据量不大时完全没问题但一旦重启机器服务不会自动拉起必须配好系统d服务或Docker。跨平台部署时还有一个很隐蔽的坑项目路径中的中文或空格。我第一次打包zip时把目录名写成了“任务管理系统”结果Linux服务器解压后路径里带中文Nginx和systemd解析都出了奇怪问题。后来全部改成英文目录名世界就清净了。7. 复盘这套任务系统在真实使用中的八个问题和解决过程7.1 问题admin后台密码忘了怎么办系统上线第二天管理员告诉我后台进不去了。原因大概率是密码设太复杂自己也记不住。在服务器上执行python manage.py shell进入交互环境后from django.contrib.auth.models import User u User.objects.get(usernameadmin) u.set_password(newpassword) u.save()用Django的createsuperuser命令也可以新建一个管理员但要记得旧账号可能还占着数据。我后来写了一个运维脚本每天备份数据库并把Admin密码恢复流程写进团队文档。对于内部系统这种“自救”方式比重装数据库省事得多。7.2 问题创建时间总是差8小时第一版上线后同事反馈“我刚刚创建的任务显示时间却是几个小时前”。查数据库后发现数据库里存储的是UTC时间页面展示时没有正确转换。原因是settings里TIME_ZONE没有设置成Asia/Shanghai。TIME_ZONE Asia/Shanghai USE_TZ True设置之后模板里显示时会自动转成北京时间但要注意已经存进数据库的历史数据不会自动重新转换。如果历史数据的时间被错误写入本地时间需要写脚本修复。所以项目一开始就要把时区配置好不要等上线后才发现。7.3 问题queryset被重复执行导致性能变慢任务列表页一开始很流畅后来数据量到了几千条时变卡。排查发现一个问题把任务列表查出来后在模板里对每个任务都做了一个反向关联查询导致N1查询问题。Django中可以用select_related和prefetch_related来解决tasks Task.objects.select_related(assignee, creator).filter(...)如果还要在页面上展示每个任务关联的评论列表就得用prefetch_relatedtasks Task.objects.prefetch_related(comments).filter(...)select_related适合外键和多对一prefetch_related适合多对多和反向关联。加上之后任务列表页的SQL数量从几十条降到了几条。7.4 问题删除任务时外键关联报错有个任务已经被评论和附件引用直接删除时Django抛了ProtectedError。因为我creator字段用的on_deletemodels.PROTECT这是故意的。解决办法是给任务模型增加一个“归档”操作而不是删除。在视图里task.status Task.Status.ARCHIVED task.save(update_fields[status, updated_at])页面上的“删除”按钮对于有关联数据的任务就不显示或者点击后先弹确认框“该任务有N条关联评论归档后将不再展示确定继续”。归档的另一个好处是保留审计记录后续如果有人想回头看历史任务还能在“已归档”标签页里找到。整条链路走下来这个任务管理系统从最初只有一张任务表慢慢长出用户筛选、状态流转、归档机制、后台管理这些功能。它不是一个大项目但每一步都踩在Django最常用的能力上。如果你也想拿Django练手或做内部工具我建议不要一开始就追求微服务或者前后端分离先把这套“服务端渲染ORMAdmin”的经典组合跑通很多业务需求在这个架构下已经能解决得相当舒服。本文还有配套的精品资源点击获取