简介:本资源是一款基于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 我把问题拆成了哪几步

排课问题本质上是一个带约束的资源分配问题。教室里有人上课就不能再排,教师同一时间不能分身,班级也不能同时上两门课。要找到全局最优解,这是个组合爆炸的问题,课堂数量稍微一多,穷举法直接不可行。

我当时的策略是放弃全局最优,用两步走:

  1. 先按约束强度排序,约束越多越难排的课程优先安排。
  2. 每一步都用冲突检测函数检查候选时间段,找到第一个不冲突的时间段就占上,找不到就记入待处理列表。

这套方案本质上是贪心+冲突消解。虽然不能保证得到理论最优解,但在一个几十个教学班、十来个教室的培训机构场景里,解的质量已经足够好了。真正排不出来的课程,大多数是教师的可用时间被排满或者教室类型不匹配,这类问题靠算法硬找不如直接暴露给教务老师去判断。

另外一个关键设计是: 算法不直接写入数据库 。我先在内存里构建一套"已占用的资源表",在这个表上进行模拟分配,全部排完并确认没有冲突后,再一次性落库。这样用户可以在页面上反复调参数"试排",不满意就撤回到试排之前的状态,不会污染真实课表。

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 还在,拿去看看当时用了哪些第三方库,也是挺有意思的一件事。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

更多推荐