"""学生管理接口(需求 2.1、4.1)。""" from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Body, File, Query, Response, UploadFile from app.core.deps import DbSession, ReadAccount, WriteAccount from app.core.response import Resp, ok from app.dao.clazz_dao import ClazzDao from app.dao.student_dao import StudentDao from app.model import Education from app.model.constants import STUDENT_STATUS_TEXT from app.schema.common import PageResult from app.schema.student_schema import ( StudentCreate, StudentImportResult, StudentOut, StudentUpdate, ) from app.service.score_service import ScoreService from app.service.student_service import StudentService router = APIRouter(prefix="/students", tags=["2.1 学生基本信息管理"]) @router.get( "", response_model=Resp[PageResult[StudentOut]], summary="学生列表(支持编号/姓名/班级/状态/性别/年龄区间筛选)", ) def list_students( db: DbSession, _: ReadAccount, page: Annotated[int, Query(ge=1, description="页码")] = 1, page_size: Annotated[int, Query(ge=1, le=200, description="每页条数")] = 10, keyword: Annotated[str | None, Query(description="模糊搜索:姓名/学号/电话/专业/院校/籍贯")] = None, class_id: Annotated[int | None, Query(description="班级ID")] = None, class_no: Annotated[str | None, Query(description="班级编号")] = None, status: Annotated[int | None, Query(ge=1, le=3, description="状态 1=在读 2=进入就业 3=已就业")] = None, gender: Annotated[int | None, Query(ge=1, le=2, description="性别 1=男 2=女")] = None, advisor_id: Annotated[int | None, Query(description="顾问ID")] = None, education: Annotated[str | None, Query(description="学历")] = None, age_min: Annotated[int | None, Query(ge=1, le=100, description="年龄下限")] = None, age_max: Annotated[int | None, Query(ge=1, le=100, description="年龄上限")] = None, order_by: Annotated[str, Query(description="排序字段 id/stu_no/name/age/enroll_date/status")] = "id", order: Annotated[str, Query(pattern="^(asc|desc)$", description="排序方向")] = "desc", ): stmt = StudentDao.build_stmt( keyword=keyword, class_id=class_id, class_no=class_no, status=status, gender=gender, advisor_id=advisor_id, education=education, age_min=age_min, age_max=age_max, order_by=order_by, order=order, ) items, total, page, pages = StudentDao.paginate(db, stmt, page, page_size) return ok({ "total": total, "page": page, "page_size": page_size, "pages": pages, "items": [StudentOut.model_validate(s) for s in items], }) @router.post("", response_model=Resp[StudentOut], summary="创建学生(学号按规则自动生成)") def create_student(db: DbSession, account: WriteAccount, payload: StudentCreate): student = StudentService.create(db, payload) db.commit() db.refresh(student) return ok(StudentOut.model_validate(student), msg=f"学生 {student.name} 创建成功,学号 {student.stu_no}") @router.get("/meta/options", summary="下拉选项:班级 / 顾问 / 学历 / 状态") def student_options(db: DbSession, _: ReadAccount): from app.dao.advisor_dao import AdvisorDao classes = ClazzDao.all(db, ClazzDao.build_stmt(order_by="class_no", order="asc")) advisors = AdvisorDao.all(db, AdvisorDao.build_stmt(order_by="id", order="asc")) return ok({ "classes": [ {"id": c.id, "name": c.name, "class_no": c.class_no, "status_text": c.status_text} for c in classes ], "advisors": [{"id": a.id, "name": a.name} for a in advisors], "educations": [e.value for e in Education], "statuses": [{"value": k, "label": v} for k, v in sorted(STUDENT_STATUS_TEXT.items())], }) @router.get("/import/template", summary="下载 Excel 导入模板") def download_template(_: ReadAccount): content = StudentService.build_import_template() return Response( content=content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": 'attachment; filename="student_import_template.xlsx"'}, ) @router.post( "/import", response_model=Resp[StudentImportResult], summary="Excel 批量导入学生(逐行校验,失败行有行号和原因)", ) async def import_students( db: DbSession, _: WriteAccount, file: Annotated[UploadFile, File(description="按模板填写的 .xlsx")], dry_run: Annotated[bool, Query(description="只校验不落库")] = False, ): if not file.filename or not file.filename.lower().endswith((".xlsx", ".xlsm")): return Resp.fail("只支持 .xlsx 文件,请先用「下载模板」拿到标准格式") content = await file.read() result = StudentService.import_from_excel(db, content, dry_run=dry_run) return ok(result, msg=f"导入完成:成功 {result.success} 行,失败 {result.failed} 行") @router.get("/{stu_id}", response_model=Resp[StudentOut], summary="学生详情") def get_student(db: DbSession, _: ReadAccount, stu_id: int): student = StudentDao.get_or_404(db, stu_id, "学生") return ok(StudentOut.model_validate(student)) @router.get("/{stu_id}/scores", summary="学生成绩明细 + 汇总") def get_student_scores(db: DbSession, _: ReadAccount, stu_id: int): StudentDao.get_or_404(db, stu_id, "学生") return ok(ScoreService.student_summary(db, stu_id)) @router.put("/{stu_id}", response_model=Resp[StudentOut], summary="更新学生信息") def update_student(db: DbSession, _: WriteAccount, stu_id: int, payload: StudentUpdate): student = StudentDao.get_or_404(db, stu_id, "学生") StudentService.update(db, student, payload) db.commit() db.refresh(student) return ok(StudentOut.model_validate(student), msg="更新成功") @router.delete("/{stu_id}", summary="逻辑删除学生(成绩与就业记录一并逻辑删除)") def delete_student(db: DbSession, _: WriteAccount, stu_id: int): student = StudentDao.get_or_404(db, stu_id, "学生") StudentService.delete(db, student) db.commit() return ok(msg=f"已删除学生 {student.name}(可联系管理员恢复)") @router.post("/{stu_id}/restore", summary="恢复被逻辑删除的学生") def restore_student(db: DbSession, _: WriteAccount, stu_id: int): student = StudentDao.get(db, stu_id, with_deleted=True) if student is None: return Resp.fail("学生不存在", 404) if student.is_del == 0: return Resp.fail("该学生未被删除,无需恢复") student.is_del = 0 db.commit() return ok(msg=f"已恢复学生 {student.name}") @router.post("/batch/delete", summary="批量逻辑删除") def batch_delete(db: DbSession, _: WriteAccount, ids: Annotated[list[int], Body(embed=True, description="学生ID列表")]): deleted = 0 missing = [] for stu_id in ids: student = StudentDao.get(db, stu_id) if student is None: missing.append(stu_id) continue StudentService.delete(db, student) deleted += 1 db.commit() return ok({"deleted": deleted, "missing": missing}, msg=f"已删除 {deleted} 名学生")