开学季的教务处有多忙,我估计不少人在大学里都见识过:学生抱着手机蹲点抢课,教务老师面对几百份纸质退改申请一边盖章一边登记,任课教师拿着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_delete=models.CASCADE, verbose_name='用户') student_no = models.CharField('学号', max_length=20, unique=True) major = models.CharField('专业', max_length=50) grade = models.CharField('年级', max_length=20, blank=True) phone = models.CharField('联系电话', max_length=20, blank=True) 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_length=100) category = models.ForeignKey(CourseCategory, on_delete=models.SET_NULL, null=True, verbose_name='课程分类') teacher = models.ForeignKey(User, on_delete=models.CASCADE, related_name='courses', verbose_name='授课教师') credit = models.DecimalField('学分', max_digits=3, decimal_places=1) capacity = models.PositiveIntegerField('容量', default=60) selected_count = models.PositiveIntegerField('已选人数', default=0) schedule = models.CharField('上课时间', max_length=100, help_text='示例:周一 3-4节|周三 5-6节') classroom = models.CharField('上课地点', max_length=100) start_week = models.PositiveIntegerField('开始周', default=1) end_week = models.PositiveIntegerField('结束周', default=16) status = models.SmallIntegerField('状态', choices=STATUS_CHOICES, default=1) description = models.TextField('课程简介', blank=True) created_at = models.DateTimeField('创建时间', auto_now_add=True) 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_delete=models.CASCADE, related_name='selections', verbose_name='学生') course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='selections', verbose_name='课程') selected_at = models.DateTimeField('选课时间', auto_now_add=True) score = models.DecimalField('成绩', max_digits=5, decimal_places=1, null=True, blank=True) 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, username=username, password=password) 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(pk=course_id) if course.status != 2: raise ValidationError('该课程当前不可选') if course.selected_count >= course.capacity: raise ValidationError('课程容量已满') if Selection.objects.filter(student=student, course=course).exists(): raise ValidationError('你已经选过这门课程') # 时间冲突检查(简化为解析schedule字符串) conflict = check_schedule_conflict(student, course) if conflict: raise ValidationError(f'与课程《{conflict.name}》时间冲突') Selection.objects.create(student=student, course=course) course.selected_count += 1 course.save(update_fields=['selected_count'])def check_schedule_conflict(student, new_course): existing_selections = Selection.objects.filter(student=student, course__status=2).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(pk=selection_id, student=request.user) if selection.course.status != 2: raise ValidationError('该课程已结束,不能直接退选') course = Course.objects.select_for_update().get(pk=selection.course_id) selection.delete() course.selected_count -= 1 course.save(update_fields=['selected_count'])这里要特别说明一下select_related和select_for_update的配合:select_related先做连表查询,避免后续再查一次course,select_for_update本身要求锁住查询对象,两者不冲突。实际在MySQL InnoDB下,加锁的SQL会对命中的行加写锁,直到事务提交才释放,可以有效解决并发退选时count减少被覆盖的问题。
3.4 教师录入成绩:一页表格的批量提交
教师端最常见的痛点是逐个录入成绩太慢,我直接用了一个for循环在POST请求里批量读取成绩字段。页面上用Django模板循环课程的学生名单,每个学生一行input,name格式是“score_学生ID”,后端解析的时候用request.POST.getlist()或循环构造。还要注意成绩范围校验,不能小于0也不能大于100,小数位限制1位。
def enter_scores(request, course_id): course = Course.objects.get(pk=course_id, teacher=request.user) if request.method == 'POST': selections = Selection.objects.filter(course=course) for sel in selections: score_key = f'score_{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_id=course.pk) selections = Selection.objects.filter(course=course).select_related('student') return render(request, 'teacher/enter_scores.html', {'course': course, 'selections': selections})写这个功能时我还特意加了一个“成绩单”视图,按分数段统计这门课的人数,方便教师看一眼班级分布。管理员端也有一个按课程分类统计选课人数的图表,这里用简单的字典循环就能做出来,不一定要引入ECharts之类的大库。
4. 上线部署:从本地运行到waitress+nginx
4.1 本地开发环境配置
这个项目我本地用的是Python 3.11,Django 4.2,数据库开发环境直接用SQLite,但生产环境切到MySQL或PostgreSQL。切换数据库时最需要注意的就是模型里的verbose_name和索引,Django的migrations能帮你同步大部分结构,但像JSON字段、全文索引这类特性在不同数据库下能力边界不同,建议早期就确定生产数据库,别到最后再换。
requirements.txt大致如下:
Django==4.2.7 mysqlclient==2.2.0 waitress==2.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,没法上gunicorn(gunicorn在Windows下支持很差),后来用了waitress。waitress是一个纯Python的WSGI服务器,安装简单、跨平台,性能对校园系统这种规模完全够用。启动命令可以写成一个bat脚本:
waitress-serve --listen=0.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', default='unsafe-dev-key') DEBUG = env.bool('DJANGO_DEBUG', default=True) DATABASES = { 'default': { 'ENGINE': env('DB_ENGINE', default='django.db.backends.sqlite3'), 'NAME': env('DB_NAME', default=os.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默认的错误页面在DEBUG=True时会展示完整的堆栈信息,这在生产环境无异于是裸奔。部署时把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_id=user.id),也有人写成filter(student=user)。两种写法都能跑,但新手容易把一个具体对象传进filter里的整型字段,导致TypeError。以后写代码多留意ORM的翻译逻辑:如果是ForeignKey字段,可以传实例、实例ID或QuerySet,但别直接传了个字符串。
5.4 CSRF验证失败,请求被中断
这种问题绝大多数是因为模板里的
5.5 并发选课导致selected_count不准
前面已经说到了select_for_update的解法,但如果用的不是InnoDB而是MyISAM,是锁不住行的。所以生产环境数据库必须用InnoDB或PostgreSQL。另外如果用Redis做缓存优化读操作,那写入的时候要留意缓存失效策略,最好的办法是选课事务提交后再删掉对应的缓存键。
5.6 时区导致“选课时间”比实际时间差8小时
Django里的TIME_ZONE默认是UTC,如果USE_TZ=True,存进数据库的时间都是UTC时间,前端展示时要转成Asia/Shanghai。很多新手在这里懵掉。解决方式是在settings.py里设置TIME_ZONE = 'Asia/Shanghai',并且看业务场景决定是否保留USE_TZ=True。如果系统只在国内跑,直接USE_TZ=False反而省心,但要注意Django 4.0以后对USE_TZ=False的默认行为有一些调整,建议提前查阅当前版本文档。
5.7 N+1查询问题
列表页显示课程和教师名字时,最容易出现N+1:每显示一门课程就额外执行一次教师查询,几十门课就是几十条SQL。解决办法是在视图里用select_related('teacher')一次性连表查出来。这里也顺手分享一下排查方法:安装django-debug-toolbar,在页面侧边栏直接看SQL条数和耗时,一目了然。实测同一个课程列表页,不加select_related时50门课程触发51条SQL,加上之后变1条。
5.8 源码发布前的清理
既然项目标题是“附源码”,源码里不能留本地绝对路径、数据库密码、SECRET_KEY和测试数据。我通常在发布前会做一次全面扫描,重点查settings.py、.env、以及是否存在debug=True的测试代码。另外源码包内建议放一份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的理解会明显上一个大台阶。