ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Django校园选修课程管理系统实战:从模型设计到部署上线

Django校园选修课程管理系统实战:从模型设计到部署上线 开学季的教务处有多忙我估计不少人在大学里都见识过学生抱着手机蹲点抢课教务老师面对几百份纸质退改申请一边盖章一边登记任课教师拿着Excel表到处收成绩单。去年帮朋友搭一个校园选修课程管理系统用的就是Django正好把这段经验写成一篇项目拆解标题就叫“django校园选修课程管理系统”。这个系统不复杂但麻雀虽小五脏俱全覆盖了权限、多对多选课、状态流、报表统计这些典型场景很适合Python初学者、Django刚入门的同学拿来练手也适合正在做课设或毕设的人参考整体思路。源码我已经整理好了文章里会同步把关键实现讲透。1. 先把业务理清楚这个系统到底要管什么1.1 三类用户三种完全不同的需求选课系统表面上只有一个“选课”动作但真正落到校园场景里参与者至少有三类角色而且彼此诉求冲突。学生想要的是快速浏览课程、一键选课、退课方便老师想要的是发布课程、查看选课名单、录入成绩教务处管理员想要的则是审核课程、控制选课人数、统计开课数据和成绩分布。如果一开始就把三个角色混在一起设计后面改起来会很痛苦。我当时的设计思路是先把“角色—权限—操作”这个铁三角列清楚再倒推数据表结构。学生端核心操作是浏览课程、提交选课、退选、查看自己已选课程和成绩教师端核心操作是申请开课、维护课程信息、查看选课名单、录入成绩管理员端就是最重的包括用户管理、课程审核、选课开关控制、开课统计、成绩归档。这一步花了不少时间但非常值得后面写视图函数基本就是在照着这张表填代码。1.2 功能清单要能做减法很多初学者拿到这类题目就去堆功能什么论坛、公告、问答全塞进来结果代码写了一堆核心流程反而不稳。我建议主干只保留五件事用户认证与权限区分、课程信息发布与管理、选课退课与容量控制、成绩录入与查询、基本的数据统计。这五件事做完系统已经能在真实的小型教务场景里跑起来。至于消息通知、学生评教、课程表导出属于锦上添花等主干稳定了再逐步迭代。这也符合我后来一直坚持的习惯一个1.0版本的系统一定是把最痛的痛点解决掉而不是把想象中所有可能用到的功能都铺开。选课系统最痛的就是“抢课高峰期不出错”和“退选补选数据别乱”先把这两个点砸实。1.3 技术选型为什么是Django技术选型上我不排斥Flask但Django在这个项目上确实更省心。首先用户认证模块是现成的Auth框架自带User模型、Session管理、密码加密其次Admin后台几乎是白送的管理界面只要把模型注册进去管理员能直接在后台上做数据的增删改查第三ORM非常成熟尤其适合这类以关系模型为主的业务系统多对多选课关系用起来特别顺手最后是Django的模板和静态资源机制配合Bootstrap这类前端库能比较快地做出一个能看的界面不必单独搭前后端分离。还有一点容易被忽略Django对新手更友好的原因在于它的“约定优于配置”项目结构高度统一你需要做的不是自己想一套结构而是在官方结构里填充业务代码。对课题组或者毕业设计来说后期接手的人学习成本低。2. 数据模型设计选课系统的地基怎么打2.1 核心模型与字段设计这个项目我用了四个核心模型分别是User扩展自Django自带用户、StudentProfile学生扩展信息、Course课程、Selection选课记录外加一个CourseCategory做课程分类。设计时有个原则能用现有User的就不要重建用户表业务扩展信息单独用一对一表关联避免动到Django强悍的认证逻辑。先看学生扩展模型核心代码如下from django.db import models from django.contrib.auth.models import User class StudentProfile(models.Model): user models.OneToOneField(User, on_deletemodels.CASCADE, verbose_name用户) student_no models.CharField(学号, max_length20, uniqueTrue) major models.CharField(专业, max_length50) grade models.CharField(年级, max_length20, blankTrue) phone models.CharField(联系电话, max_length20, blankTrue) class Meta: db_table student_profile verbose_name 学生信息 verbose_name_plural verbose_name def __str__(self): return f{self.student_no} - {self.user.get_full_name() or self.user.username}Course模型是系统的中心节点字段上要注意容量、学分、上课时间、选课状态这几个核心属性。特别是status字段我用了三个值来表示课程的三个状态1代表待审核2代表审核通过可选3代表已结束不允许再选。上课时间字段我建议用一个文本字段按约定格式存储比如“周一 3-4节|周三 5-6节”简单直接查询冲突时再解析判断避免做一个复杂的时间表关联模型把系统搞重。class Course(models.Model): STATUS_CHOICES ( (1, 待审核), (2, 审核通过), (3, 已结束), ) name models.CharField(课程名称, max_length100) category models.ForeignKey(CourseCategory, on_deletemodels.SET_NULL, nullTrue, verbose_name课程分类) teacher models.ForeignKey(User, on_deletemodels.CASCADE, related_namecourses, verbose_name授课教师) credit models.DecimalField(学分, max_digits3, decimal_places1) capacity models.PositiveIntegerField(容量, default60) selected_count models.PositiveIntegerField(已选人数, default0) schedule models.CharField(上课时间, max_length100, help_text示例周一 3-4节|周三 5-6节) classroom models.CharField(上课地点, max_length100) start_week models.PositiveIntegerField(开始周, default1) end_week models.PositiveIntegerField(结束周, default16) status models.SmallIntegerField(状态, choicesSTATUS_CHOICES, default1) description models.TextField(课程简介, blankTrue) created_at models.DateTimeField(创建时间, auto_now_addTrue) class Meta: db_table course verbose_name 课程 verbose_name_plural verbose_name2.2 多对多选课关系要不要单独建表很多人会用ManyToManyField直接把User和Course关联起来这样确实简单但遇到“记录选课时间”“保存退课原因”“期末存成绩”这些需求时多对多中间表就变成鸡肋了。我选了独立建Selection表本质上是一个带额外字段的中间模型这也是官方文档里推荐的进阶做法。class Selection(models.Model): student models.ForeignKey(User, on_deletemodels.CASCADE, related_nameselections, verbose_name学生) course models.ForeignKey(Course, on_deletemodels.CASCADE, related_nameselections, verbose_name课程) selected_at models.DateTimeField(选课时间, auto_now_addTrue) score models.DecimalField(成绩, max_digits5, decimal_places1, nullTrue, blankTrue) class Meta: db_table selection verbose_name 选课记录 verbose_name_plural verbose_name unique_together (student, course)整张表的设计核心是unique_together约束这是防重复选课的数据库层最后一道闸门。平时关系型数据库里最容易出现的就是脏数据这里用数据库约束硬卡住比在代码里反复query要可靠得多。2.3 状态机与定时任务的取舍课程有“待审核—审核通过—已结束”这套状态机学生的选课记录则是“已选—退选—已出成绩”。我一开始想在Django里引入django-fsm库来管理状态流转后来觉得杀鸡用牛刀直接在视图函数里面检查状态、更新状态配合Django自带的transaction.atomic保证事务就行。选课系统一旦做复杂很容易陷入过度设计。这里我强烈建议新手同学不要在一开始就引入各种通用状态机库先把基本的if判断写好等真的出现状态混乱、入口太多不好维护时再考虑做状态模式重构。3. 核心功能实现登录、选课、退选、录成绩的关键代码3.1 用户登录与权限区分Django自带login_required和user_passes_test足够实现角色区分了。具体做法是登录后读user的is_staff字段True就跳教师端或者管理后台False就再查有没有StudentProfile有则跳学生端。简单粗暴但够用。from django.contrib.auth import authenticate, login from django.shortcuts import render, redirect def user_login(request): if request.method POST: username request.POST.get(username) password request.POST.get(password) user authenticate(request, usernameusername, passwordpassword) if user is not None: login(request, user) if user.is_staff: return redirect(admin_dashboard) return redirect(student_dashboard) return render(request, login.html, {error: 用户名或密码错误}) return render(request, login.html)这里有个细节教师也可以用is_staff来识别因为教师确实需要进入后台发布课程。而学生账号如果不是工作人员is_staff为False。如果学校场景里有助教或者教务员这类中间角色就用Django自带的Group和Permission不建议自己再造一套权限表。3.2 学生端选课容量检查与冲突检测选课是整个系统最核心的路径也是并发压力最大的地方。我还记得第一次写完选课功能时自己开两个浏览器窗口同时点选同一门课结果容量60的课被选进去70多个人数据一片混乱。后来老老实实加上事务行级锁才把这个坑填平。容量检查的核心思路是在事务里先通过select_for_update锁住Course行然后读取selected_count做判断再写Selection记录。这样并发的两个请求不会同时读到同样的旧人数。from django.db import transaction from django.core.exceptions import ValidationError transaction.atomic def select_course(request, course_id): student request.user course Course.objects.select_for_update().get(pkcourse_id) if course.status ! 2: raise ValidationError(该课程当前不可选) if course.selected_count course.capacity: raise ValidationError(课程容量已满) if Selection.objects.filter(studentstudent, coursecourse).exists(): raise ValidationError(你已经选过这门课程) # 时间冲突检查简化为解析schedule字符串 conflict check_schedule_conflict(student, course) if conflict: raise ValidationError(f与课程《{conflict.name}》时间冲突) Selection.objects.create(studentstudent, coursecourse) course.selected_count 1 course.save(update_fields[selected_count])def check_schedule_conflict(student, new_course): existing_selections Selection.objects.filter(studentstudent, course__status2).select_related(course) new_time_set parse_schedule(new_course.schedule) for sel in existing_selections: if sel.course_id new_course.pk: return sel.course old_time_set parse_schedule(sel.course.schedule) if new_time_set old_time_set: return sel.course return Noneparse_schedule这个函数是把“周一 3-4节”这类字符串解析成可比较的元组集合大致的实现思路是用split切分再映射星期和节次的数字编号。3.3 退选要处理的不只是删除记录退选是选课的逆操作但很多人只写了删除Selection记录忘了恢复selected_count。更隐蔽的问题是如果一门课已经结束还允许学生随意退课那成绩数据就乱了。所以我在退选函数里加了判断课程状态为2才允许退状态为3时走“退课申请”流程。另外退选最好也包事务删除记录和减少计数都必须同时生效。transaction.atomic def drop_course(request, selection_id): selection Selection.objects.select_related(course).get(pkselection_id, studentrequest.user) if selection.course.status ! 2: raise ValidationError(该课程已结束不能直接退选) course Course.objects.select_for_update().get(pkselection.course_id) selection.delete() course.selected_count - 1 course.save(update_fields[selected_count])这里要特别说明一下select_related和select_for_update的配合select_related先做连表查询避免后续再查一次courseselect_for_update本身要求锁住查询对象两者不冲突。实际在MySQL InnoDB下加锁的SQL会对命中的行加写锁直到事务提交才释放可以有效解决并发退选时count减少被覆盖的问题。3.4 教师录入成绩一页表格的批量提交教师端最常见的痛点是逐个录入成绩太慢我直接用了一个for循环在POST请求里批量读取成绩字段。页面上用Django模板循环课程的学生名单每个学生一行inputname格式是“score_学生ID”后端解析的时候用request.POST.getlist()或循环构造。还要注意成绩范围校验不能小于0也不能大于100小数位限制1位。def enter_scores(request, course_id): course Course.objects.get(pkcourse_id, teacherrequest.user) if request.method POST: selections Selection.objects.filter(coursecourse) for sel in selections: score_key fscore_{sel.id} raw_score request.POST.get(score_key, ).strip() if raw_score: try: score_val round(float(raw_score), 1) if not 0 score_val 100: raise ValueError sel.score score_val sel.save(update_fields[score]) except ValueError: pass return redirect(course_detail, course_idcourse.pk) selections Selection.objects.filter(coursecourse).select_related(student) return render(request, teacher/enter_scores.html, {course: course, selections: selections})写这个功能时我还特意加了一个“成绩单”视图按分数段统计这门课的人数方便教师看一眼班级分布。管理员端也有一个按课程分类统计选课人数的图表这里用简单的字典循环就能做出来不一定要引入ECharts之类的大库。4. 上线部署从本地运行到waitressnginx4.1 本地开发环境配置这个项目我本地用的是Python 3.11Django 4.2数据库开发环境直接用SQLite但生产环境切到MySQL或PostgreSQL。切换数据库时最需要注意的就是模型里的verbose_name和索引Django的migrations能帮你同步大部分结构但像JSON字段、全文索引这类特性在不同数据库下能力边界不同建议早期就确定生产数据库别到最后再换。requirements.txt大致如下Django4.2.7 mysqlclient2.2.0 waitress2.1.2开发阶段跑起来很简单python manage.py makemigrations python manage.py migrate python manage.py createsuperuser python manage.py runserver 0.0.0.0:80004.2 Windows Server上用waitress托底之前帮一个小学期项目部署过一套系统服务器是Windows没法上gunicorngunicorn在Windows下支持很差后来用了waitress。waitress是一个纯Python的WSGI服务器安装简单、跨平台性能对校园系统这种规模完全够用。启动命令可以写成一个bat脚本waitress-serve --listen0.0.0.0:8000 myproject.wsgi:application用Django自带的runserver跑生产是绝对不行的runserver是多线程但没做进程管理而且默认的静态文件处理在生产模式直接失效。所以我这里用了waitress托管Django应用再用nginx做反向代理和静态文件服务。Windows下nginx的配置和Linux类似但要注意路径用/还是\以及进程是否被防火墙挡住。nginx核心配置片段server { listen 80; server_name your.domain.com; location /static/ { alias D:/projects/course_system/static/; } location /media/ { alias D:/projects/course_system/media/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里必须提醒一个最大的坑Django把DEBUG改成False之后静态文件不会由Django自动服务。所以务必先执行python manage.py collectstatic把Admin后台的静态文件收集到STATIC_ROOT目录再交给nginx alias指向这个目录否则你会看到后台页面完全没有样式白底黑字特别丑。4.3 环境变量管理与敏感信息保护系统里涉及数据库密码、SECRET_KEY等敏感信息直接写在settings.py里等于把钥匙挂在门口。我项目里用django-environ读.env文件再结合Git忽略.env确保源码目录里不泄露生产配置。尤其是有“附源码”需求的项目发布源码前一定要把本地配置替换成示例配置。import environ env environ.Env() environ.Env.read_env(.env) SECRET_KEY env(DJANGO_SECRET_KEY, defaultunsafe-dev-key) DEBUG env.bool(DJANGO_DEBUG, defaultTrue) DATABASES { default: { ENGINE: env(DB_ENGINE, defaultdjango.db.backends.sqlite3), NAME: env(DB_NAME, defaultos.path.join(BASE_DIR, db.sqlite3)), USER: env(DB_USER, default), PASSWORD: env(DB_PASSWORD, default), HOST: env(DB_HOST, default), PORT: env(DB_PORT, default), } }4.4 生产环境关闭调试信息Django默认的错误页面在DEBUGTrue时会展示完整的堆栈信息这在生产环境无异于是裸奔。部署时把DEBUG设为False后还需要配置ALLOWED_HOSTS否则会报DisallowedHost错误。同时要把ADMINS和SERVER_EMAIL配上出错了能第一时间收到日志邮件。再配合LOG_DIR用RotatingFileHandler按天滚动日志排查问题时能按时间定位。5. 踩过的坑与排查技巧一串真实问题实录5.1 模板里加载不到static文件这个几乎每天都会遇到。新手最常见的错误是{% load static %}写在模板底部或者STATIC_URL配置的时候结尾少了斜杠。还有一个bug特别隐蔽在Django的模板里如果某个HTML文件中引用了CSS路径写成background: url(../images/bg.jpg)浏览器解析路径时是相对于当前HTML的URL而不是相对于项目根目录很容易404。后面统一改成用Django模板语法硬编码完整URL再配合collectstatic彻底解决。5.2 多对多保存顺序问题使用form.save_m2m()时必须先save主模型对象然后调用save_m2m否则会报“RelatedObjectDoesNotExist”这类错误。如果你用ModelForm处理多对多关系Django的常规做法是先实例化form检查is_valid再form.save()但遇到需要在保存后继续处理数据时很容易忘记调用save_m2m。这个错误我建议直接写在项目README里面免得后人踩同样的坑。5.3 ORM查询里误用对象而不是id写Seletion查询时有人喜欢写filter(student_iduser.id)也有人写成filter(studentuser)。两种写法都能跑但新手容易把一个具体对象传进filter里的整型字段导致TypeError。以后写代码多留意ORM的翻译逻辑如果是ForeignKey字段可以传实例、实例ID或QuerySet但别直接传了个字符串。5.4 CSRF验证失败请求被中断这种问题绝大多数是因为模板里的标签忘记写{% csrf_token %}。Django默认开启了CSRF中间件遇到POST请求必须带token。这个机制对用户是透明的只要你模板里写了标签。但如果你用了Ajax提交就得在JS里通过Cookie读取csrftoken再塞进请求头。一个小技巧是可以自己写一个fetch的统一封装自动带X-CSRFToken一劳永逸。5.5 并发选课导致selected_count不准前面已经说到了select_for_update的解法但如果用的不是InnoDB而是MyISAM是锁不住行的。所以生产环境数据库必须用InnoDB或PostgreSQL。另外如果用Redis做缓存优化读操作那写入的时候要留意缓存失效策略最好的办法是选课事务提交后再删掉对应的缓存键。5.6 时区导致“选课时间”比实际时间差8小时Django里的TIME_ZONE默认是UTC如果USE_TZTrue存进数据库的时间都是UTC时间前端展示时要转成Asia/Shanghai。很多新手在这里懵掉。解决方式是在settings.py里设置TIME_ZONE Asia/Shanghai并且看业务场景决定是否保留USE_TZTrue。如果系统只在国内跑直接USE_TZFalse反而省心但要注意Django 4.0以后对USE_TZFalse的默认行为有一些调整建议提前查阅当前版本文档。5.7 N1查询问题列表页显示课程和教师名字时最容易出现N1每显示一门课程就额外执行一次教师查询几十门课就是几十条SQL。解决办法是在视图里用select_related(teacher)一次性连表查出来。这里也顺手分享一下排查方法安装django-debug-toolbar在页面侧边栏直接看SQL条数和耗时一目了然。实测同一个课程列表页不加select_related时50门课程触发51条SQL加上之后变1条。5.8 源码发布前的清理既然项目标题是“附源码”源码里不能留本地绝对路径、数据库密码、SECRET_KEY和测试数据。我通常在发布前会做一次全面扫描重点查settings.py、.env、以及是否存在debugTrue的测试代码。另外源码包内建议放一份README.md写明Python版本、依赖安装、数据库迁移、初始账号创建和测试数据导入四个步骤。相信我这份README能省掉一半的issue提问。pip install -r requirements.txt python manage.py makemigrations python manage.py migrate python manage.py collectstatic python manage.py createsuperuser6. 后续还能往哪些方向扩展选课系统的主干做扎实之后可以扩展的方向其实不少。首先是消息通知模块选课成功、开课审核结果、课程容量快满时通过邮件或站内信提醒用户然后是课程评价与教学反馈学生结课后可以对课程打分、写评语教师端能看到匿名汇总结果还有选课热度分析和预测根据历史选课数据做一个简单的趋势图辅助教务处制定下一学期的开课计划。这些听上去都很吸引人但我个人建议一步一步来先把1.0版本稳定跑一学期把真实的脏数据、边界情况都经历一遍再放手加功能。另外现在不少学校对选课系统有“抢课高峰期抗压”的要求那就免不了引入Redis做分布式锁和排队再配合读写分离这个就属于偏中高级的优化了。对于Django新人来说先把ORM、Admin、模板、权限这些基本功吃透比一开始就去啃高并发方案要实在得多。最后说句掏心窝的做这一类管理系统最难的从来不是某个技术点而是把所有业务流程串起来之后还能保持逻辑清晰。建议你在写代码之前先把用例图、ER图画出来哪怕只是草稿纸上的几条线后面写代码和调试都能少走很多弯路。这个项目我前后加起来用了一周左右的业余时间核心代码量并不大但走通一遍之后对Django的理解会明显上一个大台阶。
返回列表