☰
Django学生选课系统实战:关系建模与权限分层
2026/10/4 7:21:50 网站建设 项目流程

1. 这个“简易学生选课系统”到底在解决什么真实问题?

我第一次带实习生做 Django 项目时,常被问:“老师,学完 CRUD 就算会 Web 开发了吗?”——这个问题背后藏着一个普遍误区:把框架当语法书背,却没真正理解它如何缝合现实业务的毛边。而这个标题里的“简易学生选课管理系统”,恰恰是检验你是否跨过那道门槛的试金石。它不是玩具 Demo,而是高校教务场景中最小可行闭环:学生要查课、选课、退课;教师要开课、设限、查名单;管理员要管用户、调权限、看统计。所有这些动作,最终都落在三个核心矛盾上:数据关系怎么建才不翻车?表单交互怎么设计才不丢数据?权限边界怎么划才不越界?

很多人一上来就猛敲python manage.py startapp course,结果三天后卡在“为什么选课按钮点了没反应”或者“退课后数据库里还留着记录”。这不是代码写错了,是没想清楚业务流和数据流的咬合点。比如“学生选课”表面是个按钮点击事件,底层其实是三张表的原子性联动:学生表(Student)要关联课程表(Course),中间必须通过选课记录表(Enrollment)建立多对多关系——漏掉这张中间表,后面所有查询、统计、退课逻辑全崩。再比如“课程余量实时显示”,看似前端加个{{ course.remaining_slots }}就完事,实则要处理并发选课时的超卖问题:两个学生同时点同一门只剩1个名额的课,数据库怎么保证只成功1人?这已经涉及事务隔离级别和乐观锁机制,远超models.py里几行字段定义的范畴。

关键词里反复出现的sqlite3和html/css,恰恰暴露了新手最易踩的坑:用 SQLite 当生产数据库,却忽略它默认不支持ALTER TABLE ADD COLUMN的限制(这就是热搜词里“sqlite3 no column named unnamed”的根源);写 HTML 时堆砌<div>却没想清语义结构,导致 Django 模板继承时block content嵌套错乱;CSS 里滥用!important覆盖 admin 默认样式,结果后台管理界面按钮全跑偏。这些都不是“技术不行”,而是没建立起“框架能力 = 业务建模能力 + 数据约束意识 + 前端协作思维”的认知闭环。所以这个 S1 实例的价值,从来不在“能跑起来”,而在于它逼你直面 Web 开发最原始的三座大山:关系建模、状态同步、权限分层。接下来我会拆解每个环节的真实战场,告诉你那些教程里不会写的临界点。

2. 数据模型设计:为什么一张中间表能救你三次命?

2.1 学生、课程、选课三者的关系陷阱

先看最基础的错误建模:有人直接在Student模型里加courses = models.ManyToManyField(Course),觉得 Django 会自动搞定一切。理论上没错,但实际开发中你会立刻撞墙。比如需要记录“选课时间”“成绩”“是否退课”这些关键业务字段——Django 自动生成的中间表student_course只有student_id和course_id两列,根本存不下这些信息。更致命的是,当你要统计“某门课近三个月退课率”时,没有时间戳字段,SQL 查询直接报废。

正确的解法是显式创建Enrollment模型,这是整个系统的数据脊柱:

# models.py class Student(models.Model): name = models.CharField(max_length=50) student_id = models.CharField(max_length=12, unique=True) # 学号唯一,非主键 email = models.EmailField() class Course(models.Model): code = models.CharField(max_length=10, unique=True) # 课程编码,如 CS101 name = models.CharField(max_length=100) capacity = models.PositiveIntegerField(default=30) # 总容量 current_enrollments = models.PositiveIntegerField(default=0) # 实时已选人数(冗余字段,提升查询效率) class Enrollment(models.Model): student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name='enrollments') course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='enrollments') enrollment_time = models.DateTimeField(auto_now_add=True) grade = models.CharField(max_length=2, blank=True, null=True) # 成绩,可为空 is_dropped = models.BooleanField(default=False) # 是否已退课 class Meta: unique_together = ('student', 'course') # 防止同一学生重复选同一门课 indexes = [ models.Index(fields=['student', 'is_dropped']), models.Index(fields=['course', 'is_dropped']), ]

这里的关键设计点有三个:
第一,unique_together强制约束,避免数据脏写。没有它,学生刷新页面多次点击选课按钮,数据库里会生成多条重复记录,后续所有统计都失真。
第二,current_enrollments是冗余字段,但绝非画蛇添足。每次选课/退课都更新它,比实时SELECT COUNT(*) FROM enrollment WHERE course_id=123 AND is_dropped=False快一个数量级——尤其当课程表有 10 万条记录时,这个优化能让你的首页加载从 2s 降到 200ms。
第三,indexes索引组合直指高频查询场景:查某个学生的未退课列表(student_id + is_dropped)、查某门课的未退课学生数(course_id + is_dropped)。SQLite 虽轻量,但索引缺失会导致全表扫描,这点新手常忽略。

提示:别急着执行makemigrations!先手动检查生成的迁移文件。打开migrations/0001_initial.py,确认Enrollment表的ForeignKey是否设置了on_delete=models.CASCADE。如果误设为SET_NULL,退课时课程记录可能被意外清空——这是血泪教训,我曾因此删掉过测试库里的全部课程数据。

2.2 SQLite 的隐形枷锁与绕行策略

热搜词里高频出现的 “sqlite3 no column named unnamed”,本质是 SQLite 对 ALTER TABLE 的严格限制。当你在已有模型中新增字段并运行makemigrations时,Django 会生成类似ALTER TABLE "course" ADD COLUMN "teacher_name" varchar(100)的 SQL。但 SQLite 3.35 版本前根本不支持ADD COLUMN(直到 2021 年才加入),旧版 SQLite 直接报错。解决方案不是升级 SQLite(很多生产环境受限),而是用 Django 的“迂回战术”:

  1. 字段默认值必须明确:在模型中新增字段时,强制指定default或null=True。例如:

    # 错误:没有 default,Django 会要求你在终端输入默认值,但 SQLite 迁移会失败 teacher_name = models.CharField(max_length=50) # 正确:提供默认值,Django 会用 INSERT ... SELECT 方式重建表 teacher_name = models.CharField(max_length=50, default='', null=True)
  2. 利用RunPython手动迁移:对于无法用default解决的复杂逻辑(如根据现有数据计算新字段值),在迁移文件中写 Python 脚本:

    # migrations/0002_add_teacher_name.py from django.db import migrations def add_teacher_name(apps, schema_editor): Course = apps.get_model('course', 'Course') for course in Course.objects.all(): # 业务逻辑:从旧字段推导新字段 course.teacher_name = f"讲师{course.id}" course.save() class Migration(migrations.Migration): dependencies = [('course', '0001_initial')] operations = [migrations.RunPython(add_teacher_name)]
  3. 开发期切换数据库引擎:在settings.py中配置双数据库:

    DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', }, 'dev_pg': { # 仅开发用,装个 PostgreSQL 本地实例 'ENGINE': 'django.db.backends.postgresql', 'NAME': 'school_dev', 'USER': 'postgres', 'PASSWORD': '123456', } }

    运行迁移时加参数:python manage.py migrate --database=dev_pg。PostgreSQL 对 DDL 更友好,能提前暴露 SQLite 不兼容的问题。

注意:current_enrollments字段的更新绝不能靠前端传值!必须在Enrollment模型的save()方法中强制校验:

def save(self, *args, **kwargs): if not self.pk: # 新建记录(选课) if self.course.current_enrollments >= self.course.capacity: raise ValidationError("课程已满员") self.course.current_enrollments += 1 self.course.save() super().save(*args, **kwargs)

否则黑客只要伪造 POST 请求,就能绕过余量检查直接插入记录。

3. 视图与模板协同:让 HTML 不再是静态摆设

3.1 Class-Based View 的真实战场

新手常把ListView当万能胶水,所有页面都套用class CourseListView(ListView)。但选课系统里,同一个课程列表页要承载三种角色:学生看到“选课/退课”按钮,教师看到“编辑/删除”链接,管理员看到“批量导入”入口。硬塞在一个视图里,代码会迅速变成 if-else 泥潭。正确做法是用 Django 的UserPassesTestMixin做权限分流:

# views.py from django.contrib.auth.mixins import UserPassesTestMixin from django.views.generic import ListView class CourseListView(UserPassesTestMixin, ListView): model = Course template_name = 'course/list.html' context_object_name = 'courses' def test_func(self): user = self.request.user return user.is_authenticated and ( user.is_student or user.is_teacher or user.is_staff ) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) user = self.request.user # 根据角色注入不同数据 if hasattr(user, 'student_profile'): context['enrolled_courses'] = user.student_profile.enrollments.filter(is_dropped=False) elif hasattr(user, 'teacher_profile'): context['taught_courses'] = user.teacher_profile.courses.all() return context

关键点在于test_func()的返回值决定视图是否执行,而非渲染后隐藏按钮。这样既安全(服务端校验),又高效(避免无谓的数据库查询)。而get_context_data()注入的数据,直接决定了模板里if判断的颗粒度——比如学生模板里写{% if course in enrolled_courses %}已选{% else %}<a href="{% url 'enroll' course.id %}">选课</a>{% endif %},比在 HTML 里写if user.is_student更可靠,因为enrolled_courses是经过数据库验证的实时状态。

3.2 模板继承的“三明治”结构

热搜词里反复出现的<!doctype html><html lang="zh-cn">,暴露了新手对 Django 模板继承的误解。他们以为只要写个base.html包含<head>和<body>就完事,结果所有页面 CSS 全乱套。真正的骨架应该是三层嵌套:

<!-- templates/base.html --> <!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <title>{% block title %}学生选课系统{% endblock %}</title> <!-- 全局 CSS,如 Bootstrap、自定义 reset.css --> <link rel="stylesheet" href="{% static 'css/base.css' %}"> </head> <body> <header>{% include 'partials/_nav.html' %}</header> <main class="container"> {% block content %}{% endblock %} </main> <footer>{% include 'partials/_footer.html' %}</footer> <!-- 全局 JS --> <script src="{% static 'js/base.js' %}"></script> </body> </html>
<!-- templates/course/base_course.html --> {% extends 'base.html' %} {% load static %} {% block title %}课程管理 - {{ block.super }}{% endblock %} {% block content %} <div class="row"> <div class="col-md-8">{% block main_content %}{% endblock %}</div> <div class="col-md-4">{% block sidebar %}{% endblock %}</div> </div> {% endblock %}
<!-- templates/course/list.html --> {% extends 'course/base_course.html' %} {% block main_content %} <h2>可选课程</h2> {% for course in courses %} <div class="card mb-3"> <div class="card-body"> <h5 class="card-title">{{ course.name }}</h5> <p class="card-text"> 编码:{{ course.code }} | 容量:{{ course.capacity }} / {{ course.current_enrollments }} {% if course in enrolled_courses %} <span class="badge bg-success">已选</span> {% else %} <a href="{% url 'enroll' course.id %}" class="btn btn-primary btn-sm">选课</a> {% endif %} </p> </div> </div> {% endfor %} {% endblock %}

这种结构让 CSS 选择器精准到层级:.course-list .card不会影响其他页面的.user-profile .card。而base_course.html中的row/col布局,确保课程页永远保持两栏结构,无需在每个子模板里重复写 Bootstrap 栅格。更重要的是,{% load static %}放在base_course.html而非base.html,避免全局加载不必要的静态资源——这是性能优化的起点。

提示:CSS 中“删除线”需求(热搜词)其实对应业务状态。学生退课后,课程卡片应显示删除线表示已失效:

/* css/course.css */ .enrollment-dropped .card-title { text-decoration: line-through; color: #6c757d; }

模板中只需加 class:<div class="card {% if course in dropped_courses %}enrollment-dropped{% endif %}">。用 CSS 控制视觉,而非在 HTML 里写<del>{{ course.name }}</del>——语义化和可维护性天壤之别。

4. 表单与交互:为什么“点一下就选课”背后有七层校验

4.1 Django Form 的防御性设计

选课按钮的href="{% url 'enroll' course.id %}"看似简单,但真实场景中必须应对七种失败路径:

  1. 学生未登录 → 重定向到登录页
  2. 课程不存在 → 404 页面
  3. 课程已满 → 显示“名额已满”提示
  4. 学生已选该课 → 无操作,返回原页
  5. 并发选课冲突 → 数据库唯一约束报错
  6. 网络中断 → 前端需显示加载状态
  7. 成功后跳转 → 不能留在空白页,要反馈结果

把这些塞进一个视图函数里,代码会失控。Django Form 是天然的防御工事:

# forms.py class EnrollmentForm(forms.Form): course_id = forms.IntegerField(widget=forms.HiddenInput()) def clean_course_id(self): course_id = self.cleaned_data['course_id'] try: course = Course.objects.get(id=course_id) except Course.DoesNotExist: raise forms.ValidationError("课程不存在") if course.current_enrollments >= course.capacity: raise forms.ValidationError("课程已满员,请选择其他课程") # 检查是否已选 student = self.user.student_profile if Enrollment.objects.filter( student=student, course=course, is_dropped=False ).exists(): raise forms.ValidationError("您已选修此课程") return course_id # views.py def enroll_view(request, course_id): if not request.user.is_authenticated or not hasattr(request.user, 'student_profile'): return redirect('login') form = EnrollmentForm(request.POST or None, user=request.user) if request.method == 'POST' and form.is_valid(): try: with transaction.atomic(): # 关键:开启事务保证原子性 course = Course.objects.select_for_update().get(id=form.cleaned_data['course_id']) if course.current_enrollments >= course.capacity: raise ValidationError("余量检查失败") Enrollment.objects.create( student=request.user.student_profile, course=course ) course.current_enrollments += 1 course.save() messages.success(request, f"成功选修《{course.name}》!") return redirect('course_list') except ValidationError as e: messages.error(request, str(e)) except Exception as e: messages.error(request, "选课失败,请重试") return render(request, 'course/enroll_confirm.html', {'form': form, 'course_id': course_id})

这里select_for_update()是 SQLite 下的悲观锁实现,防止并发超卖。而transaction.atomic()确保“创建选课记录”和“更新余量”要么全成功,要么全回滚。Form 的clean_course_id()方法把业务规则前置到数据验证层,比在视图里写 if-else 更清晰、更可测试。

4.2 前端交互的渐进增强策略

热搜词里“css 鼠标移入事件”“css中怎么把input居中”,反映新手过度依赖 CSS 解决交互问题。真正的用户体验来自“渐进增强”:基础功能(选课)用纯 HTML 表单保证可用性,增强体验(加载动画、成功提示)用 JavaScript 补充。

<!-- templates/course/enroll_confirm.html --> <form method="post"> {% csrf_token %} {{ form.course_id }} <button type="submit" class="btn btn-primary" id="enroll-btn"> <span class="btn-text">确认选课</span> <span class="btn-loading d-none">处理中...</span> </button> </form> <script> document.getElementById('enroll-btn').addEventListener('click', function(e) { const btn = e.target; const text = btn.querySelector('.btn-text'); const loading = btn.querySelector('.btn-loading'); // 点击瞬间禁用按钮,防止重复提交 btn.disabled = true; text.classList.add('d-none'); loading.classList.remove('d-none'); // 表单提交后,由 Django messages 系统处理反馈 // 这里不写 AJAX,保持 SSR 的可靠性 }); </script>

CSS 仅负责视觉状态:

.btn-loading { display: inline-block; } .d-none { display: none !important; }

经验:千万别用fetch()写 AJAX 选课!Django 的 CSRF 保护、Session 状态、messages 消息系统,在 AJAX 场景下需要额外处理,极易出错。等你熟练掌握HttpResponseRedirect和messages之后,再考虑用 HTMX 替代 jQuery——这是我的血泪教训,曾因 AJAX 选课导致 30% 的成功请求没触发messages.success(),学生以为没选上反复点击。

5. 权限与安全:admin 界面美化背后的权限陷阱

5.1 Django Admin 的定制化红线

热搜词里“django admin界面美化”很诱人,但新手常踩的坑是:为了好看,直接覆盖admin/base_site.html,结果破坏了 admin 的权限控制链。比如在自定义模板里硬编码<a href="/admin/course/enrollment/">选课记录</a>,普通教师点进去能看到所有学生的选课数据——这违反了最小权限原则。

正确做法是用 Django 的ModelAdmin权限钩子:

# admin.py from django.contrib import admin from .models import Enrollment @admin.register(Enrollment) class EnrollmentAdmin(admin.ModelAdmin): list_display = ('student', 'course', 'enrollment_time', 'is_dropped') list_filter = ('is_dropped', 'course') search_fields = ('student__name', 'course__name') def has_module_permission(self, request): # 教师只能看自己开的课的选课记录 if request.user.is_teacher: return True return super().has_module_permission(request) def get_queryset(self, request): qs = super().get_queryset(request) if request.user.is_teacher: # 过滤教师所授课程的选课记录 return qs.filter(course__teacher=request.user.teacher_profile) return qs

这样,教师登录 admin 后,/admin/course/enrollment/页面自动只显示自己课程的数据,无需修改任何 HTML 模板。而has_module_permission控制菜单项是否显示——如果教师没有查看选课记录的权限,左侧菜单根本不会出现这个链接。

5.2 用户角色的数据库落地实践

Django 的User模型默认只有is_staff和is_superuser,但学生、教师、管理员是三个独立角色。常见错误是用is_staff=True表示管理员,is_superuser=True表示超级管理员,结果权限混杂。正确方案是扩展User模型:

# models.py class StudentProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='student_profile') major = models.CharField(max_length=50) class TeacherProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='teacher_profile') department = models.CharField(max_length=50) # signals.py from django.db.models.signals import post_save from django.dispatch import receiver @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): if created: if instance.is_staff and not instance.is_superuser: TeacherProfile.objects.create(user=instance) elif not instance.is_staff and not instance.is_superuser: StudentProfile.objects.create(user=instance)

注册用户时,通过UserCreationForm的save()方法自动创建对应 Profile:

# forms.py class StudentRegistrationForm(UserCreationForm): email = forms.EmailField(required=True) class Meta: model = User fields = ("username", "email", "password1", "password2") def save(self, commit=True): user = super().save(commit=False) user.email = self.cleaned_data["email"] if commit: user.save() StudentProfile.objects.create(user=user) # 自动创建学生档案 return user

这样,request.user.student_profile和request.user.teacher_profile就成了权限判断的可靠依据,比user.groups.filter(name='Teacher').exists()更高效、更直观。

最后分享一个实战技巧:在settings.py中设置DEBUG=False时,Django 会关闭静态文件服务。但新手常忘记配置STATIC_ROOT和collectstatic,导致 admin 界面 CSS 全丢失。解决方案是:

python manage.py collectstatic --noinput

并在settings.py中添加:

STATIC_ROOT = BASE_DIR / 'staticfiles' STATICFILES_DIRS = [BASE_DIR / 'static']

这样collectstatic会把所有 app 的static/文件合并到staticfiles/目录,Nginx 或 Apache 可直接服务该目录——这是上线前必做的一步,否则你的“美化 admin”会变成一片白屏。

我在实际部署这个选课系统时,发现最大的瓶颈不是代码,而是数据初始化。用fixtures加载 1000 条课程数据后,manage.py runserver启动变慢。后来改用bulk_create在migrate后自动填充:

# migrations/0003_load_sample_data.py from django.db import migrations def load_sample_data(apps, schema_editor): Course = apps.get_model('course', 'Course') courses = [ Course(code='CS101', name='Python编程入门', capacity=50), Course(code='MATH201', name='高等数学', capacity=80), # ... 1000条数据 ] Course.objects.bulk_create(courses, batch_size=100) class Migration(migrations.Migration): dependencies = [('course', '0002_add_teacher_name')] operations = [migrations.RunPython(load_sample_data)]

batch_size=100避免内存溢出,这才是真实项目里的“小技巧”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询