简介:这是一份基于Django框架实现的学生选课管理系统源码,主要面向正在学习Web开发的人员、需要完成课程设计的在校生,以及希望在真实项目中理解Django用法的开发者。系统围绕学生选课场景,完整覆盖了数据建模、视图逻辑、模板渲染、路由分发、表单处理、登录认证等关键环节,清晰体现出该框架开发Web应用的典型架构。资源压缩包共60个文件,主流类型包括35个Python脚本、14个HTML模板、8个CSS样式和2个Markdown说明文档,整体大小仅44KB,结构紧凑、便于按模块研读。目前已有137人学习,读者可从学生信息管理、课程发布与查询、选课退课操作、用户权限验证等模块中,了解从数据库表结构设计到前端页面呈现的完整实现链路。该源码还涉及数据库迁移、URL命名、模板继承、消息提示等实用技巧,对理解此类项目组织方式、提升实战能力都很有帮助。
1. 学生选课管理系统的难点在“选课”两字,Django 也只是载体
很多学生选课管理系统项目做不下去,不是因为 Django 不熟,而是把“选课”做成了先查后写的普通增删改查。真实场景里,一门课有容量上限,一个学生同一学期不能重复选同一门课,课程与课程之间的上课时间不能重叠,到了抢课高峰还要保证没有超卖。这些规则放页面里校验只是体验层,真正兜底的是数据库的约束、事务和行锁。下面顺着一条选课记录从点击到落库的链路,从 Django models 设计、select_for_update事务、权限与 Django Admin 定制,讲到部署后的并发验证,覆盖搜索“Python Django 选课系统源码”时最常被问到的几个工程问题。适合刚学完 Django 基础、想把课设做成能运行可演示项目的同学,也适合工作几年但没写过教务场景、想看看这套系统惯用工程做法的开发者。
2. 先建模还是先定业务规则:Django 选课系统的数据模型与约束设计
选课系统的数据模型比普通博客、商城系统多一层“开课计划”的抽象。很多入门教程把学生和课程直接用ManyToManyField挂在一起,这样做课设演示可以,但放到真实教务场景里,很快会发现容量、学期、上课时间、任课教师都无处安放。模型设计这一步决定了后面事务和查询代码怎么写,值得单独拆开讲。
2.1 一门课程和一个开课计划是两个模型,少在 Course 上直接挂 ManyToManyField
Course只保存课程本身的稳定信息:课程编号、名称、学分。某个学期某位老师在某时间地点开的一班课,应该是一条独立的CourseOffering,容量、时间、地点、教师都挂在它身上。这样做的好处有三个:同一门课可以由不同老师在不同学期分别开课;容量和已选人数针对的是“这一班课”而不是整门课程;选课记录Enrollment指向具体的开课计划,天然带上了学期维度。
我一般会把三张核心表拆成下面这段代码,模型注释里写明每个字段的业务含义。
# courses/models.py from django.db import models from django.contrib.auth.models import User class Student(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, verbose_name="登录账号") student_no = models.CharField("学号", max_length=20, unique=True) grade = models.CharField("年级", max_length=10) major = models.CharField("专业", max_length=50) def __str__(self): return f"{self.student_no} {self.user.username}" class Teacher(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, verbose_name="登录账号") teacher_no = models.CharField("工号", max_length=20, unique=True) title = models.CharField("职称", max_length=20, blank=True) class Course(models.Model): course_code = models.CharField("课程编号", max_length=16, unique=True) name = models.CharField("课程名", max_length=50) credit = models.DecimalField("学分", max_digits=3, decimal_places=1) class CourseOffering(models.Model): course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="offerings", verbose_name="课程") teacher = models.ForeignKey(Teacher, on_delete=models.CASCADE, verbose_name="任课教师") semester = models.CharField("开课学期", max_length=12, db_index=True) capacity = models.PositiveIntegerField("容量", default=60) selected_count = models.PositiveIntegerField("已选人数", default=0) weekday = models.PositiveSmallIntegerField( "星期", choices=[(1, "周一"), (2, "周二"), (3, "周三"), (4, "周四"), (5, "周五"), (6, "周六"), (7, "周日")] ) start_period = models.PositiveSmallIntegerField("开始节次") period_count = models.PositiveSmallIntegerField("持续节次", default=2) weeks = models.JSONField("上课周次", default=list, help_text="例如 [1,2,3,4] 表示第 1 到第 4 周上课") location = models.CharField("上课地点", max_length=100) class Meta: constraints = [ models.CheckConstraint( check=models.Q(selected_count__gte=0) & models.Q(selected_count__lte=models.F("capacity")), name="selected_count_in_range" ), models.CheckConstraint( check=models.Q(capacity__gt=0) & models.Q(period_count__gt=0), name="capacity_and_period_positive" ), ] indexes = [ models.Index(fields=["semester", "selected_count"]), models.Index(fields=["weekday", "start_period"]), ] class Enrollment(models.Model): student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name="enrollments", verbose_name="学生") offering = models.ForeignKey(CourseOffering, on_delete=models.CASCADE, related_name="enrollments", verbose_name="开课计划") created_at = models.DateTimeField("选课时间", auto_now_add=True) class Meta: constraints = [ models.UniqueConstraint(fields=["student", "offering"], name="unique_student_offering"), ]这段模型最关键的地方是把时间信息拆成了四个数字字段:weekday表示星期几,start_period表示第几节开始,period_count表示连上几节,weeks用列表存第几周上课。后面做时间冲突检测时,区间重叠写起来非常直观。selected_count是冗余计数字段,列表页展示余量时不用逐行COUNT(*),代价是维护它的事务代码必须严格配对,否则会出现“已选人数和实际记录数对不上”的脏数据。
2.2 用约束和索引把超卖、重复选课、非法节次挡在 ORM 之前
新手通常只会在视图里if selected_count >= capacity判断,忽略数据库侧的兜底。我的习惯是三层校验:视图层给用户友好提示,事务层用行锁串行化余量扣减,数据库约束作为最后一道防线的保险丝。
Enrollment上的UniqueConstraint直接拒绝同一位学生选同一门开课计划两次,即使两个并发请求同时进来,数据库也只会让其中一个插入成功。CourseOffering上的CheckConstraint保证selected_count永远落在0 到 capacity之间,任何导致超出的写操作都会让事务回滚并抛IntegrityError。视图里只要把这条异常捕获住,返回“课程已满”即可。
两个Index分别服务两类高频查询:按学期和已选人数排序的开课列表页,以及按星期和节次做冲突过滤的课表查询。选课高峰期这两类查询每秒钟会被打很多次,索引加不加效果差别很大。模型改完执行迁移:
python manage.py makemigrations courses python manage.py migrate第一行命令会生成一个新的迁移文件,第二行把约束和索引落到数据库。migrate失败时最常见的两个原因:一是INSTALLED_APPS里没有注册courses这个应用,二是已有数据不满足新加的CheckConstraint,比如历史数据里出现了selected_count大于capacity的行,需要先清理数据再迁移。
2.3 学期和时间字段的选型对照表
同一类业务信息有几种常见存储方式,选错会让后续查询很别扭。我把字段选型整理成一张表,设计模型时可以按这个思路做取舍:
| 业务信息 | 常见错误代入 | 推荐字段 | 理由 |
|---|---|---|---|
| 学期 | 1, 2 这种纯数字 | CharField + db_index | “2024-2025-1”本身就是展示文案,不参与日期运算 |
| 星期 | CharField 存“周一” | PositiveSmallIntegerField + choices | 冲突判断是纯数字比较,避免字符串逐字比对 |
| 节次 | CharField 存“3-4节” | start_period + period_count | 区间重叠计算需要两个数值端点 |
| 上课周次 | CharField 存“1,2,3,4” | JSONField | 读出来直接转 set 做交集,比正则拆字符串干净 |
这里要纠正一个常见设计惯性:不要为了让后台录入界面显得友好,就把星期和节次先拼成字符串存起来。界面友好交给 Django Admin 的fieldsets处理,数据库层保持面向计算的结构。两个时间区间只要都用数字端点表示,冲突检测就能写成“新区间左端不大于旧区间右端,且新区间右端不小于旧区间左端”的简单判断。
3. 选课、退课与冲突检测:Django 事务、行锁和 ORM 查询
模型建好后,真正的核心动作是选课和退课。这一章会给出可直接抄进视图的选课接口实现,重点解释select_for_update和F表达式为什么能防超卖,以及时间冲突检测应该放在哪里做。
3.1 选课先锁开课计划行,再在 atomic 里扣余量
选课接口的核心不是创建一条Enrollment记录,而是保证“查余量、写记录、扣余量”三步不会被并发其他请求打断。常见做法是让所有请求在数据库行锁上排队,拿到锁的人先做完整套操作。
# courses/views.py from django.db import transaction from django.db.models import F from django.http import JsonResponse from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_POST def current_semester(): # 实际项目里应读取系统配置表或 Redis 缓存,不要硬编码在业务代码里 return "2024-2025-1" @login_required @require_POST def enroll_course(request, offering_id): student = request.user.student with transaction.atomic(): offering = CourseOffering.objects.select_for_update().get(id=offering_id) if offering.semester != current_semester(): return JsonResponse({"code": 1, "msg": "不在本学期的选课时间范围内"}, status=400) if offering.selected_count >= offering.capacity: return JsonResponse({"code": 1, "msg": "该课程已选满"}, status=400) if Enrollment.objects.filter(student=student, offering=offering).exists(): return JsonResponse({"code": 1, "msg": "不能重复选择同一门课"}, status=400) has_conflict = False for enrollment in Enrollment.objects.filter( student=student, offering__semester=current_semester() ).select_related("offering"): if time_conflict(offering, enrollment.offering): has_conflict = True conflict_name = enrollment.offering.course.name break if has_conflict: return JsonResponse({"code": 1, "msg": f"与《{conflict_name}》上课时间冲突"}, status=400) Enrollment.objects.create(student=student, offering=offering) CourseOffering.objects.filter(id=offering.id).update( selected_count=F("selected_count") + 1 ) return JsonResponse({"code": 0, "msg": "选课成功", "selected_count": offering.selected_count + 1})第 8 行的select_for_update()必须在transaction.atomic()块内使用,否则数据库会抛“select for update 不能用在事务外”的错误。它的作用是锁住CourseOffering这一行,其他事务想读同一行做更新时必须等当前事务提交或回滚。锁范围是“一行开课计划”,不是整张表,所以不同课程的选课请求可以并行。余量扣减用F("selected_count") + 1在数据库层完成,不把旧值读到 Python 再写回,避免丢失更新。
需要提醒的是,SQLite 自带驱动对select_for_update是空操作,本地开发验证并发效果时要用 MySQL 或 PostgreSQL。另一个理论盲区是:同一学生同时提交两门时间冲突的课程时,两个事务都查不到对方刚插入的记录,存在幻读窗口;彻底解决需要对该学生本学期已有 Enrollment 行也加锁,或者引入 Redis 分布式锁。课程设计阶段优先解决超卖这个主要矛盾即可。
3.2 时间冲突检测:区间重叠和周次交集都放在 Python 里算
time_conflict函数放在视图文件或者独立的utils.py里。核心逻辑很简单:先比较星期,星期不同直接不冲突;星期相同再看节次区间是否重叠;区间重叠后还要看周次是否有交集。
# courses/utils.py def time_conflict(new_offering, existing_offering): if new_offering.weekday != existing_offering.weekday: return False new_start = new_offering.start_period new_end = new_start + new_offering.period_count - 1 old_start = existing_offering.start_period old_end = old_start + existing_offering.period_count - 1 if new_end < old_start or new_start > old_end: return False new_weeks = set(new_offering.weeks) old_weeks = set(existing_offering.weeks) return bool(new_weeks & old_weeks)节次重叠判断用两个区间的“不重叠”条件反推:只要新课程的结束节次早于旧课程的开始节次,或者新课程的开始节次晚于旧课程的结束节次,就说明两节课错开了。周次部分把 JSONField 里的列表转成set做交集运算,{3, 4, 5} & {5, 6, 7}的结果非空即冲突。
可能有同学问,为什么不用一条 SQL 把所有冲突判断全写进去。因为weeks是 JSON 数组,直接做数据库层相交过滤要依赖数据库方言特性,查询条件可读性极差。一个学生一学期只会有几十条选课记录,在 Python 里循环判断的开销可以忽略。这套实现让业务规则一目了然,后续要给“单双周”或“节假日停课”做扩展,只需要在周次集合上做减法。
3.3 退课与删除对象:先删选课记录,再回补余量
退课比选课简单,但容易少做一步:余量回补。Django 的delete()不会替你执行selected_count - 1,必须手动用F表达式同步两个数据源。
@login_required @require_POST def drop_course(request, offering_id): student = request.user.student with transaction.atomic(): deleted, _ = Enrollment.objects.filter( student=student, offering_id=offering_id ).delete() if deleted == 0: return JsonResponse({"code": 1, "msg": "你没有选过这门课"}, status=400) CourseOffering.objects.filter(id=offering_id).update( selected_count=F("selected_count") - 1 ) return JsonResponse({"code": 0, "msg": "退课成功"})这段要注意两点。第一,filter(...).delete()虽然走的是批量删除接口,但命中条件只有一行,会触发该模型的pre_delete和post_delete信号,级联关系里的关联数据也会被清理。第二,先删记录再减余量的顺序不能反过来,否则减数成功但删除失败时,余量就会虚高。事务把两步包在一起,任何一步失败都会整体回滚。
关于“删除对象”还有一层考虑:课程设计阶段用硬删除没问题,但真实教务系统里选课记录是成绩、绩点计算的依据,学生退课后这条历史记录往往需要保留,此时建议在Enrollment上加status字段,退课只把状态改成“已退”,查询课表时过滤掉即可。这个改动不影响当前代码结构,只是把delete()换成一次update(status=...)。
3.4 事务、锁与约束的取舍对照表
四种机制各管一件事,放在一起容易混淆,整理成对比表:
| 机制 | 解决的问题 | 使用位置 | 要留心的边界 |
|---|---|---|---|
| select_for_update | 两人同抢一门课导致超卖 | 选课接口 | 必须在atomic内,SQLite 无效 |
| F 表达式 | 并发扣减时丢失更新 | update 语句 | 读到的旧值只能用于展示,不能参与计算写回 |
| UniqueConstraint | 同一学生重复选同一门课 | Enrollment 模型 | 插入冲突时抛 IntegrityError,要捕获后转成友好提示 |
| CheckConstraint | 余量越界、容量非法 | CourseOffering 模型 | 已有脏数据时迁移会失败,先清洗再迁移 |
事务把“锁”和“写”绑在一起,锁的时间越短越好,所以选课接口里不要在事务块内做耗时操作,比如发送通知邮件、调用外部接口。通知动作放到选课成功后再发,哪怕发送失败也不影响选课结果。
4. 权限、Django Admin 定制与前后端分离接口的务实做法
Django 内置的auth应用已经提供用户、登录、权限和分组,学生选课管理系统可以直接复用。剩下的工作是定义角色边界,以及把后台管理界面调整到教务老师能顺畅操作的程度。这一章顺带讲一下前后端分离时后端接口的轻量写法,很多同学拿到源码第一件事就是问“前端页面在哪”。
4.1 用分组和装饰器切分学生、教师、管理员的访问边界
角色区分不一定要在数据库里加role字段。我一般直接用 Django 的is_staff区分“普通用户”和“后台用户”,再用Student、Teacher两个 OneToOne 扩展表区分业务身份。进入后台的是教师和管理员,前台选课的是学生。前台接口的权限控制用一个装饰器:
# courses/decorators.py from functools import wraps from django.http import JsonResponse def student_required(view_func): @wraps(view_func) def wrapper(request, *args, **kwargs): if not hasattr(request.user, "student"): return JsonResponse({"code": 403, "msg": "仅限学生操作"}, status=403) return view_func(request, *args, **kwargs) return wrapperhasattr(request.user, "student")会在反向 OneToOne 关系不存在时返回False,不会抛异常,用来判断“当前登录用户是不是学生”很干净。教师端的发布开课计划直接走 Django Admin,前提是教师用户的is_staff设为True。这样设计不需要维护复杂的权限模型,课设阶段够用;若要更细的粒度,再考虑给不同分组挂自定义 Permission。
4.2 Django Admin 的高频定制:列表字段、检索效率和界面顺手度
Django Admin 默认的管理界面只是可用,距离“好用”还差几个属性。教务老师打开开课计划列表时,最关心的是课程名、教师、学期、容量和已选人数,这些字段默认不会全部显示出来,需要显式声明。
# courses/admin.py from django.contrib import admin from .models import CourseOffering, Enrollment @admin.register(CourseOffering) class CourseOfferingAdmin(admin.ModelAdmin): list_display = ("id", "course", "teacher", "semester", "capacity", "selected_count", "weekday", "start_period") list_filter = ("semester", "weekday") search_fields = ("course__name", "course__course_code", "teacher__teacher_no") list_select_related = ("course", "teacher") list_editable = ("capacity",)这里最值得记住的是list_select_related。没有它时,列表页每展示一行开课计划就会查一次course表、一次teacher表,100 行数据多出 200 条查询,后台会明显卡顿;加上这个配置后,框架在取列表时一次性 JOIN 出关联数据。search_fields里的course__name是跨表关联搜索的标准写法。list_editable让容量可以直接在列表页改数字,不用进入详情页,教务高峰期改容量会顺手很多。
4.3 检索页面与“前后端分离”无关时,DataTables 的数据从哪来
不少拿这套课设源码的同学会把前端改成 Vue,后端只负责输出 JSON。不引入 Django REST Framework 也能做到,Django 自带的JsonResponse配合Paginator就能输出一个结构清晰的分页接口。
# courses/views.py from django.core.paginator import Paginator def offered_courses(request): semester = request.GET.get("semester", "2024-2025-1") qs = CourseOffering.objects.filter(semester=semester).select_related("course", "teacher") paginator = Paginator(qs, 10) page = paginator.get_page(request.GET.get("page", 1)) rows = [] for off in page.object_list: rows.append({ "id": off.id, "course_name": off.course.name, "teacher_name": off.teacher.user.username, "capacity": off.capacity, "selected_count": off.selected_count, "remaining": off.capacity - off.selected_count, }) return JsonResponse({"code": 0, "page": page.number, "total": paginator.count, "rows": rows})分页参数用request.GET.get("page", 1)读取,前端传页码,后端返回当前页数据和总条数。remaining在 Python 里通过两个已加载的整数相减得到,不会额外触发查询。需要说明的是,真正的前后端分离项目要考虑跨域 CORS、接口鉴权、接口文档,Django 原生方案只适合课设和内部小系统;想升级为 DRF 时,这套视图逻辑可以直接平移。
5. 部署与并发验证:宝塔上线后的两个检查和一个压测脚本
课设源码收尾前,先在本地把部署边界确认一遍,能省去部署到服务器后的排错时间。这一章不讲面板操作步骤,只讲两个必做的检查,以及怎么证明选课接口在并发下没有超卖。
5.1 部署前先跑 check --deploy,静态文件路径要单独配置
先在项目根目录执行:
python manage.py check --deploy输出里的 WARNING 按顺序逐个处理,常见的有DEBUG未关闭、ALLOWED_HOSTS未配置、SECRET_KEY硬编码在settings.py里。接着执行python manage.py collectstatic --noinput,把模板里{% static %}引用的文件收集到STATIC_ROOT指定目录。宝塔部署 Django 时,nginx 配置里需要为静态文件单独指路:动态请求交给 uwsgi,静态文件直接读磁盘。uwsgi 启动配置里socket必须是 uwsgi 协议地址,nginx 用uwsgi_pass才能对接:
location /static/ { alias /www/wwwroot/select_course/staticfiles/; } location / { include uwsgi_params; uwsgi_pass 127.0.0.1:8001; }uwsgi_pass后面的端口一定要和 uwsgi 从socket=127.0.0.1:8001里读到的配置保持一致,面板里改过其中一个后另一个也要同步,否则会报 502。
5.2 用并发压测脚本验证没有超卖
部署验证最有效的手段是模拟几十个人同时点选课。下面的脚本用线程并发登录不同账号,同时提交选课请求,最后统计成功数量。
import threading import requests BASE = "https://your-domain.com" OFFERING_ID = 12 results = [] def one_student(username, password): s = requests.Session() s.get(f"{BASE}/accounts/login/") token = s.cookies.get("csrftoken") s.post(f"{BASE}/accounts/login/", data={"username": username, "password": password}, headers={"X-CSRFToken": token}) token = s.cookies.get("csrftoken") r = s.post(f"{BASE}/courses/{OFFERING_ID}/enroll/", headers={"X-CSRFToken": token}) results.append(r.status_code) threads = [threading.Thread(target=one_student, args=(f"stu{i:02d}", "pass")) for i in range(30)] for t in threads: t.start() for t in threads: t.join() success = sum(1 for r in results if r == 200) print("success:", success)登录后要重新读取一次csrftoken,Django 在登录成功后会刷新 CSRF cookie,用旧 token 发 POST 会返回 403。脚本跑完后,进数据库核验selected_count和Enrollment实际行数是否一致:
from courses.models import CourseOffering off = CourseOffering.objects.get(id=OFFERING_ID) print(off.selected_count, off.enrollments.count()) assert off.selected_count == off.enrollments.count()如果成功数超过了capacity,优先检查选课接口里select_for_update是否被写进了atomic事务块内;如果selected_count比记录数大,说明退课接口里漏了余量回补的update语句。两个数据源对上,就可以放心把源码交付给教务人员试用了。
本文还有配套的精品资源,点击获取