Django排课系统开发实战:模型设计、贪心算法与迁移经验
简介:本资源是一款基于Python 2.7与Django框架开发的通用排课系统,面向高校、中学及培训机构的教务管理人员与毕业设计学习者,旨在解决课程安排中教师、教室、时段等多约束条件下的高效排课难题。压缩包共276个文件,含39个核心Python源码(.py)、38个前端交互脚本(.js)、19个HTML页面、39个CSS样式文件(含Bootstrap、Layui、Layer等主流UI框架资源),以及CSV、XLSX等数据模板和PDF操作手册,整体大小为35.69MB。已有56人下载学习,适合Python Web开发初学者通过完整MTV架构项目理解模型设计、视图逻辑与模板渲染全流程。读者可直接部署运行,掌握智能排课算法集成、课表可视化展示、手动微调交互及跨平台部署等实战能力,并参考配套文档快速开展二次开发与功能扩展。 前段时间翻硬盘,无意中找到一个压了很久的压缩包, python270通用排课系统(django).rar ,解压密码早就忘了,但看到文件名我一下子就想起来——这是我早年用Python 2.7 + Django 1.11写的一个培训机构排课系统。当时是为了解决一个比较现实的问题:校区多、班级多、教师和教室都是公共资源,纯靠Excel排课,到了换季开班季基本就是灾难。那会儿Django在国内已经有不少人在用了,但排课这类业务系统写起来还是得靠自己去趟坑。这个系统后来在本地部署跑了几个学期,虽然谈不上多完善,但核心的排课逻辑和Django落地方式是值得搬出来说一说的。这篇文章就把整个项目的设计思路、核心代码、迁移到Python 3的经验一并梳理出来,给正在做课表类系统的同学一个参考。
1. 一个老项目的复盘:排课系统到底要解决什么问题
1.1 培训机构的排课痛点
先还原一下当时的场景。这个系统服务的不是大学,是一家在多城区有分校区的培训机构。他们的排课需求有几个特点,跟大学教务处排课差别很大:
- 教室不按院系划分,所有班级共用一套教室资源,教室有容量限制,有的还区分是否带投影、是否机房。
- 教师不是全脱产,很多教师晚上和周末才来,而且教师不允许跨校区同一天排课。
- 课程有固定频率,像"每周三节"、"隔天上课"这种,不是一个学期只有一次。
- 开课计划变化频繁,新班插班是常态,临时调课、教师请假替代课也经常发生。
这些问题叠加起来,就不是简单套个Excel模板能搞定的了。最开始我收到的需求只是"帮我们做个方便的课表录入工具",但实际用下来发现,录入反而是次要的,真正让人崩溃的是冲突检测——教务老师排完一张新课表,要人工去对照六个校区所有教室、所有教师的时间段,一圈下来基本就神经衰弱了。
所以这个项目在设计上,核心关键字不是"录入",而是"自动检测"和"尽量自动排"。Django在这个场景下是很合适的选择:ORM足够方便,Admin后台开箱即可维护基础数据,模板系统渲染课表很顺手,开发速度在当时的一众框架里是公认的够快。
1.2 系统的功能边界
老项目在功能上划得很清晰,没有去搞花哨的东西,主要包含五块:
| 功能模块 | 说明 |
|---|---|
| 基础数据维护 | 校区、班级、课程、教师、教室、上课时间段 |
| 排课引擎 | 根据开课计划自动推荐不冲突的时间段与教室 |
| 冲突检测 | 手动调整课表时实时提示教师、教室、班级时间冲突 |
| 课表查询 | 按教师、教室、班级三个维度导出周课表 |
| 手工调课 | 支持调课、停课、补课,并有操作记录 |
这套边界当时定下来是有讲究的。最初客户需求里还有收费统计、教师工资核算、教材库存,但我都砍掉了。原因很简单:排课系统最重要的是把"课"排清楚,一旦跟财务和库存搅在一起,数据完整性和权限模型都会复杂很多,交付周期拉长,反而把核心功能拖黄了。Django项目最怕的就是模型边界不清,后面加字段改表结构都是牵一发动全身的事。
实践下来,这个决定是对的。教务系统排课模块稳定跑起来之后,等客户真正理解了系统的能力边界,再来讨论其他模块就顺畅得多。
2. 数据模型设计:课表编排的底层骨架
2.1 基础模型的字段与关系
排课系统的数据模型没有太多神秘的地方,核心就是把现实世界的实体映射进去。我当时建了大约十张表,最核心的是这几张:
# 教师
class Teacher(models.Model):
name = models.CharField('姓名', max_length=64)
department = models.ForeignKey(
Department, verbose_name='所属校区',
related_name='teachers', on_delete=models.CASCADE
)
max_hours_per_week = models.IntegerField('每周最大课时', default=24)
# 教室
class Classroom(models.Model):
number = models.CharField('教室编号', max_length=32)
capacity = models.IntegerField('容量', default=30)
has_projector = models.BooleanField('投影仪', default=True)
classroom_type = models.CharField(
'教室类型', max_length=16,
choices=(('normal', '普通教室'), ('computer', '机房'), ('special', '功能教室')),
default='normal'
)
# 课程
class Course(models.Model):
name = models.CharField('课程名称', max_length=128)
teacher = models.ForeignKey(Teacher, related_name='courses', on_delete=models.CASCADE)
weekly_hours = models.IntegerField('每周课时', default=2)
# 课程对教室类型有要求,比如Python课必须在机房
need_classroom_type = models.CharField(
'所需教室类型', max_length=16, choices=..., default='normal'
)
# 可用时间段,按"星期几 + 第几大节"建模
class TimeSlot(models.Model):
WEEKDAY_CHOICES = [(0, '周一'), (1, '周二'), (2, '周三'),
(3, '周四'), (4, '周五'), (5, '周六'), (6, '周日')]
SLOT_CHOICES = [(1, '上午第1-2节'), (2, '上午第3-4节'),
(3, '下午第5-6节'), (4, '晚上第7-8节')]
weekday = models.IntegerField('星期', choices=WEEKDAY_CHOICES)
slot = models.IntegerField('时间片', choices=SLOT_CHOICES)
class Meta:
unique_together = ('weekday', 'slot')
verbose_name = '上课时间段'
然后是最重要的一张关系表,排课结果表:
class CourseSchedule(models.Model):
course = models.ForeignKey(Course, related_name='schedules', on_delete=models.CASCADE)
classroom = models.ForeignKey(Classroom, on_delete=models.CASCADE)
time_slot = models.ForeignKey(TimeSlot, on_delete=models.CASCADE)
semester = models.CharField('学期', max_length=32, db_index=True)
class Meta:
unique_together = (
# 同一学期、同一时间片不能在同一教室上两门课
('classroom', 'time_slot', 'semester'),
# 同一学期、同一时间片下同一教师不能同时上两门课
('course__teacher', 'time_slot', 'semester'),
# 注意:Django的unique_together支持模型字段,但不能这样跨模型引用
# 上面这个写法在实际项目中要调整,见下文的坑
)
模型设计里最值得说的是 TimeSlot 。我当时没有用 DateTimeField 来存每节课的具体起止时间,而是抽象成了"星期几+第几大节"两个字段。一开始客户要求精确到分钟,比如9:00-10:30。但后来发现,时间是会变的,冬夏令时、临时调整都有可能影响每节课起止时间,而"周几第几节"这个结构不会变。只要在展示层把时段映射做一次转换就够了,排课逻辑完全不用动。这是整个项目里最省心的一次建模决策。
2.2 模型设计里容易忽略的两个细节
第一个坑,就是上面代码注释里提到的: unique_together 不能跨模型引用字段。我当时天真地想写成 ('course__teacher', 'time_slot', 'semester') 来保证教师不冲突,结果Django直接报错,翻车翻得很干脆。
正确做法是在保存排课记录时,在业务逻辑层检测教师是否已经被占用,或者把教师冗余到 CourseSchedule 表里,每次保存时直接对 teacher 字段做 unique_together 。我更推荐业务层检测的方式,因为冗余字段会增加数据同步的隐患。
第二个细节,是"班级"这个概念。很多排课系统把班级绑在课程模型上,一个课程实例就是一个班级。但培训机构不一样,同一门课可以拆成好几个平行班,每个班有不同的进度和教师。所以我在 Course 和 CourseSchedule 中间加了 CourseGroup (教学班)这一层,开课计划落到具体教学班上,排课也就是给教学班找时间找教室。
Teacher(1) ──< Course(1) ──< CourseGroup(n) ──< CourseSchedule(n) >── Classroom
这个中间层看起来多一张表,但带来的灵活性是巨大的。插班生可以算进 CourseGroup 而不影响课程定义;平行班之间可以共享课程名称,但各自独立排课;教师替代课也能定位到某个具体的教学班,而不是把所有同名课程一起改了。
3. 排课核心算法:贪心排课与冲突消解
3.1 我把问题拆成了哪几步
排课问题本质上是一个带约束的资源分配问题。教室里有人上课就不能再排,教师同一时间不能分身,班级也不能同时上两门课。要找到全局最优解,这是个组合爆炸的问题,课堂数量稍微一多,穷举法直接不可行。
我当时的策略是放弃全局最优,用两步走:
- 先按约束强度排序,约束越多越难排的课程优先安排。
- 每一步都用冲突检测函数检查候选时间段,找到第一个不冲突的时间段就占上,找不到就记入待处理列表。
这套方案本质上是贪心+冲突消解。虽然不能保证得到理论最优解,但在一个几十个教学班、十来个教室的培训机构场景里,解的质量已经足够好了。真正排不出来的课程,大多数是教师的可用时间被排满或者教室类型不匹配,这类问题靠算法硬找不如直接暴露给教务老师去判断。
另外一个关键设计是: 算法不直接写入数据库 。我先在内存里构建一套"已占用的资源表",在这个表上进行模拟分配,全部排完并确认没有冲突后,再一次性落库。这样用户可以在页面上反复调参数"试排",不满意就撤回到试排之前的状态,不会污染真实课表。
3.2 核心代码实现
资源占用表我用Python字典来表示,键是资源的标识,值是该时间片是否被占用的集合。
def create_scheduler(courses, teachers, classrooms, timeslots):
"""
构建调度器,返回一个可调用的排课函数。
scheduler_output = {
'assignments': [{'course': c, 'teacher': t, 'classroom': r, 'slot': s}, ...],
'unassigned': [course, ...]
}
"""
# 三种资源的占用字典
teacher_busy = {t.id: set() for t in teachers}
room_busy = {r.id: set() for r in classrooms}
course_group_busy = set()
# 按约束强度排序:限制越多越靠前
def constraint_weight(course):
# 课程对教室类型有要求 + 教师可用时间片数量 + 每周课时数
return (
len(course.coursegroup_set.all()),
0 if course.need_classroom_type == 'normal' else 10,
course.weekly_hours,
)
ordered_courses = sorted(courses, key=constraint_weight, reverse=True)
assignments = []
unassigned = []
for course in ordered_courses:
placed = False
for slot in order_time_slots(timeslots):
# 1. 先看教学班是否已被占用
if (course.id, slot.id) in course_group_busy:
continue
# 2. 再看教师
if slot.id in teacher_busy[course.teacher_id]:
continue
# 3. 找匹配的教室
for room in order_classrooms(classrooms, course):
if slot.id in room_busy[room.id]:
continue
if room.capacity < course.expected_student_count:
continue
if course.need_classroom_type != 'normal' and room.classroom_type != course.need_classroom_type:
continue
# 占用资源
teacher_busy[course.teacher_id].add(slot.id)
room_busy[room.id].add(slot.id)
course_group_busy.add((course.id, slot.id))
assignments.append({
'course': course,
'teacher': course.teacher,
'classroom': room,
'slot': slot,
})
placed = True
break
if placed:
break
if not placed:
unassigned.append(course)
return {'assignments': assignments, 'unassigned': unassigned}
这个函数看起来简单,但实际项目里迭代了很多次。比较关键的一点是 order_time_slots 和 order_classrooms 这两个排序函数——它们决定了同等条件下的优先级。
我默认把上午的时间片排在前面,因为培训机构普遍上午教室最紧张;教室则优先排容量更合适的,太大浪费小班资源,太小装不下又直接跳过。这些启发式规则看起来土,但在实际使用中确实能减少不少"排出来但不合理"的结果。
3.3 冲突检测的优先级逻辑
试排结束后,用户可能会手动调整某个班级的时间。这时我不限制用户只能从一个时间片选到另一个时间片,而是把选择行为直接接上冲突检测,在页面上即时给出反馈。
冲突检测有一个优先级顺序:
- 班级冲突最严重。一个教学班同一个时间片只能上一门课,这是硬约束,直接拒绝。
- 教师冲突次之。教师虽然不允许分身,但偶尔会出现同一教室同一时间两个教师同时在上课的情况——这种其实也属于硬约束。
- 教室冲突是软约束。如果一个教室被占,可以换个教室继续排,不一定要拒绝操作。
我的实现里,三类检测都写在一个函数里:
def check_schedule_conflict(candidate, all_schedules):
errors = []
# 1. 教师时间冲突
if all_schedules.filter(
course__teacher=candidate.course.teacher,
time_slot=candidate.time_slot,
semester=candidate.semester
).exclude(id=candidate.id).exists():
errors.append('该时间片教师已有其他课程')
# 2. 教室时间冲突
if all_schedules.filter(
classroom=candidate.classroom,
time_slot=candidate.time_slot,
semester=candidate.semester
).exclude(id=candidate.id).exists():
errors.append('该时间片教室已被占用')
# 3. 教学班时间冲突
if all_schedules.filter(
course_group=candidate.course_group,
time_slot=candidate.time_slot,
semester=candidate.semester
).exclude(id=candidate.id).exists():
errors.append('该时间片教学班已有其他课程')
return errors
这个函数在保存表单前调用,校验不过就直接在页面上用红色文字标出来。教务老师最怕的就是"看起来排上了,实际是错的",有了即时反馈,他们才敢放心让系统辅助判断。
4. Django视图与模板:把排课结果落到浏览器里
4.1 视图层的接口设计
整个项目不复杂,所以我没有使用DRF,就用了最基础的Django函数视图加少量Class-Based View,既够用又好维护。核心页面主要是:排课工作台、课表查询页、基础数据管理页。
排课工作台的视图大概是这样的:
from django.shortcuts import render, redirect
from django.contrib import messages
def arrange_workbench(request):
if request.method == 'POST':
semester = request.POST.get('semester', '')
course_ids = request.POST.getlist('course_ids')
courses = Course.objects.filter(id__in=course_ids)
# 调度器在内存中模拟
result = create_scheduler(
courses,
Teacher.objects.all(),
Classroom.objects.all(),
TimeSlot.objects.all()
)
request.session['arrange_result'] = {
'assignments': [...],
'unassigned': [c.id for c in result['unassigned']]
}
return redirect('arrange_preview')
# GET请求:显示可选课程、学期下拉框
semesters = CourseSchedule.objects.values_list('semester', flat=True).distinct()
return render(request, 'arrange/arrange_workbench.html', {
'courses': Course.objects.select_related('teacher').all(),
'semesters': semesters,
})
用 request.session 暂存试排结果是一个比较土但很实用的做法。当时没有引入Redis,session默认存在数据库里,用来存几个小小的排课结果完全够用。关键是用户点击"确认保存"之前,数据库里不会出现任何脏数据。
确认保存的视图也不复杂,但有一点必须注意:保存时要用 transaction.atomic() 包住整批写入,并且在写入前再跑一遍冲突检测,避免两个人同时打开工作台操作时产生时间窗冲突。
提示:Django的
transaction.atomic()不仅能保证事务回滚,还能配合select_for_update()解决排课操作并发冲突。虽然培训机构场景很少遇到真正的高并发,但这个是很好的习惯。
4.2 模板渲染与页面交互
课表展示我是用最传统的HTML表格渲染的,没有上任何前端框架。第一版甚至没有用JavaScript,纯粹是后端把数据拼成二维数组传到模板里,模板用 {% for %} 循环渲染。
模板核心代码如下:
<table class="table table-bordered">
<thead>
<tr>
<th>时间</th>
<th>周一</th>
<th>周二</th>
<!-- ... 周三到周日 -->
</tr>
</thead>
<tbody>
{% for slot in slot_list %}
<tr>
<td>{{ slot.name }}</td>
{% for weekday in weekday_list %}
<td>
{% for s in schedule_matrix %}
{% if s.time_slot.weekday == weekday and s.time_slot.slot == slot.id %}
<div class="schedule-item">
<span class="course-name">{{ s.course.name }}</span>
<span class="course-room">{{ s.classroom.number }}</span>
<span class="course-teacher">{{ s.teacher.name }}</span>
</div>
{% endif %}
{% endfor %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
这套方案一开始用着挺顺,但数据量一大就暴露问题了。如果每个格子都要遍历全部课表记录,渲染一个学期的总课表要好几秒钟。后来我做了个预处理:在视图里先把课表记录按 (weekday, slot) 分组放进 defaultdict ,模板里只需要按 matrix[weekday][slot] 取值,速度直接提升了一个数量级。
from collections import defaultdict
def build_matrix(schedules):
matrix = defaultdict(dict)
for s in schedules:
matrix[(s.time_slot.weekday, s.time_slot.slot)].append(s)
return matrix
前端还做了一个很轻量的交互:点击某个格子弹出"排课操作"小面板,里面可以调整教室、换时间段、调用上面的 check_schedule_conflict 做即时校验。这个交互只需要一点点jQuery就够了,但极大提升了教务老师的使用体验——他们不用去后台表单页面一个个改,直接在总课表上点就行。
5. 从Python 2.7迁到Python 3:一次躲不掉的升级
5.1 迁移的动机和变化
这个项目的开发周期很长,最初在Python 2.7上跑得挺好。后来Django版本越升越高,Python 2.7的兼容性维护逐渐成了包袱,尤其到了2019年之后,很多第三方库都不再支持Py2,迁移到Python 3变成了一个不得不做的事。
标题里的"python270"恰好点到了Python 2.7.0这个版本。当时用2.7有几个历史原因,但到了迁的时候,我发现自己对项目的很多假设已经不成立了。
迁移路径我选的是:
- Python 2.7.0 → Python 3.8(当时LTS的稳定版本)
- Django 1.11 → 2.2 LTS
- 顺手把SQLite测试库换成MySQL,因为生产环境最终用的MySQL
这个跨度看起来大,但Django的ORM封装得比较好,真正需要手工改的代码没有想象中那么多。
5.2 迁移中踩过的几个坑
第一个坑是 str 和 unicode 。Python 2代码里到处是 u'中文' 字符串前缀,在Python 3里这些前缀虽然还能用,但含义完全不同了。最直接的麻烦是数据库返回的字符串类型变了,以前跟 unicode 类型判断的地方几乎全要改。我后来用了一个笨办法:全局搜索 u' 前缀和 unicode( 调用,逐个清理。
第二个坑是 url 路由函数的写法。Django 1.11里的 url(r'^schedule/$', ...) 在Django 2.2里还能用,但正则前缀 r 已经不推荐了,建议用 path('schedule/', ...) 或者 re_path(r'^schedule/$', ...) 。我是直接用 path() 重写,顺便把路由结构整理了一遍。
第三个坑比较隐蔽,是模板过滤器里 {{ value|escapejs }} 的行为差异。Python 2对非ASCII字符的转义方式跟Python 3完全不同,导致个别课程名称在页面里显示成了乱码。排查了很久才发现是模板过滤器行为变化,不是数据库编码问题。
5.3 单元测试帮我抓住了哪些问题
迁移前我专门补了一批单元测试,覆盖了排课记录创建、冲突检测、课表渲染三大核心路径。这批测试在迁移过程中帮了大忙,很多低级错误在测试阶段就暴露了。
比如:
class ScheduleConflictTestCase(TestCase):
def test_teacher_conflict(self):
teacher = Teacher.objects.create(name='张三')
course1 = Course.objects.create(name='Python基础', teacher=teacher)
course2 = Course.objects.create(name='Web开发', teacher=teacher)
slot = TimeSlot.objects.create(weekday=0, slot=1)
room = Classroom.objects.create(number='A101', capacity=30)
CourseSchedule.objects.create(
course=course1, classroom=room, time_slot=slot, semester='2023-春季'
)
errors = check_schedule_conflict(
CourseSchedule(course=course2, classroom=room, time_slot=slot, semester='2023-春季'),
CourseSchedule.objects.all()
)
self.assertIn('该时间片教师已有其他课程', errors)
这类测试对业务逻辑的稳定性非常有价值。迁移完成之后,我跑了一遍全部测试,其中大概有三分之一的用例第一次跑失败,但绝大多数都是字符串类型、编码这类问题,真正涉及业务逻辑变化的很少。这也印证了Django迁移到新版本时,业务层的模型和视图改动往往不是最痛苦的,反而是那些不起眼的类型细节最耗时间。
6. 部署上线与性能优化的几个方向
6.1 本地开发与生产环境的差异
开发阶段我一直用Django自带的开发服务器,配SQLite,简单省事。但真正要稳定跑起来,还得按生产环境来收拾。我当时用的是很经典的组合:Nginx + uWSGI + MySQL + Django,Django静态文件交给Nginx直接处理,动态请求走uWSGI。
部署上踩得比较深的一个坑是静态文件。Django在DEBUG=False下不会托管静态文件,必须通过 collectstatic 把静态文件收集到指定目录,再让Nginx指向那个目录。一开始没配置好,页面上所有CSS和JS全部404,排课工作台看起来就是一个裸表格。后来在Nginx配置里加了几行:
location /static/ {
alias /data/www/schedule/static/;
expires 30d;
}
这个问题才算彻底解决。
另外,数据库从SQLite换到MySQL之后,有个字段差异需要注意。 TimeSlot 表里的 weekday 字段在SQLite里能直接用 0 表示周一,换到MySQL也没问题,但如果把BooleanField的默认值写错,MySQL会报数据长度问题。我的教训是:换数据库之后,一定要跑一遍迁移和测试,别直接用生产数据去试。
6.2 排课算法性能瓶颈怎么破
排课系统最耗时的部分不是数据库查询,而是排课算法的模拟分配过程。课程数量到100个的时候,嵌套循环会变得明显迟钝。我做了两个优化:
一是在 create_scheduler 中用局部变量替代频繁查询。比如 course.teacher.id 这种属性访问,在循环里反复调用会拖慢速度,直接提取到局部变量里。
二是在 order_time_slots 里引入缓存。同一门课在排课时会多次调用排序函数,排序结果一样,没有必要重复计算。
from functools import lru_cache
@lru_cache(maxsize=128)
def order_time_slots(course_id):
# 根据课程类型返回排好序的时间片
...
加了 lru_cache 之后,速度提升非常明显。后来课程量放宽到上千,排课时间也就几秒内完成,对于培训机构每天开几节课的场景完全够用了。
但如果说系统真的卡到不能跑,那瓶颈往往不是算法本身,而是页面上一次性渲染全部课表导致的。行数不多还好,一旦有几百条排课记录,模板循环加多次数据库查询就会很慢。解决这个问题,一个是用 select_related("course", "course__teacher", "classroom", "time_slot") 把关联查询合并,另一个就是我上面提到的 build_matrix 预分组。
我在实际使用中还有一个体会:排课系统这类管理工具,性能优化做得差不多就行,真正让用户觉得"好用"的,是操作是否流畅、错误提示是否准确、恢复误操作是否方便。有一阵子我花了大量精力去观察教务老师怎么用这个系统,发现他们最频繁的操作其实是"试排——不满意——重新试排",这个反复尝试的过程如果没有做内存模拟,直接一股脑写进数据库,那用户早就炸毛了。
所以如果你打算自己从零写一个类似系统,我建议把"可回滚的试排"放在优先级比较高的位置,它比算法本身更容易打动用户。另外,老项目解压出来没有密码的话,里面的 requirements.txt 和 settings.py 还在,拿去看看当时用了哪些第三方库,也是挺有意思的一件事。
更多推荐


所有评论(0)