ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Django学生选课系统实战:关系建模与权限分层

Django学生选课系统实战:关系建模与权限分层 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_length50) student_id models.CharField(max_length12, uniqueTrue) # 学号唯一非主键 email models.EmailField() class Course(models.Model): code models.CharField(max_length10, uniqueTrue) # 课程编码如 CS101 name models.CharField(max_length100) capacity models.PositiveIntegerField(default30) # 总容量 current_enrollments models.PositiveIntegerField(default0) # 实时已选人数冗余字段提升查询效率 class Enrollment(models.Model): student models.ForeignKey(Student, on_deletemodels.CASCADE, related_nameenrollments) course models.ForeignKey(Course, on_deletemodels.CASCADE, related_nameenrollments) enrollment_time models.DateTimeField(auto_now_addTrue) grade models.CharField(max_length2, blankTrue, nullTrue) # 成绩可为空 is_dropped models.BooleanField(defaultFalse) # 是否已退课 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_id123 AND is_droppedFalse快一个数量级——尤其当课程表有 10 万条记录时这个优化能让你的首页加载从 2s 降到 200ms。第三indexes索引组合直指高频查询场景查某个学生的未退课列表student_id is_dropped、查某门课的未退课学生数course_id is_dropped。SQLite 虽轻量但索引缺失会导致全表扫描这点新手常忽略。提示别急着执行makemigrations先手动检查生成的迁移文件。打开migrations/0001_initial.py确认Enrollment表的ForeignKey是否设置了on_deletemodels.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 的“迂回战术”字段默认值必须明确在模型中新增字段时强制指定default或nullTrue。例如# 错误没有 defaultDjango 会要求你在终端输入默认值但 SQLite 迁移会失败 teacher_name models.CharField(max_length50) # 正确提供默认值Django 会用 INSERT ... SELECT 方式重建表 teacher_name models.CharField(max_length50, default, nullTrue)利用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)]开发期切换数据库引擎在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 --databasedev_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_droppedFalse) 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 htmlhtml langzh-cn暴露了新手对 Django 模板继承的误解。他们以为只要写个base.html包含head和body就完事结果所有页面 CSS 全乱套。真正的骨架应该是三层嵌套!-- templates/base.html -- !DOCTYPE html html langzh-cn head meta charsetutf-8 title{% block title %}学生选课系统{% endblock %}/title !-- 全局 CSS如 Bootstrap、自定义 reset.css -- link relstylesheet href{% static css/base.css %} /head body header{% include partials/_nav.html %}/header main classcontainer {% 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 classrow div classcol-md-8{% block main_content %}{% endblock %}/div div classcol-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 classcard mb-3 div classcard-body h5 classcard-title{{ course.name }}/h5 p classcard-text 编码{{ course.code }} | 容量{{ course.capacity }} / {{ course.current_enrollments }} {% if course in enrolled_courses %} span classbadge bg-success已选/span {% else %} a href{% url enroll course.id %} classbtn 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; }模板中只需加 classdiv classcard {% if course in dropped_courses %}enrollment-dropped{% endif %}。用 CSS 控制视觉而非在 HTML 里写del{{ course.name }}/del——语义化和可维护性天壤之别。4. 表单与交互为什么“点一下就选课”背后有七层校验4.1 Django Form 的防御性设计选课按钮的href{% url enroll course.id %}看似简单但真实场景中必须应对七种失败路径学生未登录 → 重定向到登录页课程不存在 → 404 页面课程已满 → 显示“名额已满”提示学生已选该课 → 无操作返回原页并发选课冲突 → 数据库唯一约束报错网络中断 → 前端需显示加载状态成功后跳转 → 不能留在空白页要反馈结果把这些塞进一个视图函数里代码会失控。Django Form 是天然的防御工事# forms.py class EnrollmentForm(forms.Form): course_id forms.IntegerField(widgetforms.HiddenInput()) def clean_course_id(self): course_id self.cleaned_data[course_id] try: course Course.objects.get(idcourse_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( studentstudent, coursecourse, is_droppedFalse ).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, userrequest.user) if request.method POST and form.is_valid(): try: with transaction.atomic(): # 关键开启事务保证原子性 course Course.objects.select_for_update().get(idform.cleaned_data[course_id]) if course.current_enrollments course.capacity: raise ValidationError(余量检查失败) Enrollment.objects.create( studentrequest.user.student_profile, coursecourse ) 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 methodpost {% csrf_token %} {{ form.course_id }} button typesubmit classbtn btn-primary idenroll-btn span classbtn-text确认选课/span span classbtn-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 的可靠性 }); /scriptCSS 仅负责视觉状态.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__teacherrequest.user.teacher_profile) return qs这样教师登录 admin 后/admin/course/enrollment/页面自动只显示自己课程的数据无需修改任何 HTML 模板。而has_module_permission控制菜单项是否显示——如果教师没有查看选课记录的权限左侧菜单根本不会出现这个链接。5.2 用户角色的数据库落地实践Django 的User模型默认只有is_staff和is_superuser但学生、教师、管理员是三个独立角色。常见错误是用is_staffTrue表示管理员is_superuserTrue表示超级管理员结果权限混杂。正确方案是扩展User模型# models.py class StudentProfile(models.Model): user models.OneToOneField(User, on_deletemodels.CASCADE, related_namestudent_profile) major models.CharField(max_length50) class TeacherProfile(models.Model): user models.OneToOneField(User, on_deletemodels.CASCADE, related_nameteacher_profile) department models.CharField(max_length50) # signals.py from django.db.models.signals import post_save from django.dispatch import receiver receiver(post_save, senderUser) def create_user_profile(sender, instance, created, **kwargs): if created: if instance.is_staff and not instance.is_superuser: TeacherProfile.objects.create(userinstance) elif not instance.is_staff and not instance.is_superuser: StudentProfile.objects.create(userinstance)注册用户时通过UserCreationForm的save()方法自动创建对应 Profile# forms.py class StudentRegistrationForm(UserCreationForm): email forms.EmailField(requiredTrue) class Meta: model User fields (username, email, password1, password2) def save(self, commitTrue): user super().save(commitFalse) user.email self.cleaned_data[email] if commit: user.save() StudentProfile.objects.create(useruser) # 自动创建学生档案 return user这样request.user.student_profile和request.user.teacher_profile就成了权限判断的可靠依据比user.groups.filter(nameTeacher).exists()更高效、更直观。最后分享一个实战技巧在settings.py中设置DEBUGFalse时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(codeCS101, namePython编程入门, capacity50), Course(codeMATH201, name高等数学, capacity80), # ... 1000条数据 ] Course.objects.bulk_create(courses, batch_size100) class Migration(migrations.Migration): dependencies [(course, 0002_add_teacher_name)] operations [migrations.RunPython(load_sample_data)]batch_size100避免内存溢出这才是真实项目里的“小技巧”。
返回列表