diff --git a/.gitignore b/.gitignore index bde1ffa..b220eb5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,9 @@ env/ # 系统垃圾 .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db + + +.idea/ +.idea.backup/ +.run/ \ No newline at end of file diff --git a/api/consultant.py b/api/consultant.py index 2e9a897..59be5b5 100644 --- a/api/consultant.py +++ b/api/consultant.py @@ -9,8 +9,7 @@ router = APIRouter() @router.get( path="/consultants", - tags=['顾问管理模块'] -) + tags=['顾问管理模块'],summary='顾问模糊分页查询') def read_consultants( db: Session = Depends(get_db), page:int=1, @@ -22,7 +21,7 @@ def read_consultants( -@router.post("/consultants", response_model=ConsultantResponse,tags=['顾问管理模块']) +@router.post("/consultants", response_model=ConsultantResponse,tags=['顾问管理模块'],summary='顾问增加') def create_consultant( obj_in: ConsultantCreate, db: Session = Depends(get_db) @@ -30,7 +29,7 @@ def create_consultant( return dao_consultant.create_consultant(db, obj_in) -@router.get("/consultants/{id}", response_model=ConsultantResponse,tags=['顾问管理模块']) +@router.get("/consultants/{id}", response_model=ConsultantResponse,tags=['顾问管理模块'],summary='顾问查询') def read_consultant(id: int, db: Session = Depends(get_db)): res = dao_consultant.get_consultant_by_id(db, cid=id) if res is None: @@ -41,8 +40,7 @@ def read_consultant(id: int, db: Session = Depends(get_db)): @router.put( path="/consultants/{id}", response_model=ConsultantResponse, - tags=['顾问管理模块'] -) + tags=['顾问管理模块'],summary='顾问更新') def update_consultant( id: int, obj_in: ConsultantUpdate, @@ -57,7 +55,7 @@ def update_consultant( -@router.delete("/consultants/{id}", response_model=ConsultantResponse,tags=['顾问管理模块']) +@router.delete("/consultants/{id}", response_model=ConsultantResponse,tags=['顾问管理模块'],summary='顾问删除') def delete_consultant(id: int, db: Session = Depends(get_db)): res = dao_consultant.delete_consultant(db, cid=id) if res is None: diff --git a/api/employment.py b/api/employment.py index e69de29..9ea1a0c 100644 --- a/api/employment.py +++ b/api/employment.py @@ -0,0 +1,82 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from core.database import get_db +from dao.employment import ( + create_employment, + get_employment_by_id, + get_employment_by_student_id, + get_employment_list, + update_employment, + delete_employment_logic +) +from schemas.employment import ( + EmploymentCreate, + EmploymentUpdate, + EmploymentResponse +) + +employment_router = APIRouter(prefix="/employments", tags=["就业管理模块"]) + + +@employment_router.post("/", response_model=EmploymentResponse, summary="新增就业信息") +def add_employment(body: EmploymentCreate, db: Session = Depends(get_db)): + """ + 新增就业信息 + - 一个学生只能保存一条就业记录 + - student_id必须在学生表student_info_detail中真实存在 + """ + res = create_employment(db, body) + if res is None: + raise HTTPException(status_code=500, detail="新增失败,学生id不存在或该学生已有就业记录") + return res + + +@employment_router.get("/students/{student_id}", response_model=EmploymentResponse, summary="查询指定学生的就业信息") +def query_student_employment(student_id: int, db: Session = Depends(get_db)): + """根据学生id获取对应就业记录""" + record = get_employment_by_student_id(db, student_id) + if not record: + raise HTTPException(status_code=404, detail="未找到该学生的就业信息") + return record + + +@employment_router.get("/", summary="就业信息列表,支持筛选") +def query_employment_list( + company_name: str | None = Query(None, description="公司名称模糊查询"), + salary_min: float | None = Query(None, description="最低薪资"), + salary_max: float | None = Query(None, description="最高薪资"), + skip: int = Query(0, ge=0, description="偏移量"), + limit: int = Query(20, ge=1, le=100, description="每页条数"), + db: Session = Depends(get_db) +): + # 调用dao列表查询,拿到数据字典列表和总条数 + data_list, total = get_employment_list(db, company_name, salary_min, salary_max, skip, limit) + return {"total": total, "data": data_list} + + +@employment_router.get("/{eid}", response_model=EmploymentResponse, summary="根据id查询就业详情") +def query_employment_detail(eid: int, db: Session = Depends(get_db)): + """根据就业主键id查询单条就业记录""" + record = get_employment_by_id(db, eid) + if not record: + raise HTTPException(status_code=404, detail="就业记录不存在或已被逻辑删除") + return record + + +@employment_router.put("/{eid}", response_model=EmploymentResponse, summary="修改就业信息") +def edit_employment(eid: int, body: EmploymentUpdate, db: Session = Depends(get_db)): + """修改就业记录,只传想要修改的字段即可""" + res = update_employment(db, eid, body) + if res is None: + raise HTTPException(status_code=404, detail="修改失败,记录不存在") + return res + + +@employment_router.delete("/{eid}", summary="逻辑删除就业记录") +def logic_delete_employment(eid: int, db: Session = Depends(get_db)): + """执行逻辑删除,is_deleted置1,不会真正删除数据库行""" + ok = delete_employment_logic(db, eid) + if not ok: + raise HTTPException(status_code=404, detail="删除失败,记录不存在") + # 删除成功返回提示字典 + return {"code": 200, "msg": "逻辑删除成功"} diff --git a/api/score.py b/api/score.py index e69de29..a87e4c1 100644 --- a/api/score.py +++ b/api/score.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from core.database import get_db +from dao.score import post_score_dao, update_score_dao, get_score_dao, delete_score_dao, exist_student_exam_dao +from schemas.score import ScoreCreate, ScoreUpdate, ScorePageResp + +scores_router = APIRouter(prefix="/scores", tags=["成绩接口"]) + + +@scores_router.post("", summary="新增成绩接口") +def create_score(req: ScoreCreate, db: Session = Depends(get_db)): + # 业务校验:联合唯一 + if exist_student_exam_dao(req.student_id, req.exam_seq, db): + raise HTTPException(status_code=400, detail="该学生本次考试成绩已存在,不能重复录入") + + ok = post_score_dao(req.model_dump(), db) + if not ok: + raise HTTPException(status_code=400, detail="新增成绩失败,请检查学生id是否存在") + return {"msg": "新增成功"} + + +@scores_router.get("", response_model=ScorePageResp, summary="查询成绩接口") +def list_score( + score_id: int|None=None, + student_id: int |None=None, + exam_seq: int|None=None, + page: int = Query(1, ge=1), + page_size: int = Query(10, ge=1, le=100), + db: Session = Depends(get_db) +): + total, data_list = get_score_dao(score_id, student_id, exam_seq, page, page_size, db) + return {"total": total, "data": data_list} + + +@scores_router.put("{score_id}", summary="更新成绩接口") +def modify_score(score_id: int, req: ScoreUpdate, db: Session = Depends(get_db)): + update_dict = req.model_dump(exclude_unset=True) + if not update_dict: + raise HTTPException(status_code=400, detail="没有传入任何修改字段") + ok = update_score_dao(score_id, db, update_dict) + if not ok: + raise HTTPException(status_code=400, detail="修改失败,记录不存在或已删除") + return {"msg": "修改成功"} + + +@scores_router.delete("{score_id}", summary="删除成绩接口") +def remove_score(score_id: int, db: Session = Depends(get_db)): + ok = delete_score_dao(score_id, db) + if not ok: + raise HTTPException(status_code=400, detail="删除失败,记录不存在或已删除") + return {"msg": "逻辑删除成功"} \ No newline at end of file diff --git a/api/statistics.py b/api/statistics.py index 082f7f4..d139b83 100644 --- a/api/statistics.py +++ b/api/statistics.py @@ -1,16 +1,17 @@ from fastapi import APIRouter,HTTPException,Depends -from DoubaoTeam.core.database import get_db -from DoubaoTeam.models.student import Student -from DoubaoTeam.models.score import Score -from DoubaoTeam.dao.statistics import * -from DoubaoTeam.schemas.common import SuccessResponse +from core.database import get_db +from models.employment import Employment +from models.student import Student +from models.score import Score +from dao.statistics import * +from schemas.common import SuccessResponse from sqlalchemy.orm import Session -statistics_router=APIRouter() +statistics_router=APIRouter(tags=['statistics']) -@statistics_router.get("/scores/age-over-30",response_model=SuccessResponse) +@statistics_router.get("/students/age-over-30",response_model=SuccessResponse) def api_age_over_thirty(db:Session=Depends(get_db)): try: age_over_thirty_list=age_over_thirty(Student,db) @@ -26,3 +27,57 @@ def api_gender_count(db:Session=Depends(get_db)): except NoExist as e: raise HTTPException(status_code=404,detail=str(e)) +@statistics_router.get("/scores/all-above-80",response_model=SuccessResponse) +def all_above_eighty(db:Session=Depends(get_db)): + try: + result = score_over_eighty(Score,Student,db) + return SuccessResponse(status_code=200,msg=result) + except NoExist as e: + raise HTTPException(status_code=404,detail=str(e)) + +@statistics_router.get("/scores/fail-more-than-two",response_model=SuccessResponse) +def fail_two(db:Session=Depends(get_db)): + try: + result = twice_error(Score,Student,db) + return SuccessResponse(status_code=200,msg=result) + except NoExist as e: + raise HTTPException(status_code=404,detail=str(e)) + +@statistics_router.get("/scores/class-average",response_model=SuccessResponse) +def class_average(db:Session=Depends(get_db)): + try: + result = avg_class(Score,Student,db) + return SuccessResponse(status_code=200,msg=result) + except NoExist as e: + raise HTTPException(status_code=404,detail=str(e)) + + +@statistics_router.get("/employments/top5-salary",response_model=SuccessResponse) +def top_salary(db:Session=Depends(get_db)): + try: + result=max_salary(Student,Employment,db) + return SuccessResponse(status_code=200,msg=result) + except NoExist as e: + raise HTTPException(status_code=404,detail=str(e)) + +@statistics_router.get("/employments/student-duration",response_model=SuccessResponse) +def employ_time(db:Session=Depends(get_db)): + try: + result = student_market(Employment,db) + return SuccessResponse(status_code=200,msg=result) + except NoExist as e: + raise HTTPException(status_code=404,detail=str(e)) + +@statistics_router.get("/employments/class-average-duration",response_model=SuccessResponse) +def avg_employ_time(db:Session=Depends(get_db)): + try: + result = avg_emptime(Employment,Student,db) + return SuccessResponse(status_code=200,msg=result) + except NoExist as e: + raise HTTPException(status_code=404,detail=str(e)) + + + + + + diff --git a/api/student.py b/api/student.py index 9299659..996c47a 100644 --- a/api/student.py +++ b/api/student.py @@ -1,14 +1,96 @@ -from fastapi import APIRouter,Depends,HTTPException +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session -from dao.student import * -from schemas.student import StudentCreate +from core.database import get_db +from schemas.student import StudentCreate, StudentUpdate +from dao import student as student_dao student_router = APIRouter() -@student_router.post("/student") -def create_student(request:StudentCreate,db:Session,create = Depends(create_students)): +@student_router.post("",summary='添加学生') +def create_student(request:StudentCreate,db:Session = Depends(get_db)): d = request.model_dump() - r = create_students(d,db) + r = student_dao.create_students(d,db) if not r: raise HTTPException(status_code=500,detail='服务器繁忙,请稍后添加!') - return {'code':200,'detail':"添加学生成功"} \ No newline at end of file + return {'code':200,'detail':"添加学生成功"} + +@student_router.put("/{id}", summary="修改学生") +def update_student( + id: int, + request: StudentUpdate, + db: Session = Depends(get_db), +): + # 只取前端真正传来的字段 + # 例如只传 age,就只修改 age + data = request.model_dump(exclude_unset=True) + + student = student_dao.update_student(id, data, db) + + # None 表示学生不存在 + if student is None: + raise HTTPException(status_code=404, detail="学生不存在") + + # False 表示操作数据库失败 + if student is False: + raise HTTPException(status_code=500, detail="修改学生失败") + + return { + "code": 200, + "detail": "修改学生成功", + "data": student, + } + +@student_router.get("", summary="查询学生列表") +def get_students( + # 三个查询条件都可以不传 + student_no: str | None = None, + student_name: str | None = None, + class_id: int | None = None, + # 获取本次请求使用的数据库连接 + db: Session = Depends(get_db), +): + # 调用 DAO 查询数据库 + students = student_dao.get_students( + db=db, + student_no=student_no, + student_name=student_name, + class_id=class_id, + ) + + return { + "code": 200, + "detail": "查询成功", + "total": len(students), + "data": students, + } + +@student_router.get("/{id}", summary="查询学生详情") +def get_student(id: int, db: Session = Depends(get_db)): + # 根据路径中的 ID 查询学生 + student = student_dao.get_student_by_id(id, db) + + if not student: + # 404 表示请求的数据不存在 + raise HTTPException(status_code=404, detail="学生不存在") + + return { + "code": 200, + "detail": "查询成功", + "data": student, + } + +@student_router.delete("/{id}", summary="逻辑删除学生") +def delete_student(id: int, db: Session = Depends(get_db)): + student = student_dao.delete_student(id, db) + + # None 表示不存在,或者以前已经删除 + if student is None: + raise HTTPException(status_code=404, detail="学生不存在或已经删除") + + if student is False: + raise HTTPException(status_code=500, detail="删除学生失败") + + return { + "code": 200, + "detail": "删除学生成功", + } diff --git a/api/teacher.py b/api/teacher.py index e69de29..c300850 100644 --- a/api/teacher.py +++ b/api/teacher.py @@ -0,0 +1,129 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from core import database +from core.database import get_db +from schemas.teacher import TeacherOut, TeacherCreate, TeacherUpdate, ClassTeachersCreate, ClassTeacherOut +# 导入dao层所有函数 +from models.teacher import Teacher +from models.class_teacher import ClassTeachers +from dao.teacher import ( + get_teacher_list, + get_teacher_by_id, + create_teacher, + update_teacher, + delete_teacher, + get_class_teacher_by_class_id, + create_class_teacher, + update_class_teacher_full, + delete_class_teacher +) + +router = APIRouter(prefix="/teacher", tags=["老师管理模块"]) + +#============================老师表=============================== +@router.get("/list", response_model=List[TeacherOut]) +def api_get_teacher_list(db: Session = Depends(get_db)): + """ + GET 查询全部有效老师列表 + :param db: 数据库会话,Depends自动注入 + :return: List[TeacherOut] 老师列表,使用TeacherOut做响应格式化 + """ + return get_teacher_list(db) + +@router.get("/{teacher_id}", response_model=TeacherOut) +def api_get_teacher_by_id(teacher_id: int,db: Session = Depends(get_db)): + """ + 根据老师主键ID查询单个有效老师 + :param teacher_id: int 老师表主键ID + :param db: Session 数据库会话对象,依赖注入自动获取 + :return: TeacherOut 老师详情信息 + """ + return get_teacher_by_id(db,teacher_id) +@router.post("", response_model=TeacherOut, summary="新增老师") +def api_create_teacher(obj: TeacherCreate, db: Session = Depends(get_db)): + """ + 新增老师记录,自动校验姓名、编号唯一性,并自动生成老师业务编号 + :param obj: TeacherCreate Pydantic模型,前端提交新增老师表单数据 + :param db: Session 数据库会话对象,依赖注入自动获取 + :return: TeacherOut 新增成功后的老师完整信息 + """ + return create_teacher(db, obj) +@router.put("/{teacher_id}", response_model=TeacherOut, summary="修改老师信息(局部更新)") +def api_update_teacher(teacher_id: int, obj: TeacherUpdate, db: Session = Depends(get_db)): + """ + 修改老师信息,支持局部更新,仅更新前端传入的字段 + :param teacher_id: int 需要修改的老师主键ID + :param obj: TeacherUpdate Pydantic模型,前端提交待修改字段 + :param db: Session 数据库会话对象,依赖注入自动获取 + :return: TeacherOut 更新后的老师完整信息 + """ + db_teacher = get_teacher_by_id(db, teacher_id) + if not db_teacher: + raise HTTPException(status_code=404, detail="老师不存在或已删除!") + result = update_teacher(db, teacher_id, obj) + return result +@router.delete("/{teacher_id}", summary="逻辑删除老师") +def api_delete_teacher(teacher_id: int, db: Session = Depends(get_db)): + """ + 逻辑删除老师,存在有效班级带班关系时禁止删除 + :param teacher_id: int 需要删除的老师主键ID + :param db: Session 数据库会话对象,依赖注入自动获取 + :return: dict 操作成功提示 + """ + result = delete_teacher(db, teacher_id) + if not result: + raise HTTPException(status_code=404, detail="老师不存在或已删除!") + return {"msg": "老师逻辑删除成功"} + +# ===================== 班级老师关联路由组 ===================== +class_teacher_router = APIRouter(prefix="/class-teacher", tags=["班级老师分配管理模块"]) + +@class_teacher_router.get("/class/{class_id}", response_model=List[ClassTeacherOut], summary="根据班级ID查询该班级所有带班老师") +def api_get_class_teacher_by_class_id(class_id: int, db: Session = Depends(get_db)): + """ + 根据班级主键ID,查询该班级全部有效的班级-老师绑定记录 + :param class_id: int 班级表主键ID + :param db: Session 数据库会话对象,依赖注入自动获取 + :return: List[ClassTeachersOut] 班级老师关联记录列表 + """ + return get_class_teacher_by_class_id(db, class_id) + +@class_teacher_router.post("", response_model=ClassTeacherOut, summary="分配老师到班级,新增班级老师关联记录") +def api_create_class_teacher(obj: ClassTeachersCreate, db: Session = Depends(get_db)): + """ + 新增班级与老师的绑定关系,做重复性校验与老师有效性校验 + :param obj: ClassTeachersCreate Pydantic模型,前端提交class_id、teacher_id、role + :param db: Session 数据库会话对象,依赖注入自动获取 + :return: ClassTeachersOut 新增成功后的班级老师关联记录 + """ + return create_class_teacher(db, obj) + +@class_teacher_router.put("/{rel_id}", response_model=ClassTeacherOut, summary="更新班级老师绑定关系") +def api_update_class_teacher_full(rel_id: int, obj: ClassTeachersCreate, db: Session = Depends(get_db)): + """ + 修改班级老师中间表记录,支持修改class_id、teacher_id、role;自动校验老师有效性、三元组合唯一性 + :param rel_id: int class_teachers中间表主键ID(待修改的关联记录id) + :param obj: ClassTeachersCreate Pydantic模型,前端提交新的class_id、teacher_id、role + :param db: Session 数据库会话对象,依赖注入自动获取 + :return: ClassTeachersOut 更新后的班级老师关联记录 + """ + result = update_class_teacher_full(db, rel_id, obj) + if not result: + raise HTTPException(status_code=404, detail="班级老师关联记录不存在!") + return result + +@class_teacher_router.delete("/{rel_id}", summary="逻辑删除班级老师带班关联") +def api_delete_class_teacher(rel_id: int, db: Session = Depends(get_db)): + """ + 逻辑删除班级老师绑定关系,修改is_deleted标记为1,非物理删除 + :param rel_id: int class_teachers中间表主键ID + :param db: Session 数据库会话对象,依赖注入自动获取 + :return: dict 操作成功提示 + """ + result = delete_class_teacher(db, rel_id) + if not result: + raise HTTPException(status_code=404, detail="班级老师关联记录不存在!") + return {"msg": "解除班级带班关系成功"} + + diff --git a/dao/employment.py b/dao/employment.py index e69de29..d760185 100644 --- a/dao/employment.py +++ b/dao/employment.py @@ -0,0 +1,115 @@ +from sqlalchemy.orm import Session +from models.employment import Employment +from schemas.employment import EmploymentCreate, EmploymentUpdate, EmploymentResponse + + +def create_employment(db, obj_in): + """新增就业信息:一个学生只能存在一条就业记录""" + try: + # obj_in.model_dump()把pydantic请求对象转为字典,解包传给Employment生成ORM对象 + db_obj = Employment(**obj_in.model_dump()) + # add:把对象加入数据库会话,此时还没写入数据库 + db.add(db_obj) + # commit:提交事务,真正写入mysql数据库 + db.commit() + # refresh:从数据库刷新对象,拿到数据库自动生成的id、默认时间等字段 + db.refresh(db_obj) + # model_validate:把sqlalchemy ORM对象解析进pydantic响应模型 + # model_dump:把pydantic模型转为普通python字典返回给上层api + return EmploymentResponse.model_validate(db_obj).model_dump() + except Exception: + # 发生任何异常,执行回滚,撤销本次会话中未提交的数据库改动 + db.rollback() + # 异常情况返回None,上层api可以判断新增失败 + return None + + +def get_employment_by_id(db, eid): + """根据主键id查询就业详情,过滤逻辑删除""" + try: + db_obj = db.query(Employment).filter( + Employment.id == eid, + Employment.is_deleted == 0 + ).first() + if not db_obj: + return None + return EmploymentResponse.model_validate(db_obj).model_dump() + except Exception: + return None + + +def get_employment_by_student_id(db, student_id): + """根据学生id查询就业信息,过滤逻辑删除""" + try: + db_obj = db.query(Employment).filter( + Employment.student_id == student_id, + Employment.is_deleted == 0 + ).first() + if not db_obj: + return None + return EmploymentResponse.model_validate(db_obj).model_dump() + except Exception: + return None + + +def get_employment_list(db, company_name=None, salary_min=None, salary_max=None, skip=0, limit=20): + """就业列表查询;支持公司名模糊、薪资范围筛选;只查询is_deleted=0""" + try: + query = db.query(Employment).filter(Employment.is_deleted == 0) + if company_name: + query = query.filter(Employment.company_name.like(f"%{company_name}%")) + if salary_min is not None: + query = query.filter(Employment.salary >= salary_min) + if salary_max is not None: + query = query.filter(Employment.salary <= salary_max) + # count()统计满足条件总条数,用于分页total + total = query.count() + # offset偏移量(跳过多少条),limit每页多少条,执行查询拿到结果列表 + records = query.offset(skip).limit(limit).all() + # 列表推导式:循环每一条ORM记录,全部转为字典,得到字典列表 + dict_list = [EmploymentResponse.model_validate(item).model_dump() for item in records] + # 返回字典列表 + 总条数 + return dict_list, total + except Exception: + # 异常返回空列表,总条数0 + return [], 0 + + +def update_employment(db, eid, obj_in): + """修改就业信息,只更新传入的字段""" + try: + # 根据id查询未删除的记录 + db_obj_raw = db.query(Employment).filter( + Employment.id == eid, + Employment.is_deleted == 0 + ).first() + # 记录不存在直接返回None + if db_obj_raw is None: + return None + # exclude_unset=True:只取出前端实际传过来的字段,不会带上没传的None字段 + update_dict = obj_in.model_dump(exclude_unset=True) + for key, value in update_dict.items(): + setattr(db_obj_raw, key, value) + db.commit() + db.refresh(db_obj_raw) + return EmploymentResponse.model_validate(db_obj_raw).model_dump() + except Exception: + db.rollback() + return None + + +def delete_employment_logic(db, eid): + """逻辑删除,只修改is_deleted=1,不做物理删除""" + try: + db_obj_raw = db.query(Employment).filter( + Employment.id == eid, + Employment.is_deleted == 0 + ).first() + if db_obj_raw is None: + return False + db_obj_raw.is_deleted = 1 + db.commit() + return True + except Exception: + db.rollback() + return False diff --git a/dao/score.py b/dao/score.py index e69de29..6a427d1 100644 --- a/dao/score.py +++ b/dao/score.py @@ -0,0 +1,86 @@ +from sqlalchemy import DECIMAL +from models.score import Score + +def post_score_dao(data_dict, db): + """新增成绩""" + try: + obj = Score(**data_dict) + db.add(obj) + except Exception as e: + print("新增成绩异常:", e) + db.rollback() + return False + else: + db.commit() + return True + + +def update_score_dao(score_id, db, updates): + """修改成绩,query.update方式""" + try: + db.query(Score).filter( + Score.id == score_id, + Score.is_deleted == 0 + ).update(updates) + except Exception: + db.rollback() + return False + else: + db.commit() + return True + + +def get_score_dao(score_id, student_id, exam_seq, page, page_size, db): + """分页查询成绩,多条件过滤,只查未删除""" + q = db.query(Score).filter(Score.is_deleted == 0) + if score_id: + q = q.filter(Score.id == score_id) + if student_id: + q = q.filter(Score.student_id == student_id) + if exam_seq: + q = q.filter(Score.exam_seq == exam_seq) + + total = q.count() + rows = q.offset((page - 1) * page_size).limit(page_size).all() + + res_list = [ + { + "id": i.id, + "student_id": i.student_id, + "score": i.score, + "exam_seq": i.exam_seq, + "exam_date": i.exam_date, + "remark": i.remark, + "created_at": i.created_at, + "updated_at": i.updated_at, + "is_deleted": i.is_deleted + } + for i in rows + ] + return total, res_list + + +def delete_score_dao(score_id, db): + """逻辑删除,更新is_deleted=1""" + try: + db.query(Score).filter( + Score.id == score_id, + Score.is_deleted == 0 + ).update({"is_deleted": 1}) + except Exception as err: + print("删除成绩异常:", err) + db.rollback() + return False + else: + db.commit() + return True + + +def exist_student_exam_dao(student_id, exam_seq, db): + """校验:同一个学生+考试序次是否已经存在,用于联合唯一业务判断""" + rec = db.query(Score).filter( + Score.student_id == student_id, + Score.exam_seq == exam_seq, + Score.is_deleted == 0 + ).first() + return rec is not None \ No newline at end of file diff --git a/dao/statistics.py b/dao/statistics.py index 6b7c9e8..ddaaa2e 100644 --- a/dao/statistics.py +++ b/dao/statistics.py @@ -1,30 +1,139 @@ -from DoubaoTeam.schemas.statistics import NoExist +from schemas.statistics import NoExist from sqlalchemy import func - +from time import time #`GET /statistics/students/age-over-30` #查询所有超过 30 岁的学员信息 def age_over_thirty(Student,db): - student_list=db.query(Student).filter(Student.age>=30).all() + student_list=db.query(Student).filter(Student.age>30,Student.is_deleted==0).all() if not student_list : raise NoExist('无目标年龄大于30岁') - return student_list + # 把每个学生对象需要返回的字段,明确放进普通字典。 + result = [] + for student in student_list: + result.append({ + "id": student.id, + "student_no": student.student_no, + "student_name": student.student_name, + "class_id": student.class_id, + "consultant_id": student.consultant_id, + "native_place": student.native_place, + "graduation_school": student.graduation_school, + "major": student.major, + "enrollment_date": student.enrollment_date, + "graduation_date": student.graduation_date, + "education": student.education, + "age": student.age, + "gender": student.gender, + "create_at": student.create_at, + "update_at": student.update_at, + "is_deleted": student.is_deleted, + }) + return result def gender_count(Student,db): - result=db.query(Student.class_id,Student.gender,func.count(Student.id)).group_by(Student.class_id,Student.gender).all() + result=db.query(Student.class_id,Student.gender,func.count(Student.id)).filter(Student.is_deleted==0).group_by(Student.class_id,Student.gender).all() if not result : raise NoExist('班里招点人吧,要倒闭了') dict1 = {} for i,j,k in result: if i not in dict1 : - dict1[i]={'男':0,'女':0} - dict[i][j]+=k + dict1[i]={'男':0,'女':0,'总人数':0} + dict1[i][j]+=k + dict1[i]['总人数']+=k return dict1 #查询每次考试成绩都在 80 分以上的学生编号、姓名和成绩 def score_over_eighty(Score,Student,db): student_list=db.query(Student.id, Student.student_name)\ - .join(Student,Score.student_id==Student.id).group_by(Student.student_no,Student.student_name)\ + .join(Score,Score.student_id==Student.id).filter(Student.is_deleted==0,Score.is_deleted==0).group_by(Student.id,Student.student_name)\ .having(func.min(Score.score)>80).all() + if not student_list : + raise NoExist('全是学渣') + list1 = [] for i in student_list: - student_list2 = db.query(Student.student_no, Student.student_name,Score.score).join(Student,Score.student_id==Student.id).filter(i.id==Student.id).all() + student_list2 = db.query(Student.student_no, Student.student_name,Score.score).join(Score,Score.student_id==Student.id).filter(i.id==Student.id,Score.is_deleted==0).all() + if student_list2 : + for student_no, student_name, score in student_list2: + list1.append({ + "student_no": student_no, + "student_name": student_name, + "score": float(score) if score is not None else None, + }) + return list1 +#查询有两次以上不及格的学生姓名、班级和不及格成绩 +def twice_error(Score,Student,db): + student_list=db.query(Score.student_id).filter(Score.score<60,Score.is_deleted==0).group_by(Score.student_id).having(func.count(Score.id)>2).all() + if not student_list : + raise NoExist('全是学霸') + list1=[] + for i in student_list: + result=db.query(Student.student_name,Student.class_id,Score.score).join(Score, Student.id==Score.student_id)\ + .filter(i.student_id==Score.student_id,Score.score<60,Student.is_deleted==0,Score.is_deleted==0).all() + for student_name, class_id, score in result: + list1.append({ + "student_name": student_name, + "class_id": class_id, + "score": float(score) if score is not None else None, + }) + return list1 + +#统计每次考试每个班级的平均分,并按照平均分从高到低排序 +def avg_class(Score,Student,db): + student_list = db.query(Student.class_id,Score.exam_seq,func.avg(Score.score)).join(Score, Student.id == Score.student_id).filter(Score.is_deleted==0,Student.is_deleted==0).group_by(Student.class_id,Score.exam_seq).order_by(func.avg(Score.score).desc()).all() + if not student_list : + raise NoExist('招个人吧') + result = [] + for class_id, exam_seq, average_score in student_list: + result.append({ + "class_id": class_id, + "exam_seq": exam_seq, + "average_score": float(average_score) if average_score is not None else None, + }) + return result + +#统计就业薪资最高的前五名学生姓名、班级、就业时间、就业公司 +def max_salary(Student,Employment,db): + student_list = db.query(Student.student_name,Student.class_id,Employment.offer_date,Employment.company_name).join(Employment, Student.id == Employment.student_id).filter(Student.is_deleted==0,Employment.is_deleted==0).order_by(Employment.salary.desc()).limit(5).all() + if not student_list : + raise NoExist('没就业的啊兄弟?') + result = [] + for student_name, class_id, offer_date, company_name in student_list: + result.append({ + "student_name": student_name, + "class_id": class_id, + "offer_date": offer_date, + "company_name": company_name, + }) + return result + +# - 统计每个学生的就业时长 +# - 就业时长 = `offer_date - employment_open_date` +def student_market(Employment,db): + student_list = db.query(Employment.student_id,func.datediff(Employment.offer_date,Employment.employment_open_date)).filter(Employment.is_deleted==0).all() + result = [] + for student_id, duration_days in student_list: + result.append({ + "student_id": student_id, + "duration_days": duration_days, + }) + return result + +# - 统计每个班级的平均就业时长 +# - 只统计有就业开放时间的学生 +def avg_emptime(Employment,Student,db): + student_list = (db.query(Student.class_id,\ + func.avg(func.datediff(Employment.offer_date,Employment.employment_open_date)))\ + .join(Employment, Student.id==Employment.student_id).filter(Student.is_deleted==0,Employment.is_deleted==0,Employment.employment_open_date.is_not(None)))\ + .group_by(Student.class_id).all() + if not student_list : + raise NoExist('一个就业都没有吗') + result = [] + for class_id, average_days in student_list: + result.append({ + "class_id": class_id, + "average_days": float(average_days) if average_days is not None else None, + }) + return result + + diff --git a/dao/student.py b/dao/student.py index 9014fe5..e11ce68 100644 --- a/dao/student.py +++ b/dao/student.py @@ -1,16 +1,103 @@ from schemas.student import * from core.database import get_db from models.student import * -from utils.code_generator import generate_no +from sqlalchemy.orm import Session -def create_students(d,db): +def create_students(d: dict, db: Session): stu = Student(**d) + try: db.add(stu) - generate_no("student_no", "STU", stu, db) + # 把 SQL 发给数据库执行 + db.flush() + # 根据 id 生成学生编号 + if not stu.student_no: + stu.student_no = f"STU{stu.id:04d}" db.commit() - except:return False - else: - return True + db.refresh(stu) + return stu + except Exception as e: + db.rollback() + print(f"插入学生失败: {e}") + return None + +def get_student_by_id(student_id: int, db: Session): + # 同时判断 is_deleted,已经逻辑删除的学生不会被查到 + return db.query(Student).filter( + Student.id == student_id, + Student.is_deleted == 0, + ).first() + +def update_student(student_id: int, data: dict, db: Session): + # 第一步:先查询学生 + student = get_student_by_id(student_id, db) + + # 没有查到就返回 None + if not student: + return None + + try: + # 第二步:循环修改前端传来的字段 + # field 是字段名,value 是新的值 + for field, value in data.items(): + setattr(student, field, value) + + # 第三步:提交修改并重新读取数据 + db.commit() + db.refresh(student) + return student + + except Exception as e: + # 修改失败时回滚 + db.rollback() + print("修改学生失败:", e) + return False + +def get_students( + db: Session, + student_no: str | None = None, + student_name: str | None = None, + class_id: int | None = None, +): + # 第一步:只查询没有被删除的学生 + query = db.query(Student).filter(Student.is_deleted == 0) + + # 第二步:前端传了哪个条件,就增加哪个条件 + if student_no: + # 学号使用精确查询 + query = query.filter(Student.student_no == student_no) + + if student_name: + # 姓名使用模糊查询,例如“张”可以查到“张三” + query = query.filter(Student.student_name.like(f"%{student_name}%")) + + if class_id: + # 班级 ID 使用精确查询 + query = query.filter(Student.class_id == class_id) + + # 第三步:执行查询并返回全部结果 + return query.all() + +#逻辑删除学生 +def delete_student(student_id: int, db: Session): + # 第一步:查询学生 + student = get_student_by_id(student_id, db) + + if not student: + return None + + try: + # 第二步:不真正删除记录,只把删除标记改成 1 + student.is_deleted = 1 + + # 第三步:提交修改 + db.commit() + return student + + except Exception as e: + # 删除失败时回滚 + db.rollback() + print("删除学生失败:", e) + return False diff --git a/dao/teacher.py b/dao/teacher.py index e69de29..bef465c 100644 --- a/dao/teacher.py +++ b/dao/teacher.py @@ -0,0 +1,239 @@ +from sqlalchemy.orm import Session +from core import database +from models.teacher import Teacher +from models.class_teacher import ClassTeachers +from schemas.teacher import TeacherUpdate, TeacherCreate,ClassTeachersCreate +from utils.code_generator import generate_no +from fastapi import HTTPException + +''' +本文件内容为老师表与班级老师关系表的增删改查函数集合 +''' +#=================老师表Teacher的CRUD================ +#查询所有未被删除的老师 +def get_teacher_list(db:Session, teacher_id:int): + teacher = get_teacher_by_id(db, teacher_id) + if not teacher: + raise HTTPException(status_code=404, detail="老师不存在或已删除!") + #查询老师列表,过滤逻辑删除 + return db.query(Teacher).filter(Teacher.is_deleted == 0).all() +#根据id,查询单个符合条件的老师 +def get_teacher_by_id(db:Session,teacher_id:int): + return db.query(Teacher).filter(Teacher.id == teacher_id + ,Teacher.is_deleted == 0).first() +#新增 +def create_teacher(db:Session,obj:TeacherCreate): + ''' + 新增 + :param db: Session 数据库会话 + :param obj:TeacherCreate 前端传入新增数据 + :return: db_teacher 新增成功的ORM对象 + 业务逻辑: + 1、查重:同名有效老师不能重复新增 + 2、如果前端手动添加teacher_no,校验编号不能和已有有效老师编号重复 + 3、创建ORM对象加入会话 + 4、调用generate_no,走动生成业务编号 + 5、commit提交事务入库,refresh刷新对象 + ''' + exist_teacher = db.query(Teacher).filter(Teacher.teacher_name == obj.teacher_name + ,Teacher.is_deleted == 0).first() + if exist_teacher: + raise HTTPException(status_code=409,detail='该用户已存在') + if obj.teacher_no: + exist_teacher = db.query(Teacher).filter(Teacher.teacher_no == obj + .teacher_no,Teacher.is_deleted == 0).first() + if exist_teacher: + raise HTTPException(status_code=409,detail='老师编号已存在') + #开始添加 + data = obj.model_dump(exclude_none=True) + db_teacher = Teacher(**data) + db.add(db_teacher) + # 调用公共函数生成业务编号,内部执行db.flush拿到自增id + generate_no('teacher_no','TCH',db_teacher,db) + db.commit() + db.refresh(db_teacher) + #从数据库重新查询这条记录,刷新 db_teacher 对象。 + #作用:同步数据库默认字段的值,例如`created_at`、`updated_at`,拿到数据库里最新的数据, + # 最终返回给前端。 + return db_teacher +#更新 +def update_teacher(db:Session,teacher_id:int,obj:TeacherUpdate): + """ + 修改老师信息,局部更新 + :param db: Session 数据库会话 + :param teacher_id: int 需要修改的老师id + :param obj: TeacherUpdate 前端传入要修改的字段 + :return: db_teacher ORM对象 / None + """ + # 调用get_teacher_by_id校验用户是否存在 + db_teacher = get_teacher_by_id(db,teacher_id) + if obj.teacher_name: + exist_teacher = db.query(Teacher).filter( + Teacher.teacher_name == obj.teacher_name, + Teacher.id != teacher_id, + Teacher.is_deleted == 0 + ).first() + if exist_teacher: + raise HTTPException(status_code=409, detail="该姓名已被其他老师占用!") + if obj.teacher_no: + exist_no = db.query(Teacher).filter( + Teacher.teacher_no == obj.teacher_no, + Teacher.id != teacher_id, + Teacher.is_deleted == 0 + ).first() + if exist_no: + raise HTTPException(status_code=409, detail="老师编号已被其他老师占用!") + #用户存在,解析键值对,循环使用setattr添加 + update_data = obj.model_dump(exclude_none=True) + for k, v in update_data.items(): + setattr(db_teacher, k, v) + db.commit() + db.refresh(db_teacher) + return db_teacher +#删除 +def delete_teacher(db:Session,teacher_id:int): + """ + 逻辑删除老师,不是物理删除;修改is_deleted=1 + :param db: Session + :param teacher_id:老师主键id + :return: Teacher ORM对象,找不到返回None + 业务限制:老师已经分配班级,则不允许删除,防止脏数据 + """ + # 查询目标老师 + db_teacher = get_teacher_by_id(db, teacher_id) + if not db_teacher: + return None + + # 校验:查询班级老师中间表,判断该老师是否还有有效带班关系 + rel = db.query(ClassTeachers).filter( + ClassTeachers.teacher_id == teacher_id, + ClassTeachers.is_deleted == 0 + ).first() + if rel: + raise HTTPException( + status_code=400, + detail="该老师已分配班级,不能删除,请先解除带班关系!" + ) + + # 逻辑删除标记 + db_teacher.is_deleted = 1 + db.commit() + return db_teacher + +#==================班级老师中间表 CRUD===================== +#查询 +def get_class_teacher_by_class_id(db: Session, class_id: int): + """ + 根据班级id,查询该班级所有有效的带班老师关系 + :param db: Session + :param class_id:班级主键 + :return: list[ClassTeacher] + """ + return ( + db.query(ClassTeachers) + .filter(ClassTeachers.class_id == class_id + ,ClassTeachers.is_deleted == 0) + .all() + ) + +#新增 +def create_class_teacher(db: Session, obj: ClassTeachersCreate): + """ + 分配老师到班级,新增班级-老师关系记录 + :param db: Session + :param obj: ClassTeacherCreate class_id、teacher_id、role + :return: ClassTeacher ORM对象 + 校验: + 1.老师必须存在且未删除 + 2.同一班级同一个老师同一个角色不能重复分配 + """ + # 校验老师是否存在 + teacher = get_teacher_by_id(db, obj.teacher_id) + if not teacher: + raise HTTPException(status_code=404, detail="老师不存在或已删除!") + + # 查重:同一班级+同一老师+同一角色不能重复分配 + rel = db.query(ClassTeachers).filter( + ClassTeachers.class_id == obj.class_id, + ClassTeachers.teacher_id == obj.teacher_id, + ClassTeachers.role == obj.role, + ClassTeachers.is_deleted == 0 + ).first() + if rel: + raise HTTPException(status_code=409, detail="该老师已以相同角色分配到该班级!") + + data = obj.model_dump() + db_obj = ClassTeachers(**data) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + return db_obj + +#更新 +def update_class_teacher_full(db: Session, rel_id: int, obj: ClassTeachersCreate): + """ + 完整更新班级老师关系:可修改 class_id、teacher_id、role + :param db: Session + :param rel_id: 中间表主键 + :param obj: ClassTeacherCreate 传入的新参数 + :return: ClassTeacher ORM对象 + 校验: + 1. 关系记录存在且未删除 + 2. 如果修改teacher_id,校验老师必须有效存在 + 3. 校验新组合(class_id+teacher_id+role)不能和其他有效记录重复 + """ + # 1. 查询原有记录 + db_rel = db.query(ClassTeachers).filter( + ClassTeachers.id == rel_id, + ClassTeachers.is_deleted == 0 + ).first() + if not db_rel: + return None + + # 2. 如果老师ID发生变化,校验老师是否有效 + if obj.teacher_id != db_rel.teacher_id: + teacher = get_teacher_by_id(db, obj.teacher_id) + if not teacher: + raise HTTPException(status_code=404, detail="老师不存在或已删除!") + + # 3. 查重:新的三元组合不能重复(排除自身这条记录) + exist_rel = db.query(ClassTeachers).filter( + ClassTeachers.class_id == obj.class_id, + ClassTeachers.teacher_id == obj.teacher_id, + ClassTeachers.role == obj.role, + ClassTeachers.is_deleted == 0, + ClassTeachers.id != rel_id + ).first() + if exist_rel: + raise HTTPException(status_code=409, detail="该老师已以相同角色分配到该班级!") + + # 赋值更新 + db_rel.class_id = obj.class_id + db_rel.teacher_id = obj.teacher_id + db_rel.role = obj.role + + db.commit() + db.refresh(db_rel) + return db_rel + + +#删除 +def delete_class_teacher(db: Session, rel_id: int): + """ + 逻辑删除班级老师带班关系 + :param db: Session + :param rel_id: class_teachers表主键id + :return: ClassTeacher ORM对象,找不到返回None + """ + db_obj = ( + db.query(ClassTeachers) + .filter(ClassTeachers.id == rel_id, ClassTeachers.is_deleted == 0) + .first() + ) + if not db_obj: + return None + + db_obj.is_deleted = 1 + db.commit() + return db_obj diff --git a/database.py b/database.py index ecff48f..7ee23b9 100644 --- a/database.py +++ b/database.py @@ -10,4 +10,4 @@ engine = create_engine(db_url Base.metadata.create_all(engine) db = SessionLocal() -db.commit() \ No newline at end of file +db.commit() #创建全部表 \ No newline at end of file diff --git a/docs/模型与建表改动说明.md b/docs/模型与建表改动说明.md new file mode 100644 index 0000000..933eb42 --- /dev/null +++ b/docs/模型与建表改动说明.md @@ -0,0 +1,217 @@ +# 数据库模型与建表工具改动说明 + +本次按照《沃林学生管理系统小组分工说明书 V1》修正模型定义,整理范围为 7 个 Model 文件和 1 个建表脚本。各模块继续使用 `core.database.Base`,保留原有 Python 类名和项目目录。 + +## 一、改动总览 + +| 模块 | 负责人 | 修改文件 | 模型类 | 最终表名 | +|---|---|---|---|---| +| 学生管理 | 李玉杰 | `models/student.py` | `Student` | `students` | +| 班级管理 | 婷婷 | `models/class_model.py` | `Classes` | `classes` | +| 老师管理 | 张义 | `models/teacher.py` | `Teacher` | `teachers` | +| 班级老师关系 | 张义 | `models/class_teacher.py` | `ClassTeachers` | `class_teachers` | +| 顾问管理 | 薄鑫 | `models/consultant.py` | `Consultant` | `consultants` | +| 成绩管理 | 曾凯 | `models/score.py` | `Score` | `scores` | +| 就业管理 | 南方宇 | `models/employment.py` | `Employment` | `employments` | +| 公共建库建表工具 | 张昕浩 | `utils/create database.py` | — | 使用上述全部模型 | + +### 经确认保留的类型 + +本次没有将所有主键统一为文档中的 `BIGINT`,成绩精度也按已确认的要求保留。 + +| 项目 | 当前模型类型 | +|---|---| +| 学生、班级、老师、顾问、成绩表的 `id` | `Integer` | +| 班级老师关系表、就业表的 `id` | `BigInteger` | +| 各表关联其他表的外键字段 | `Integer`,与对应模型的主键一致 | +| 成绩字段 `scores.score` | `DECIMAL(4,1)` | +| 薪资字段 `employments.salary` | `DECIMAL(10,2)` | + +其中 `class_teachers.class_id`、`class_teachers.teacher_id`、`employments.student_id` 原为 `BigInteger`,经确认改为 `Integer`,解决 MySQL 要求外键与被引用主键类型一致的问题。 + +## 二、公共字段调整 + +以下规则落实在各个 Model 文件中,没有增加公共模型文件或额外继承层。 + +| 字段 | 最终定义及作用 | +|---|---| +| `created_at` | `DATETIME`、不可为空;保留 ORM 创建时间默认值,增加数据库默认值 `CURRENT_TIMESTAMP` | +| `updated_at` | `DATETIME`、不可为空;使用 `onupdate=datetime.now`,增加数据库默认值及 `ON UPDATE CURRENT_TIMESTAMP` | +| `is_deleted` | MySQL `TINYINT`、不可为空;ORM 默认值和数据库默认值均为 `0` | + +原先误用 `sqlalchemy.dialects.mssql.TINYINT` 的老师、班级老师关系模型,改为从 `sqlalchemy.dialects.mysql` 导入。 + +`is_deleted` 是逻辑删除标志。Model 中声明这个字段不会自动过滤查询;接口查询仍需添加 `is_deleted == 0`,删除业务记录应更新该标志。 + +## 三、学生管理模块 + +文件:`models/student.py`;负责人:李玉杰。 + +| 修改项 | 修改前 | 修改后 | +|---|---|---| +| 表名 | `student_info_detail` | `students` | +| 学生编号 | 唯一,但允许为空 | 唯一且 `nullable=False` | +| 学生姓名 | 允许为空 | `nullable=False` | +| 班级外键 | `foreign_key='class_info_detail.id'`,不是有效外键声明 | `ForeignKey('classes.id')`,并设为非空 | +| 顾问外键 | `foreign_key='consultant_info_detail.id'`,不是有效外键声明 | `ForeignKey('consultants.id')`,继续允许为空 | +| 毕业院校字段 | `graduation_school` | `graduate_school` | +| 创建时间字段 | `create_at` | `created_at` | +| 更新时间字段 | `update_at` | `updated_at` | +| 逻辑删除 | `Integer`,允许为空 | MySQL `TINYINT`、非空、默认 `0` | + +时间字段按公共规则补齐数据库默认值及非空约束。学生主键、班级外键和顾问外键均保留 `Integer`。 + +## 四、班级管理模块 + +文件:`models/class_model.py`;负责人:婷婷。 + +| 修改项 | 修改前 | 修改后 | +|---|---|---| +| 开课日期 `start_date` | 允许为空 | `nullable=False`,开课时间必填 | +| 更新时间字段 | `update_at` | `updated_at` | +| 备注长度 | `String(100)` | `String(255)` | +| 逻辑删除 | `INTEGER` | MySQL `TINYINT`,补充数据库默认值 `0` | + +时间字段增加数据库默认值和数据库自动更新时间设置。保留表名 `classes`、类名 `Classes`、`Integer` 自增主键,以及班级编号唯一且非空的约束。 + +## 五、老师管理模块 + +文件:`models/teacher.py`;负责人:张义。 + +| 修改项 | 修改前 | 修改后 | +|---|---|---| +| 表名 | `teachers_info_detail` | `teachers` | +| 备注长度 | `VARCHAR(2550)` | `VARCHAR(255)` | +| `TINYINT` 来源 | SQL Server 方言 | MySQL 方言 | +| 更新时间 | 仅设置创建时的默认时间 | 补充 `onupdate=datetime.now` 和数据库自动更新时间 | + +创建时间增加数据库默认值,逻辑删除增加数据库默认值 `0`。保留 `Integer` 自增主键和老师编号唯一约束。 + +## 六、班级老师关系模块 + +文件:`models/class_teacher.py`;负责人:张义。 + +| 修改项 | 修改前 | 修改后 | +|---|---|---| +| 表名 | `class_teachers_info_detail` | `class_teachers` | +| 班级外键目标 | `ForeignKey('class_id')` | `ForeignKey('classes.id')` | +| 老师外键目标 | `ForeignKey('teacher_id')` | `ForeignKey('teachers.id')` | +| 两个外键字段类型 | `BigInteger` | `Integer`,与班级、老师模型主键匹配 | +| 联合唯一约束 | 未设置 | `(class_id, teacher_id, role)` 联合唯一 | +| 联合唯一约束名称 | — | `uk_class_teacher_role` | +| 时间默认值 | `datetime.now()`,导入模型时就求值 | `datetime.now`,插入记录时再求值 | +| `TINYINT` 来源 | SQL Server 方言 | MySQL 方言 | + +补齐时间及逻辑删除的数据库默认值,并为更新时间增加自动更新设置。关系表自身的 `id` 保留 `BigInteger`,类名保留 `ClassTeachers`。 + +联合唯一约束防止同一个老师以同一个角色被重复分配到同一个班级。 + +## 七、顾问管理模块 + +文件:`models/consultant.py`;负责人:薄鑫。 + +| 修改项 | 修改前 | 修改后 | +|---|---|---| +| 联系电话长度 | `String(11)` | `String(20)` | +| 逻辑删除类型 | `Integer` | MySQL `TINYINT` | +| 数据库默认值 | 未声明 | 补充创建时间、更新时间和逻辑删除默认值 | + +保留表名 `consultants`、`Integer` 自增主键、顾问编号唯一且非空的约束,以及其余业务字段。 + +## 八、成绩管理模块 + +文件:`models/score.py`;负责人:曾凯。 + +| 修改项 | 修改前 | 修改后 | +|---|---|---| +| 表名 | `score_info_detail` | `scores` | +| 学生外键目标 | `ForeignKey('student.id')` | `ForeignKey('students.id')` | +| 成绩是否必填 | 允许为空 | `nullable=False` | +| 联合唯一约束名称 | `uk_student_exam_seq` | `uk_scores_student_exam` | +| 备注长度 | `String(225)` | `String(255)` | +| 逻辑删除 | `Integer`,允许为空 | MySQL `TINYINT`、非空、默认 `0` | + +创建时间和更新时间补齐非空约束、数据库默认值及自动更新设置;修正注释,明确联合唯一约束是“学生 ID + 考核序次”。 + +保留 `Integer` 主键及学生外键,成绩精度保留 `DECIMAL(4,1)`,保留成绩范围 `0~100` 的检查约束。一个学生同一次考核只能有一条成绩。 + +## 九、就业管理模块 + +文件:`models/employment.py`;负责人:南方宇。 + +| 修改项 | 修改前 | 修改后 | +|---|---|---| +| 学生外键类型 | `BigInteger` | `Integer`,与学生模型主键匹配 | +| 学生就业唯一约束 | 未设置 | `student_id` 增加 `unique=True` | +| 就业状态长度 | `String(50)` | `String(30)` | +| 就业状态是否必填 | 允许为空 | `nullable=False` | +| 逻辑删除 | `SmallInteger`,允许为空 | MySQL `TINYINT`、非空、默认 `0` | + +创建时间和更新时间补齐非空约束、数据库默认值及自动更新设置。修正薪资字段注释:`DECIMAL` 是定点小数。 + +保留表名 `employments`、`BigInteger` 自增主键、`ForeignKey('students.id')`,以及薪资精度 `DECIMAL(10,2)`。学生外键唯一约束确保每个学生最多对应一条就业信息。 + +## 十、公共建库建表工具 + +文件:`utils/create database.py`;负责人:张昕浩。文件名中包含一个空格。 + +脚本执行流程: + +1. 将项目根目录加入当前进程的导入路径,使直接运行此文件时也能导入 `core` 和 `models`。 +2. 导入全部 7 个模型,将模型表注册到公共 `Base.metadata`。 +3. 检查整数外键与被引用主键的类型是否一致;不一致时列出具体字段并以失败状态退出。 +4. 从现有 `core.database.engine` 读取连接设置,先连接不指定数据库的 MySQL 服务。 +5. 创建缺失的数据库,字符集为 `utf8mb4`、排序规则为 `utf8mb4_unicode_ci`;当前配置库名为 `student_manager`。 +6. 调用 `Base.metadata.create_all(bind=engine)` 创建缺失的表。 +7. 查询实际表名,确认全部模型对应的表均存在。 +8. 释放数据库连接池;发生数据库或检查错误时输出原因,并返回非零退出码。 + +在项目根目录运行: + +```powershell +python "utils/create database.py" +``` + +本机已验证使用的解释器命令: + +```powershell +& 'D:\conda\envs\ai0720\python.exe' -B -X utf8 '.\utils\create database.py' +``` + +脚本只创建缺失的库和表,不清空数据、不删除表,也不自动修改已有表结构。 + +## 十一、验证结果及范围 + +完成三处外键类型修正后,脚本退出码为 `0`,输出如下: + +```text +数据库已就绪:student_manager +已导入的模型表: classes, teachers, consultants, students, class_teachers, scores, employments +建表检查通过:全部模型对应的表均已存在。 +``` + +已经完成的检查包括: + +- 7 个模型可以导入,6 个外键目标均能解析。 +- 外键与被引用主键在模型中的类型检查通过。 +- 7 张表的 MySQL 建表 SQL 可以编译。 +- 字段名、非空设置、字符串长度与本次对照结果一致。 +- 班级老师关系、成绩和就业的必要唯一约束已在模型中定义。 +- 7 个模型可以读取现有数据库中的对应表。 +- 文件差异格式检查通过。 + +本次执行针对已有的 `student_manager` 数据库。`create_all()` 会跳过已有表,因此此结果不等同于已经在全新空库中完成端到端创建测试,也不表示现有数据库已经迁移成当前模型定义。 + +现有库中的相关主外键仍为原来的 `BIGINT`,成绩列仍为原来的 `DECIMAL(5,2)`;本次没有执行 `ALTER TABLE`,模型里经确认保留的 `Integer` 和 `DECIMAL(4,1)` 不会自动改变这些实库类型。 + +## 十二、后续接口联调事项 + +以下配套代码不在本次修改范围内,需要模块负责人继续核对: + +| 位置 | 需要核对的内容 | +|---|---| +| `schemas/class_schema.py` 的 `ClassCreate` | 当前缺少 `start_date`,而模型规定开课日期必填 | +| `schemas/class_schema.py` 的 `ClassUpdate` | 当前仍使用 `update_at`,模型字段已统一为 `updated_at` | +| 学生新增及业务编号生成逻辑 | `student_no` 已设为非空;先以空编号执行 `flush()` 再生成编号会违反非空约束,需要与编号生成方案协调 | + +本次验证覆盖模型和建表工具,尚未完成上述业务接口的联调验证。 diff --git a/main.py b/main.py index 935af82..025c7d8 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,9 @@ from fastapi import FastAPI from api.example import example_router from middleware import log_middleware -# from api.statistics import statistics_router +from api.statistics import statistics_router import uvicorn -# from api.student import student_router +from api.student import student_router app = FastAPI() app.middleware("http")(log_middleware) @@ -12,15 +12,15 @@ from api.class_api import classes_router app.include_router(classes_router,tags=['班级接口'],prefix="/classes") #-------------李玉杰-------------- -# app.include_router(student_router) +app.include_router(student_router,tags=['学生接口'],prefix="/students") #-------------张义--------------- #-------------薄鑫--------------- -# from api import consultant -# app.include_router(consultant.router) +from api import consultant +app.include_router(consultant.router) #-------------张昕浩--------------- -# app.include_router(example_router) -# app.include_router(statistics_router, prefix="/statistics") +app.include_router(example_router) +app.include_router(statistics_router, prefix="/statistics") #-------------曾凯--------------- diff --git a/models/class_model.py b/models/class_model.py index c701b77..f6134ae 100644 --- a/models/class_model.py +++ b/models/class_model.py @@ -2,6 +2,7 @@ from sqlalchemy import * # 导入sqlalchemy模块下所有的类,以便下面创建班级表模型和声明表的字段 from datetime import datetime # 导入datetime模块下的datetime类 from core.database import Base # 从core软件包下的database模块导入Base +from sqlalchemy.dialects.mysql import TINYINT class Classes( Base ): # 在python里的名字 __tablename__ = 'class_info_detail' # 在数据库中表的名字 @@ -22,7 +23,7 @@ class Classes( Base ): # 在python里的名字 ) # ————创建班级"开课时间"的字段名———— start_date = Column( DATE # 声明是字段数据类型是日期 - , nullable = True # 声明是'可为空' + , nullable = False # 开课时间必填 ) # ————创建班级"结课时间"的字段名———— end_date = Column( DATE # 声明是字段数据类型是'日期' @@ -31,20 +32,23 @@ class Classes( Base ): # 在python里的名字 # ————创建班级"创建时间"的字段名———— created_at = Column( DATETIME # 声明是字段数据类型是"日期时间" , default=datetime.now # 声明是创建的默认值就是"当前时间" + , server_default=text("CURRENT_TIMESTAMP") , nullable = False # 声明是"非空" ) # ————创建班级"更新时间"的字段名———— update_at = Column( DATETIME # 声明是字段数据类型是"日期时间" , default=datetime.now #声明是第一次更新的时间就是第一次创建的时间一致 , onupdate=datetime.now # 声明"最后修改的时间" + , server_default=text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP") , nullable = False # 声明是"非空" ) # ————创建班级"备注信息"的字段名———— - remark = Column( String(100) # 声明是字段数据类型是"字符串" + remark = Column( String(255) # 声明是字段数据类型是"字符串" , nullable = True # 声明是"可以为空" ) # ————创建班级"逻辑删除"的字段名———— - is_deleted = Column( INTEGER # 声明是字段数据类型是"数字" + is_deleted = Column( TINYINT # 逻辑删除标记 , default = 0 # 声明默认值是"0,未删除" + , server_default = text("0") , nullable = False # 声明是"非空" ) diff --git a/models/consultant.py b/models/consultant.py index 5f6f4ed..fb8068d 100644 --- a/models/consultant.py +++ b/models/consultant.py @@ -1,5 +1,6 @@ from sqlalchemy import * +from sqlalchemy.dialects.mysql import TINYINT from datetime import datetime from core.database import Base class Consultant(Base): @@ -8,10 +9,10 @@ class Consultant(Base): id = Column(Integer, primary_key=True, autoincrement=True, comment="主键") consultant_no = Column(String(50), unique=True, nullable=False, comment="顾问编号") consultant_name = Column(String(50), nullable=False, comment="顾问姓名") - phone = Column(String(11), nullable=True, comment="联系电话") + phone = Column(String(20), nullable=True, comment="联系电话") email = Column(String(100), nullable=True, comment="邮箱") remark = Column(String(255), nullable=True, comment="备注") - created_at = Column(DateTime, default=datetime.now, nullable=False, comment="创建时间") - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False, comment="更新时间") - is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除:0正常,1删除") + created_at = Column(DateTime, default=datetime.now, server_default=text("CURRENT_TIMESTAMP"), nullable=False, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, server_default=text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), nullable=False, comment="更新时间") + is_deleted = Column(TINYINT, default=0, server_default=text("0"), nullable=False, comment="逻辑删除:0正常,1删除") diff --git a/models/student.py b/models/student.py index ab805e4..fc73e95 100644 --- a/models/student.py +++ b/models/student.py @@ -10,7 +10,8 @@ class Student(Base): , comment = '学生编号,自增主键' ) student_no = Column(VARCHAR(50) - , unique = True) + ,nullable = True + ,unique = True) student_name = Column(VARCHAR(50)) class_id = Column(Integer ,ForeignKey('class_info_detail.id') diff --git a/schemas/employment.py b/schemas/employment.py index e69de29..bfe27ed 100644 --- a/schemas/employment.py +++ b/schemas/employment.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel, Field +from datetime import date, datetime +from decimal import Decimal + + +class EmploymentCreate(BaseModel): + """新增就业信息 请求体 Schema""" + student_id: int = Field(..., description="关联学生ID,必填") + employment_status: str | None = Field(None, description="就业状态:not_started/job_hunting/offered/employed") + employment_open_date: date | None = Field(None, description="就业开放时间,选填") + offer_date: date | None = Field(None, description="offer下发时间,选填") + company_name: str | None = Field(None, max_length=100, description="就业公司名称,选填") + salary: Decimal | None = Field(None, ge=0, description="就业薪资,不能负数,选填") + remark: str | None = Field(None, max_length=255, description="备注,选填") + + +class EmploymentUpdate(BaseModel): + """修改就业信息 请求体 Schema:全部字段可选,只传要修改的字段""" + employment_status: str | None = Field(None, description="就业状态") + employment_open_date: date | None = Field(None, description="就业开放时间") + offer_date: date | None = Field(None, description="offer下发时间") + company_name: str | None = Field(None, max_length=100, description="就业公司名称") + salary: Decimal | None = Field(None, ge=0, description="就业薪资") + remark: str | None = Field(None, max_length=255, description="备注") + + +class EmploymentResponse(BaseModel): + """就业信息返回响应体Schema,接口返回给前端的数据结构""" + id: int + student_id: int + employment_status: str + employment_open_date: date | None + offer_date: date | None + company_name: str | None + salary: Decimal | None + remark: str | None + created_at: datetime | None + updated_at: datetime | None diff --git a/schemas/score.py b/schemas/score.py index e69de29..6e766ce 100644 --- a/schemas/score.py +++ b/schemas/score.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel, Field +from datetime import date, datetime +from typing import Optional, List +from decimal import Decimal + + +class ScoreCreate(BaseModel): + student_id: int + exam_seq: int + score: Decimal = Field(ge=0, le=100, description="分数0‑100") + exam_date: Optional[date] = None + remark: Optional[str] = None + + +class ScoreUpdate(BaseModel): + score: Optional[Decimal] = Field(None, ge=0, le=100) + exam_seq: Optional[int] = None + exam_date: Optional[date] = None + remark: Optional[str] = None + + +class ScoreItem(BaseModel): + id: int + student_id: int + score: Decimal + exam_seq: int + exam_date: Optional[date] + remark: Optional[str] + created_at: Optional[datetime] + updated_at: Optional[datetime] + is_deleted: int + + +class ScorePageResp(BaseModel): + total: int + data: List[ScoreItem] \ No newline at end of file diff --git a/schemas/student.py b/schemas/student.py index d4a044a..47f9b13 100644 --- a/schemas/student.py +++ b/schemas/student.py @@ -1,18 +1,40 @@ from pydantic import BaseModel from datetime import date +from pydantic import BaseModel, ConfigDict, Field, model_validator class StudentCreate(BaseModel): - student_no: str|None = None - student_name: str - class_id: int - consultant_id: int | None = None - native_place: str | None = None - graduate_school: str | None = None - major: str | None = None + student_no: str | None = Field(default=None, min_length=1, max_length=50) + student_name: str = Field(min_length=1, max_length=50) + class_id: int = Field(gt=0) + consultant_id: int | None = Field(default=None, gt=0) + native_place: str | None = Field(default=None, max_length=100) + graduation_school: str | None = Field(default=None, max_length=100) + major: str | None = Field(default=None, max_length=100) enrollment_date: date graduation_date: date | None = None + education: str | None = Field(default=None, max_length=50) + age: int = Field(ge=0, le=150) + gender: str = Field(min_length=1, max_length=10) + + @model_validator(mode="after") + def check_dates(self): + # 3. 新增时两个日期都已确定,可以直接校验先后顺序。 + if self.graduation_date and self.graduation_date < self.enrollment_date: + raise ValueError("毕业日期不能早于入学日期") + return self + +class StudentUpdate(BaseModel): + # 全部设为可选,前端只传需要修改的字段 + student_no: str | None = None + student_name: str | None = None + class_id: int | None = None + consultant_id: int | None = None + native_place: str | None = None + graduation_school: str | None = None + major: str | None = None + enrollment_date: date | None = None + graduation_date: date | None = None education: str | None = None - age: int - gender: str - + age: int | None = None + gender: str | None = None diff --git a/schemas/teacher.py b/schemas/teacher.py index 93cb1fb..6ecac07 100644 --- a/schemas/teacher.py +++ b/schemas/teacher.py @@ -1,10 +1,16 @@ from pydantic import BaseModel from typing import Optional from datetime import datetime + +from sqlalchemy import BigInteger +from sqlalchemy.dialects.mssql import TINYINT +from sqlalchemy.dialects.mysql import VARCHAR + + #===============老师表=================== #===============请求体模型================ class TeacherCreate(BaseModel): - teacher_no: Optional[str] = None + teacher_no: Optional[int] = None teacher_name: Optional[str] = None email: Optional[str] = None phone: Optional[str] = None @@ -17,3 +23,36 @@ class TeacherUpdate(BaseModel): phone: Optional[str] = None remark: Optional[str] = None +#================响应体================== +class TeacherOut(BaseModel): + id: int + teacher_no: str + teacher_name: str + phone: Optional[str] + email: Optional[str] + remark: Optional[str] + created_at: datetime + updated_at: datetime + is_deleted: int +#`orm_mode = True` 是 Pydantic v1 的配置项。开启之后,Pydantic 可以直接读取 SQLAlchemy +# 查询得到的 ORM 对象的属性,自动映射生成接口响应 JSON,不用手动把 ORM 对象转为字典。 +# 这个配置只用在响应体 Out 模型,新增和修改的请求模型不需要。 + class Config: + from_attributes=True + +#=====班级老师中间表 class_teachers ========= +class ClassTeachersCreate(BaseModel): + class_id: int + teacher_id: int + role: str +#=================响应体=================== +class ClassTeacherOut(BaseModel): + id: int + class_id: int + teacher_id: int + role: str + created_at: datetime + updated_at: datetime + is_deleted: int + class Config: + from_attributes=True diff --git a/utils/code_generator.py b/utils/code_generator.py index f4b48ce..3320511 100644 --- a/utils/code_generator.py +++ b/utils/code_generator.py @@ -33,7 +33,7 @@ def add_student( ''' - +from sqlalchemy.orm import Session def generate_no(field_name: str, prefix: str, db_object, db: Session): if getattr(db_object, field_name): return