Django做学生请假系统这事儿,我前前后后给学校、实训基地搭过好几版了。很多人上来就问用什么框架、怎么建表,其实真正花时间的从来不是CRUD那点代码,而是角色权限怎么设计、审批流怎么走、状态怎么流转才算合理。这篇文章就围绕“基于Python+Django的大学生请假管理系统”这个项目,把我从建模到部署踩过的坑、验证过的方案一次性说清楚,适合正在做课程设计、毕业设计,或者想拿Django练手做完整项目的同学参考。
刚接触这个项目的人,最容易犯的毛病是一上来就写代码。请假系统看起来简单,无非是学生提交、辅导员审批、记录查一下,但真做起来,你会发现角色权限、状态流转、统计报表、消息通知,每一块都能折腾你几天。尤其是权限设计,很多人用if判断用户角色,代码写到最后自己都绕晕了。我后面用的方案是Django自带的用户模型扩展加自定义权限,配合装饰器做视图级拦截,整个系统逻辑清晰很多,扩展新角色也不费劲。
1. 项目整体设计与思路拆解
1.1 选型背景:为什么用Django而不是Flask或Spring
先说选型。大学生请假管理系统属于典型的管理信息系统,特点是表单多、权限分明、流程固定,对并发要求不高但对开发效率和维护性要求高。这个场景下Django确实比Flask合适,因为Flask把ORM、Admin、认证这些全留给开发者自己拼,而Django把这些都集成了,开箱即用。
我见过不少同学用Flask写这个题目,最后Admin后台全是自己手搓的HTML,费力不说还容易出安全漏洞。Django自带的Admin后台,稍微配置一下就能管理用户、审批请假单,省下来的时间足够你把前端页面打磨得更好。另一方面,相比Spring全家桶,Python+Django的学习成本明显更低,尤其对于课程设计和毕业设计这种时间紧张的项目,Django的“约定优于配置”能让你快速跑通全流程。
1.2 角色与功能模块划分
一个标准的大学请假管理场景,至少要包含三种角色:
- 学生:提交请假申请、查看审批进度、撤销未审批的申请、查看历史记录
- 辅导员/班主任:审批自己管辖学生的请假、查看学生请假统计
- 管理员/院系领导:管理用户、查看全院请假数据、导出统计报表
功能模块对应的核心需求可以拆成四块:用户认证与权限管理、请假单的CRUD、审批流程状态管理、数据统计与导出。每一块在Django里都有对应的最佳实践,后面我会挨个讲。
这种多角色系统,数据模型设计是成败关键。我见过有人用一张表存所有用户,用字段区分角色,配合一堆if判断来做权限控制,短期能跑,但一旦要加“代课教师”“宿管员”这类角色,代码就开始失控。正确做法是利用Django的AbstractUser扩展用户模型,把角色信息、所属班级、学号这些业务字段放进去,权限用Django的Permission体系管理。
1.3 数据流与页面流转
整个系统的数据流大概是这样的:学生登录后填写请假表单,系统创建一条状态为“待审批”的请假记录;辅导员登录后看到待审批列表,同意或驳回;同意后记录进入“已批准”状态,驳回则填写驳回原因退回给学生。管理员可以跨越审批流直接查看所有记录,也可以按班级、时间、状态筛选导出。
页面流转上,我建议不做复杂的单页应用,用Django模板渲染多页面就够了,这样最简单也最容易维护。核心页面就六个:登录页、学生首页、请假申请页、请假记录列表页、审批列表页、系统管理后台。把焦点放在流程正确性和权限安全性上,这个项目的完成度会远超预期。
2. 核心模型设计与数据库实现
2.1 用户模型:基于AbstractUser的自定义扩展
这是整个系统最先要写、也最不能改错的地方,因为一旦migrate过、数据表生成了,再改用户模型会非常痛苦。我的建议是项目创建后第一件事就扩展用户模型,不要用Django默认的User。
具体做法是:
from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): ROLE_CHOICES = ( ('student', '学生'), ('counselor', '辅导员'), ('admin', '管理员'), ) role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='student', verbose_name='角色') student_id = models.CharField(max_length=20, blank=True, null=True, verbose_name='学号') phone = models.CharField(max_length=11, blank=True, null=True, verbose_name='手机号') major = models.CharField(max_length=50, blank=True, null=True, verbose_name='专业') class Meta: verbose_name = '用户' verbose_name_plural = verbose_name def __str__(self): return f"{self.username}-{self.get_role_display()}"在settings.py里配置:
AUTH_USER_MODEL = 'leave_app.User'这里有几个关键点。第一,AUTH_USER_MODEL必须在使用User的任何migrate之前设置好,否则会出现迁移冲突的报错。第二,班级信息建议用外键关联到一个单独的Class模型,不要直接存字符串,因为后面按班级筛选统计会用到。第三,student_id要设置unique=True,学号是学生身份的唯一标识,不能重复。
2.2 请假单模型:状态机设计
请假单是这个系统的核心模型,状态设计决定了审批流程能不能清晰执行。我常用的做法是定义一个LeaveRequest模型,状态字段用整数加choices,比字符串可读性更好,也比单纯的CharField更省存储空间。
class LeaveRequest(models.Model): STATUS_CHOICES = ( (0, '待审批'), (1, '已批准'), (2, '已驳回'), (3, '已撤销'), (4, '已销假'), ) student = models.ForeignKey(User, on_delete=models.CASCADE, related_name='leave_requests', verbose_name='请假学生') leave_type = models.CharField(max_length=20, choices=( ('sick', '病假'), ('personal', '事假'), ('other', '其他'), ), verbose_name='请假类型') start_time = models.DateTimeField(verbose_name='开始时间') end_time = models.DateTimeField(verbose_name='结束时间') reason = models.TextField(verbose_name='请假事由') status = models.IntegerField(choices=STATUS_CHOICES, default=0, verbose_name='状态') approver = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='approved_leaves', verbose_name='审批人') reject_reason = models.TextField(blank=True, null=True, verbose_name='驳回原因') created_at = models.DateTimeField(auto_now_add=True, verbose_name='提交时间') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') class Meta: ordering = ['-created_at'] verbose_name = '请假单' verbose_name_plural = verbose_name状态流转的规则是这样的:新建时是0(待审批),辅导员审批同意变成1(已批准),驳回变成2(已驳回),学生自己可以在待审批状态下撤销变成3(已撤销),请假归来后可以申请销假变成4(已销假)。这里最需要注意的一点是状态变更必须受约束,不能允许从“已驳回”直接跳到“已批准”,也不允许学生撤销已经批准的假条。我后面会在视图层统一封装一个状态变更方法,把所有合法流转集中在一处管理,而不是散落在各个视图里,这样逻辑不容易乱。
2.3 关联模型:班级与通知
班级模型很简单,就是Class表,包含班级名称和辅导员的关联。这里有个设计细节值得讨论,就是一个辅导员通常带多个班级,一个班级也可能有多个辅导员,所以我用了一个中间表或者直接在辅导员字段上存多对多关系。
通知模型我也建议加上,否则学生每次都要主动刷新页面看审批结果,体验很糟糕。可以设计一个Notification模型:
class Notification(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications') content = models.CharField(max_length=255, verbose_name='通知内容') is_read = models.BooleanField(default=False, verbose_name='是否已读') created_at = models.DateTimeField(auto_now_add=True)用Django的signal在请假单状态变更时自动生成通知,这个做法比在视图中手动创建通知要干净得多。后面我会讲具体怎么写signal。
3. 关键功能实现与实操细节
3.1 认证登录与权限控制
Django自带的认证系统足够满足这个项目,不需要自己写session逻辑。登录视图用django.contrib.auth的authenticate和login函数即可。
from django.contrib.auth import authenticate, login from django.shortcuts import render, redirect def login_view(request): if request.method == 'POST': username = request.POST.get('username') password = request.POST.get('password') user = authenticate(request, username=username, password=password) if user is not None: login(request, user) # 根据角色跳转到不同首页 if user.role == 'student': return redirect('student_home') elif user.role == 'counselor': return redirect('counselor_home') return redirect('admin_home') else: return render(request, 'login.html', {'error': '用户名或密码错误'}) return render(request, 'login.html')权限控制是重点。我强烈建议用user_passes_test这类装饰器做角色拦截,而不是在每个视图里写if判断。比如:
from django.contrib.auth.decorators import user_passes_test def is_counselor(user): return user.is_authenticated and user.role == 'counselor' @user_passes_test(is_counselor, login_url='/login/') def counselor_home(request): ...这样做的好处是权限规则集中、可复用,而且写起来直观。学生视图和辅导员视图的权限完全隔离,即使学生手工输入辅导员页面的URL,也会被弹回登录页,不会出现越权访问。
3.2 请假表单提交与验证
表单这块,新手容易犯的错是不做时间合法性校验。学生填一个开始时间晚于结束时间的假条,系统竟然能提交成功,这种问题在答辩时被老师一问就露馅。
我用ModelForm加自定义清理方法解决:
from django import forms from .models import LeaveRequest class LeaveRequestForm(forms.ModelForm): class Meta: model = LeaveRequest fields = ['leave_type', 'start_time', 'end_time', 'reason'] widgets = { 'start_time': forms.DateTimeInput(attrs={'type': 'datetime-local'}), 'end_time': forms.DateTimeInput(attrs={'type': 'datetime-local'}), } labels = { 'leave_type': '请假类型', 'start_time': '开始时间', 'end_time': '结束时间', 'reason': '请假事由', } def clean(self): cleaned_data = super().clean() start_time = cleaned_data.get('start_time') end_time = cleaned_data.get('end_time') if start_time and end_time and start_time >= end_time: raise forms.ValidationError('结束时间必须晚于开始时间') if start_time and end_time and (end_time - start_time).days > 30: raise forms.ValidationError('单次请假不能超过30天') return cleaned_data注意datetime-local这个widget,HTML5自带日期时间选择器,前端不用写任何JS,提交的数据是YYYY-MM-DDTHH:mm格式,Django的forms框架能自动解析成datetime对象,非常省事。
3.3 审批流程与状态变更封装
审批视图的逻辑核心是状态变更。我前面提到把所有状态流转封装成模型方法,具体实现是这样的:
class LeaveRequest(models.Model): # ...字段省略... def approve(self, approver): if self.status != 0: raise ValueError('只能审批待审批状态的申请') self.status = 1 self.approver = approver self.save() def reject(self, approver, reason): if self.status != 0: raise ValueError('只能驳回待审批状态的申请') if not reason: raise ValueError('驳回原因不能为空') self.status = 2 self.approver = approver self.reject_reason = reason self.save() def cancel(self): if self.status != 0: raise ValueError('只有待审批状态的申请才能撤销') self.status = 3 self.save() def complete(self): if self.status != 1: raise ValueError('只有已批准的申请才能销假') self.status = 4 self.save()这样做的最大好处是,业务规则集中在模型层,视图层只需调用方法即可,不会出现某个视图忘了检查状态导致逻辑漏洞。审批视图大致长这样:
@user_passes_test(is_counselor, login_url='/login/') def approve_leave(request, pk): leave_request = get_object_or_404(LeaveRequest, pk=pk) if request.method == 'POST': action = request.POST.get('action') try: if action == 'approve': leave_request.approve(request.user) elif action == 'reject': reason = request.POST.get('reject_reason', '').strip() leave_request.reject(request.user, reason) return redirect('pending_list') except ValueError as e: return render(request, 'approve_page.html', {'leave_request': leave_request, 'error': str(e)}) return render(request, 'approve_page.html', {'leave_request': leave_request})这里还要注意一个细节:辅导员只能看到自己名下学生的请假单,这需要在查询时做过滤。很多教程里直接返回所有待审批单,这在单辅导员测试环境没问题,但放到真实场景就是严重的数据越权。正确写法是:
def pending_list(request): # 这里假设User通过counselor_classes关联班级 my_classes = request.user.counselor_classes.all() pending_leaves = LeaveRequest.objects.filter( status=0, student__student_class__in=my_classes ).select_related('student') return render(request, 'pending_list.html', {'leaves': pending_leaves})3.4 自动通知:用Signal解耦业务
审批完成后要通知学生,最优雅的方式是用Django的post_save信号监控LeaveRequest的状态变化。
from django.db.models.signals import post_save from django.dispatch import receiver from .models import LeaveRequest, Notification @receiver(post_save, sender=LeaveRequest) def create_leave_notification(sender, instance, **kwargs): if kwargs.get('created'): return # 新建申请时不需要通知,因为学生自己知道 # 状态变化时通知学生 if instance.status in (1, 2): status_text = instance.get_status_display() content = f"您的请假申请已被{status_text}" if instance.status == 2 and instance.reject_reason: content += f",驳回原因:{instance.reject_reason}" Notification.objects.create(user=instance.student, content=content)用signal的好处是,通知逻辑和审批视图解耦,将来加短信、邮件、企业微信推送,只需要在signal里扩展,审批视图一行都不用改。
4. 开发环境搭建与页面实现要点
4.1 从零搭建开发环境
很多同学在环境这一步就被卡住,尤其是Windows下装Python和Django。这里给一份我实测过的流程:
- 去Python官网下载3.10或3.11版本安装包,安装时务必勾选“Add Python to PATH”
- 打开命令行,输入
python --version验证安装 - 创建虚拟环境:
python -m venv venv - 激活虚拟环境:Windows执行
venv\Scripts\activate,Linux/Mac执行source venv/bin/activate - 安装Django:
pip install django,建议指定版本如pip install django==5.0.6 - 创建项目:
django-admin startproject leave_system - 创建应用:
python manage.py startapp leave_app
关于虚拟环境,我多说一句。很多人图省事直接用全局环境装Django,短期没问题,但项目多了以后,A项目要用Django 3.2、B项目要用5.0,全局环境就打架了。虚拟环境就是给每个项目单独隔离一套Python包,这个习惯从第一天就要养成。
数据库方面,开发阶段直接用SQLite零配置跑,部署到真实服务器再切换到MySQL或PostgreSQL。在settings.py里改配置很方便:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', } }4.2 页面模板与列表分页
页面这块不用做得太花哨,Bootstrap 5 CDN引入一下,界面就挺能打了。重要的是几个交互细节:
一是列表页必须有分页。请假数据一旦累计起来,几十条甚至上百条记录堆在一个页面上,加载慢不说,体验也差。Django有现成的Paginator:
from django.core.paginator import Paginator def my_leave_list(request): leave_list = LeaveRequest.objects.filter(student=request.user) paginator = Paginator(leave_list, 10) page_number = request.GET.get('page') page_obj = paginator.get_page(page_number) return render(request, 'leave_list.html', {'page_obj': page_obj})模板里这样写:
<ul class="pagination"> {% if page_obj.has_previous %} <li><a href="?page={{ page_obj.previous_page_number }}">上一页</a></li> {% endif %} <li class="active"><span>{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}</span></li> {% if page_obj.has_next %} <li><a href="?page={{ page_obj.next_page_number }}">下一页</a></li> {% endif %} </ul>二是列表页的状态列,我用不同颜色的badge展示,待审批用黄色、已批准用绿色、已驳回用红色,一眼就能看到哪些单子卡住了。这个纯CSS就能搞定,Bootstrap的badge bg-warning这类类名直接用。
4.3 管理员后台的深度配置
Django Admin是这个项目的一大助力,但默认配置不好用。至少要做这三件事:
第一,注册模型并设置列表显示字段:
from django.contrib import admin from .models import User, LeaveRequest, Class, Notification @admin.register(LeaveRequest) class LeaveRequestAdmin(admin.ModelAdmin): list_display = ['student', 'leave_type', 'start_time', 'end_time', 'status', 'created_at'] list_filter = ['status', 'leave_type'] search_fields = ['student__username', 'reason'] date_hierarchy = 'created_at' list_per_page = 20list_filter能让你在后台快速按状态筛单子,date_hierarchy提供按日期筛选的时间轴,这两个对管理员查数据帮助极大。
第二,学生的创建和导入。如果学生数量多,一个个在Admin后台添加很崩溃。我给系统写了一个基于Excel的批量导入功能,用的是openpyxl库,上传Excel后自动解析姓名、学号、班级创建用户。这个功能在很多答辩现场都是加分项,因为老师们普遍关心系统的实用性。
第三,给Admin增加导出CSV的接口,方便管理员把请假统计带走去院里汇报。Django提供actions机制,自定义一个Action导出选中的记录为CSV:
import csv from django.http import HttpResponse def export_csv(modeladmin, request, queryset): response = HttpResponse(content_type='text/csv; charset=utf-8') response['Content-Disposition'] = 'attachment; filename="leave_requests.csv"' writer = csv.writer(response) writer.writerow(['学生', '类型', '开始', '结束', '状态', '提交时间']) for obj in queryset: writer.writerow([obj.student.username, obj.get_leave_type_display(), obj.start_time, obj.end_time, obj.get_status_display(), obj.created_at]) return response export_csv.short_description = '导出选中的请假记录'在LeaveRequestAdmin里加一行actions = [export_csv]就完事。
5. 常见问题与排查技巧实录
5.1 迁移、时区与静态文件三座大山
这三个问题是Django新手最容易卡住的地方,我每个都吃过亏。
迁移问题:最典型的是“You are trying to add a non-nullable field without a default”这类报错。产生原因是你给已有表加了非空字段但没给默认值。解决办法是在模型里加default参数或者null=True。还有一个场景是用户模型扩展晚了,导致迁移关系混乱,我的建议是如果项目刚起步没多少数据,干脆删掉db.sqlite3和各app的migrations文件夹里除了__init__.py的文件,重新makemigrations和migrate,比自己慢慢梳理迁移依赖快得多。
时区问题:Django默认USE_TZ = True,存储的是UTC时间。如果你在表单里提交了2025-06-01 10:00,存进数据库后显示出来差了8小时。解决方法是settings.py里设置:
TIME_ZONE = 'Asia/Shanghai' USE_TZ = True注意USE_TZ = True时模板显示会自动转换到当前时区,但如果你在Python代码里直接打印model.start_time,看到的还是UTC时间。要转本地时间用timezone.localtime(model.start_time)。
静态文件问题:开发环境下用python manage.py runserver能正常显示CSS和图片,部署到服务器却全丢了。这是因为Django生产模式默认不自动提供静态文件服务。需要在settings.py里加:
import os STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')然后执行python manage.py collectstatic把各app的静态文件收集到STATIC_ROOT,再由Nginx直接提供静态文件访问。
5.2 查询性能与安全隐患
这个项目数据量不大,性能问题不突出,但有两个隐患必须处理。
第一个是N+1查询。比如在待审批列表里循环显示每个学生的班级名称,如果不加select_related,每个学生都会额外发一条SQL查询,10条记录就变成11条查询。解决办法是查询时关联:
pending_leaves = LeaveRequest.objects.select_related('student', 'student__student_class').filter(status=0)第二个是越权漏洞。学生直接访问/approve/3/这个URL就能审批请假单的话,系统就废了。user_passes_test装饰器能挡住一部分,但还不够,我建议在审批视图里再验证一次leave_request.student.student_class是否在request.user管辖范围内,做到双重保险。
另外提一句密码安全。开发调试时用简单密码没问题,部署上线前务必给所有账号设置强密码,并开启Django的AUTH_PASSWORD_VALIDATORS,就是startproject默认生成的那四组密码校验器,别删。
5.3 我踩过的几个坑和对应解法
整理一个速查表,都是我实际遇到过、调试过的:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 登录后跳转回登录页 | LOGIN_URL配置错误或装饰器login_url参数不对 | 确认settings.py里LOGIN_URL = '/login/',装饰器里login_url保持一致 |
| 表单提交500错误 | DateTimeField收到空字符串 | 把required=False或保证前端必填校验,后端再double check |
Admin后台显示User对象而不是用户名 | __str__方法没写好 | 各模型都实现__str__,用户模型别只返回username,加上角色信息更直观 |
makemigrations提示No changes detected | 新模型没在app内创建,或app没注册到INSTALLED_APPS | 检查settings.py里INSTALLED_APPS是否包含leave_app |
| 中文乱码 | 数据库字符集问题 | SQLite一般没事;MySQL建库时指定utf8mb4 |
| 撤销请假后仍显示待审批 | 状态变更没走封装的模型方法,直接save()绕过校验 | 强制所有状态变更走cancel()等模型方法 |
最后说一个我个人的使用习惯:把项目的业务规则尽量收敛到模型层,视图层只做调度。请假系统的状态流转、权限判定这类规则,如果写在视图里,每个新手进来改代码都可能绕过一层校验;但集中在模型方法里,新人也只能调用这些方法,规则就固化了。这套系统我后来扩展过几次,加了补签功能、销假提醒,都是在这个模型层基础上加方法,改起来很顺手。
如果你打算在这个项目上继续拓展,可以试试往这几个方向发力:用celery加定时任务,请假结束自动销假并提醒学生;用chart.js在首页渲染每月请假趋势折线图;或者接企业微信/钉钉机器人,审批结果直接推送到学生手机。整个项目基础打牢了,这些扩展都是水到渠成的事。