Django学生教务选课系统毕设实战:从架构到部署全解析

发布时间:2026/10/23 19:14:36
Django学生教务选课系统毕设实战:从架构到部署全解析

简介:面向高校计算机相关专业学生、毕业设计开发者以及对教务系统开发感兴趣的初学者,这是一套基于 Python 与 Django 框架构建的学生教务选课系统完整源码案例。系统围绕学生、教师、管理员三类角色设计,覆盖身份验证与权限控制、课程信息增删改查、在线选课、已选课程查看、选课人数统计与课表管理等核心模块;Django 的 MTV 架构与 ORM 机制让业务逻辑清晰,数据库建模、迁移和查询更为高效,同时包含 CSRF 保护、XSS 防御等安全机制,适合直接作为课程设计或毕业设计参考。压缩包共 2000 个文件,以 1632 个 JavaScript 文件、250 个 HTML 页面、54 个 CSS 样式表为主,另有少量 Python 程序文件、JSON/XML 配置与 TXT/MD 说明文档,整体 5.66MB,目录结构规整,便于导入运行与按模块查阅。已有 182 人学习下载;若正在完成教务选课类课题,可借此掌握系统设计思路、数据库建模方法与前后端交互实现,快速搭建可演示、可二次开发的项目。

1. 学生教务选课系统:Django 毕设项目到底该怎么跑、怎么改

临近毕业季,很多同学拿到的题目都是“基于 Django 的学生教务选课系统”,但真正能把选课、退课、成绩查询、管理员审核这一套完整流程跑通的人并不多。这个项目正是一个典型的 Django 毕业设计源码案例——学生登录后能浏览课程列表、选课、退课、查看已选课程和成绩;教师能录入成绩、查看自己开设的课程;管理员负责审核课程信息、管理学生和教师账号。技术栈就是 Python + Django + SQLite,前端用 Django 模板引擎加 Bootstrap,不需要额外搭建前端工程。它适合两类人:一类是正在做毕设、想找一个能跑通全流程参考实现的同学;另一类是刚学完 Django 基础、想知道 MTV 架构在一个真实系统里怎么落地的开发者。下面我按实际拆项目的顺序,把这套系统的架构、核心代码、运行步骤和二次开发技巧一层层剥开。

2. 架构设计与数据模型:先把 MTV 和表结构理清楚

2.1 为什么选 Django 而不是 Flask 或 FastAPI

对于这类教务选课系统,我一般会优先推荐 Django,而不是 Flask 或 FastAPI。核心原因有三个:

第一,Django 自带 Admin 后台。教务系统里“管理员管理课程和学生账号”是个高频需求,Django Admin 几乎是零成本实现——注册一下模型就能增删改查,比用 Flask 自己写管理页面省下大量时间。第二,Django 的 ORM 对学生成绩、选课记录这类关联查询非常友好,比如查“某学生所有已选课程的老师是谁”,用 select_related 一行就能搞定,而 Flask + SQLAlchemy 需要手动维护会话。第三,Django 的认证体系开箱即用,登录、登出、session、权限装饰器都是现成的,改改配置就能用。

这套项目的整体架构是经典的 Django MTV 模式:

  • Model 层:负责定义学生、教师、课程、选课记录、成绩这几张核心表。
  • Template 层:Django 模板直接渲染 HTML,Bootstrap 负责样式。
  • View 层:函数视图处理选课、退课、登录等业务逻辑。

前后端不分离的架构虽然看着“老派”,但对毕设和中小型教务场景最合适——不需要配 Vue 和 REST API,服务器直接吐出渲染好的页面,部署也简单,一台机器加一个 WSGI 服务就能跑。

2.2 目录结构:一个典型 Django 项目的标准布局

拆开这个源码包后,目录结构是这样的:

student_course_system/ ├── manage.py # 项目入口脚本 ├── requirements.txt # 依赖清单 ├── config/ # 项目配置目录 │ ├── __init__.py │ ├── settings.py # 全局配置(数据库、应用注册、模板路径) │ ├── urls.py # 根路由 │ └── wsgi.py ├── apps/ # 业务应用目录 │ ├── users/ # 用户模块:学生、教师、登录注册 │ ├── courses/ # 课程模块:课程管理、选课/退课 │ └── grades/ # 成绩模块:成绩录入与查询 ├── templates/ # 全局模板目录 │ ├── base.html # 基础模板(导航栏、消息提示) │ ├── login.html │ ├── course_list.html │ └── ... ├── static/ # 静态资源(Bootstrap、CSS、JS) │ ├── css/ │ ├── js/ │ └── images/ └── db.sqlite3 # SQLite 数据库文件

这种拆分方式把用户、课程、成绩三个模块分到独立的 app 里,每个 app 都有自己的 models.py、views.py、urls.py。你拿到源码后,main.py 可以直接从 config/urls.py 入手看路由分发,再跟进到各个 app 的视图函数。

2.3 数据模型:核心表设计与代码实现

选课系统的核心表有 5 张,我直接贴出核心的 model 代码:

# apps/users/models.py from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): """自定义用户模型:扩展 Django 自带 User,添加角色字段""" ROLE_CHOICES = ( ('student', '学生'), ('teacher', '教师'), ('admin', '管理员'), ) role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='student') student_id = models.CharField(max_length=20, blank=True, null=True, verbose_name='学号') class Meta: db_table = 'sys_user' verbose_name = '用户'
# apps/courses/models.py from django.db import models from apps.users.models import User class Course(models.Model): """课程表:一门课程只有一个老师""" name = models.CharField(max_length=100, verbose_name='课程名称') code = models.CharField(max_length=20, unique=True, verbose_name='课程编号') credit = models.FloatField(default=2.0, verbose_name='学分') capacity = models.IntegerField(default=30, verbose_name='容量上限') teacher = models.ForeignKey(User, on_delete=models.CASCADE, limit_choices_to={'role': 'teacher'}, verbose_name='授课教师') schedule = models.CharField(max_length=200, blank=True, verbose_name='上课时间') class Meta: db_table = 'course' def __str__(self): return f'{self.code} {self.name}' class CourseRecord(models.Model): """选课记录表:学生和课程的多对多关系""" student = models.ForeignKey(User, on_delete=models.CASCADE, related_name='records', verbose_name='学生') course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='records', verbose_name='课程') created_at = models.DateTimeField(auto_now_add=True, verbose_name='选课时间') class Meta: db_table = 'course_record' unique_together = ('student', 'course') # 防止重复选课 class Grade(models.Model): """成绩表:一个学生一门课对应一个成绩""" record = models.OneToOneField(CourseRecord, on_delete=models.CASCADE, verbose_name='选课记录') score = models.FloatField(null=True, blank=True, verbose_name='成绩') grade_point = models.FloatField(default=0.0, verbose_name='绩点')

代码逻辑说明:

  • User 继承 AbstractUser,这样 Django 自带登录验证和密码哈希直接可用,不需要自己写注册逻辑。role 字段区分身份,teacher 外键通过 limit_choices_to 限制只能选择角色为老师的用户,这招在关联查询里能省掉很多脏数据问题。
  • CourseRecord 里的 unique_together 是防重复选课的关键约束。如果不加这个约束,两个并发请求同时提交选课,数据库里会出现两条重复记录,后面的代码逻辑就要额外做去重判断。
  • Grade 用 OneToOneField 关联选课记录,保证一门课一个学生只可能有一条成绩。

参数说明:credit 是 FloatField 因为很多课有 1.5 学分;capacity 是选课容量,用于判断课程是否已满;schedule 存的是字符串,实际项目中建议用 JSONField 存每周节次数组,这里为了简单用了文本段。

3. 核心模块拆解:一次选课请求从进入到返回的完整链路

3.1 路由与视图函数映射

我先从 routes 入口说起,root urls.py 的做法是:

# config/urls.py from django.contrib import admin from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), path('users/', include('apps.users.urls')), path('courses/', include('apps.courses.urls')), path('grades/', include('apps.grades.urls')), path('', include('apps.courses.urls')), # 首页直接展示课程列表 ]
# apps/courses/urls.py from django.urls import path from . import views urlpatterns = [ path('', views.course_list, name='course_list'), path('select/<int:course_id>/', views.select_course, name='select_course'), path('drop/<int:record_id>/', views.drop_course, name='drop_course'), path('my/', views.my_courses, name='my_courses'), ]

每个 URL 都对应一个函数视图,URL 里用<int:course_id>传递课程主键,Django 自动做类型转换校验。下面把选课和退课两个最关键的函数视图拿出来逐段拆。

3.2 选课逻辑:并发安全的代码实现

选课是教务系统最容易出问题的地方——两个学生同时抢最后一门课,结果都选上了,这就超过容量了。我用 Django 的事务加行锁来解决:

# apps/courses/views.py from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth.decorators import login_required from django.db import transaction from django.contrib import messages from .models import Course, CourseRecord @login_required @transaction.atomic def select_course(request, course_id): """选课视图:加事务和行锁防止超选""" course = get_object_or_404(Course, pk=course_id) # 检查用户身份,只有学生才能选课 if request.user.role != 'student': messages.error(request, '只有学生可以选课') return redirect('course_list') # 检查是否重复选课 if CourseRecord.objects.filter(student=request.user, course=course).exists(): messages.warning(request, '你已经选过这门课了') return redirect('course_list') # 关键操作:用 select_for_update 锁定课程行,防止并发超选 selected_count = CourseRecord.objects.filter(course=course).count() if selected_count >= course.capacity: messages.error(request, '课程已满,选课失败') return redirect('course_list') CourseRecord.objects.create(student=request.user, course=course) messages.success(request, f'选课成功:{course.name}') return redirect('my_courses')

这段代码的逻辑说明:

  • @login_required保证只有登录用户能调用选课接口,未登录会跳到登录页。
  • @transaction.atomic把整个选课过程包在事务里,如果中途出错,不会留半截数据。
  • 最核心的是select_for_update()——但注意,我这里写的是普通 filter + count,实际在 MySQL 或 PostgreSQL 下,应该先Course.objects.select_for_update().get(pk=course_id)锁定课程行,再统计选课人数。SQLite 不支行锁,所以毕设里直接用 count 也能工作,但如果部署到 MySQL,就一定要改成先锁行再 count。我一般在代码里会写成course = Course.objects.select_for_update().get(pk=course_id)。

参数说明:course_id 来自 URL 的<int:course_id>,Django 会自动把它转成 int 传入视图;request.user 是 Django 的认证中间件注入的当前登录用户对象。

3.3 退课逻辑与权限控制

@login_required def drop_course(request, record_id): """退课视图:删除选课记录""" record = get_object_or_404(CourseRecord, pk=record_id, student=request.user) course_name = record.course.name record.delete() messages.success(request, f'已退课:{course_name}') return redirect('my_courses')

退课比选课简单直接——查到自己名下的选课记录,删掉就行。有个细节:get_object_or_404里带了student=request.user这个条件,这样学生只能退自己的课,就算有人猜到别人的 record_id 也无法操作别人的记录。这是 Django ORM 里常见的权限控制写法:不是先查再判断,而是在查询条件里直接带上当前用户,查不到就 404。

3.4 课程列表与我的课程

def course_list(request): """课程列表:显示所有课程,附带已选人数和是否可选状态""" if request.user.is_authenticated: user_records = CourseRecord.objects.filter(student=request.user) selected_ids = [r.course_id for r in user_records] else: selected_ids = [] courses = Course.objects.all().select_related('teacher') course_data = [] for c in courses: course_data.append({ 'course': c, 'selected_count': c.records.count(), 'full': c.records.count() >= c.capacity, 'selected': c.id in selected_ids, }) return render(request, 'course_list.html', {'course_list': course_data})

参数说明:c.records.count()利用了 CourseRecord 里 course 外键的 related_name='records',反向查询该课程下所有选课记录的数量。select_related('teacher')是个性能优化玄学——它会在一条 SQL 里用 LEFT JOIN 同时查出课程和老师,避免每个课程循环查一遍数据库,这就是血泪经验总结出来的:列表页一定要用 select_related 或 prefetch_related,否则数据一多页面就卡成 PPT。

页面模板里用一个循环渲染课程卡片,每个卡片上根据 selected 和 full 状态显示不同按钮——已选显示“已选课”,满了显示“已满”,可选则显示“选课”按钮。这些判断都在视图层算好,模板只管展示,省去了模板里写复杂逻辑的麻烦。

4. 运行与部署:从拿到源码到完整跑通的每一个步骤

4.1 环境准备

拿到项目后,建议先去确认 Python 版本,这个项目基于 Django 3.x/4.x,Python 需要 3.8 以上。我一般习惯先建虚拟环境再装依赖:

# 进入项目根目录 cd student_course_system # 创建虚拟环境(Windows 和 Mac 略有不同) python -m venv venv source venv/bin/activate # Linux / Mac venv\Scripts\activate # Windows # 安装依赖 pip install -r requirements.txt

requirements.txt 里的核心依赖大概有 django、pytz 等,如果缺少某个版本,直接pip install django装最新版一般也能跑。需要注意的是,如果项目原来是用 Django 2.x 写的,升级到 3.x 后url()写法会报错,改成path()或re_path()即可。这一步翻车率极高,先打开项目的 urls.py 看一眼写法再跑最省事。

4.2 数据库迁移与初始化

数据库迁移是跑通前最容易踩坑的环节:

# 生成迁移脚本(如果源码里没有 migrations 目录的话) python manage.py makemigrations users courses grades # 执行迁移,创建数据库表 python manage.py migrate # 创建管理员账号 python manage.py createsuperuser

执行 migrate 后会生成 db.sqlite3 文件,表结构建好了。如果你用的是项目自带的 db.sqlite3,就不需要 makemigrations,直接 migrate 即可。注意顺序不能反过来——先 makemigrations 再 migrate,少了前者会报No changes detected。

4.3 启动开发服务器

python manage.py runserver

看到Starting development server at http://127.0.0.1:8000/就表示成功了。浏览器打开http://127.0.0.1:8000/,会跳转到登录页。用你刚才 createsuperuser 创建的账号就能登录后台http://127.0.0.1:8000/admin/,但注意这个超管在系统里默认没有 student 角色,去 Admin 后台找到他改一下 role 就能当学生用了。

没有课程数据的话,先去 Admin 后台手动添加几门课程,或者执行一个数据填充脚本——项目里如果有 seed.py 这类文件,直接python manage.py shell < seed.py跑一遍。往下走到这一步如果页面样式乱了,多半是静态文件的问题,看下一个小节。

4.4 最常碰到的 3 个启动问题

问题一:TemplateDoesNotExist。原因是模板路径配置不对。看 settings.py 里的TEMPLATES配置,DIRS应该指向项目根目录下的 templates 文件夹。如果是[BASE_DIR / 'templates']这种写法,就检查 BASE_DIR 拼接出来的路径对不对。

问题二:静态文件 404。开发模式下 Django 只处理动态请求,静态文件需要配置:

# settings.py STATIC_URL = '/static/' STATICFILES_DIRS = [BASE_DIR / 'static']

urls.py 里在末尾加上+ static(settings.STATIC_URL, document_root=settings.STATICFILES_DIRS[0])才能看到 Bootstrap 样式。

问题三:sqlite3.OperationalError: no such table。这通常是最开始没跑 migrate 或 migrate 时报错。回退方法是把 db.sqlite3 删掉重新建。

rm db.sqlite3 python manage.py migrate

4.5 测试账号与初始数据

跑通之后,你会需要几个不同身份的账号来测试完整流程:管理员一个(管理课程和学生)、教师一个(录成绩)、学生两个以上(测试选课冲突)。可以去 Admin 后台手动创建:在 Users 页面新增用户时,把 role 选成 teacher 或 student,学号字段按规则填。创建完再用这些账号去前台走一遍登录、选课、退课、录成绩的完整链路,确认每个环节都符合预期。

5. 避坑与常见问题排查:5 个高频翻车点的解决方案

5.1 并发选课超员:两个学生同时选了最后一门课

现象:两个学生同时提交选课,数据库里出现了 3 条记录,但课程容量只有 2。原因:选课代码先 count 再 insert,两个请求同时 count 到 1 一共只剩 1 个名额,然后都执行 insert,导致超选。解决:改为事务 + 行锁。在 MySQL 下用Course.objects.select_for_update().get(pk=course_id)锁住课程行,再执行 count 和 create。SQLite 不支持行锁,毕设可以用transaction.atomic()配合CourseRecord.unique_together保证不超选,但严格来说还需要在线程层面加锁。用代码写成这样:

from django.db import transaction with transaction.atomic(): course = Course.objects.select_for_update().get(pk=course_id) if course.records.count() >= course.capacity: return redirect('course_list') CourseRecord.objects.create(student=request.user, course=course)

注意 select_for_update 必须放在事务里,否则 Django 会报错。

5.2 学号重复但 User 表没有唯一约束

现象:管理员手动在 Admin 后台创建学生时,能创建两个学号相同的学生。原因:models.py 里 student_id 字段没有加 unique=True,导致系统里出现两个同号学生,选课和成绩归属全乱。解决:迁移时先清理重复数据,然后给 student_id 加上唯一约束:

student_id = models.CharField(max_length=20, unique=True, blank=True, null=True)

5.3 CSRF 验证失败,表单提交被拒绝

现象:登录页输入账号密码后点登录,页面报了CSRF verification failed。原因:Django 对 POST 请求强制检查 CSRF Token,模板里没加{% csrf_token %}。解决:在所有的<form>标签里,第一行加上{% csrf_token %}。这是一行代码的问题,但每年都能遇到。

5.4 静态文件加载后,CSS 样式时好时坏

现象:刷新页面样式时有时无,控制台报Not Found: /static/css/bootstrap.min.css。原因:开发服务器下,静态文件是通过 Django 的 staticfiles 应用处理的,如果 settings.py 里DEBUG = False,则静态文件不再由 Django 处理。解决:开发阶段把 settings.py 里的 DEBUG 保持为 True,跑通后再研究生产环境的静态文件托管方案。

5.5 时区导致成绩时间显示错乱

现象:成绩录入时间比北京时间快了 8 小时。原因:settings.py 中TIME_ZONE默认是 'UTC',USE_TZ是 True。解决:改成TIME_ZONE = 'Asia/Shanghai'和USE_TZ = False,这样数据库存的就是本地时间,模板渲染的时间也是本地时间。如果你保留 USE_TZ=True,模板里就要用localtime过滤器去显示,不然永远是 UTC 时间。

6. 二次开发:给选课系统加“课程冲突检测”的实战

如果你想拿这个项目做毕设升级,又不想只停留在原样,我建议第一优先级加“课程冲突检测”。这是一个非常立体、能体现业务理解深度的功能——判断学生选新课的时间,是否与已选课程重叠。

课程表里 schedule 字段目前是字符串存上课时间,比如“周一 3-4 节”,要做冲突检测,最简单的做法是把它规范化成 JSON:

# apps/courses/models.py from django.db import models class Course(models.Model): # schedule 改为 JSON 存 [{ "day": 1, "start": 3, "end": 4 }, ...] schedule = models.JSONField(default=list, blank=True, verbose_name='上课时间')

然后在选课视图里加一次冲突检查:

# apps/courses/views.py def has_conflict(user, new_course): """检查新课程与已选课程是否时间冲突""" for record in user.records.select_related('course'): old_schedule = record.course.schedule new_schedule = new_course.schedule for o in old_schedule: for n in new_schedule: if o['day'] == n['day'] and not (n['end'] <= o['start'] or n['start'] >= o['end']): return True, record.course.name return False, None

调用时把结果返回到前端提示。这样一个功能改动下来,架构上不需要动表结构——除了把 schedule 从 CharField 换成了 JSONField。数据库迁移时需要在 Admin 里把旧数据批量转成 JSON 格式。

做改造验证时,我习惯先写几个单元测试:

# apps/courses/tests.py from django.test import TestCase from .models import Course, CourseRecord # 测试用例

造两个时间错开的课程,断言没有冲突;再造两个时间重叠的课程,断言 has_conflict 返回 True。测试过了再放到页面上联调,避免每次手动点页面回归。从那以后,我每次拿到新项目都强制走一遍“先跑通、再加功能、再写测试”的顺序,改到哪层都不慌。希望帮到你。

本文还有配套的精品资源,点击获取