175 lines
7.4 KiB
Python
175 lines
7.4 KiB
Python
"""班级管理接口(需求 2.4)。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Query
|
|
|
|
from app.core.deps import DbSession, ReadAccount, WriteAccount
|
|
from app.core.exceptions import BusinessError, ConflictError, NotFoundError
|
|
from app.core.response import Resp, ok
|
|
from app.core.utils import parse_date
|
|
from app.dao.advisor_dao import AdvisorDao
|
|
from app.dao.clazz_dao import ClazzDao
|
|
from app.dao.teacher_dao import TeacherDao
|
|
from app.model import Clazz
|
|
from app.schema.clazz_schema import ClazzCreate, ClazzOut, ClazzUpdate
|
|
from app.schema.common import PageResult
|
|
|
|
router = APIRouter(prefix="/classes", tags=["2.4 班级管理"])
|
|
|
|
|
|
def _to_out(clazz: Clazz, student_count: int | None = None) -> ClazzOut:
|
|
out = ClazzOut.model_validate(clazz)
|
|
if student_count is not None:
|
|
out.student_count = student_count
|
|
return out
|
|
|
|
|
|
@router.get("", response_model=Resp[PageResult[ClazzOut]], summary="班级列表")
|
|
def list_classes(
|
|
db: DbSession,
|
|
_: ReadAccount,
|
|
page: Annotated[int, Query(ge=1)] = 1,
|
|
page_size: Annotated[int, Query(ge=1, le=200)] = 10,
|
|
keyword: Annotated[str | None, Query(description="班级名称/编号/方向")] = None,
|
|
status: Annotated[int | None, Query(ge=1, le=3, description="1=在读 2=已结课 3=已解散")] = None,
|
|
advisor_id: Annotated[int | None, Query(description="带班顾问")] = None,
|
|
head_teacher_id: Annotated[int | None, Query(description="班主任")] = None,
|
|
teacher_id: Annotated[int | None, Query(description="授课老师")] = None,
|
|
order_by: Annotated[str, Query()] = "id",
|
|
order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
|
|
):
|
|
stmt = ClazzDao.build_stmt(
|
|
keyword=keyword, status=status, advisor_id=advisor_id,
|
|
head_teacher_id=head_teacher_id, teacher_id=teacher_id, order_by=order_by, order=order,
|
|
)
|
|
items, total, page, pages = ClazzDao.paginate(db, stmt, page, page_size)
|
|
counts = ClazzDao.student_counts(db)
|
|
return ok({
|
|
"total": total, "page": page, "page_size": page_size, "pages": pages,
|
|
"items": [_to_out(c, counts.get(c.id, 0)) for c in items],
|
|
})
|
|
|
|
|
|
@router.get("/meta/options", summary="班级下拉选项(含老师)")
|
|
def class_options(db: DbSession, _: ReadAccount):
|
|
classes = ClazzDao.all(db, ClazzDao.build_stmt(order_by="class_no", order="asc"))
|
|
teachers = TeacherDao.all(db, TeacherDao.build_stmt(order_by="id", order="asc"))
|
|
advisors = AdvisorDao.all(db, AdvisorDao.build_stmt(order_by="id", order="asc"))
|
|
counts = ClazzDao.student_counts(db)
|
|
return ok({
|
|
"classes": [
|
|
{"id": c.id, "name": c.name, "class_no": c.class_no, "student_count": counts.get(c.id, 0)}
|
|
for c in classes
|
|
],
|
|
"teachers": [{"id": t.id, "name": t.name, "subject": t.subject} for t in teachers],
|
|
"advisors": [{"id": a.id, "name": a.name} for a in advisors],
|
|
})
|
|
|
|
|
|
@router.post("", response_model=Resp[ClazzOut], summary="创建班级")
|
|
def create_class(db: DbSession, _: WriteAccount, payload: ClazzCreate):
|
|
class_no = (payload.class_no or "").strip()
|
|
if not class_no:
|
|
prefix = _direction_prefix(payload.direction or payload.name)
|
|
from datetime import date
|
|
|
|
class_no = ClazzDao.next_class_no(db, prefix, (parse_date(payload.open_date, "开课时间") or date.today()).year)
|
|
if ClazzDao.get_by_class_no(db, class_no, with_deleted=True):
|
|
raise ConflictError(f"班级编号 {class_no} 已存在")
|
|
|
|
_check_refs(db, payload.head_teacher_id, payload.advisor_id, payload.teacher_ids)
|
|
|
|
klass = Clazz(
|
|
class_no=class_no,
|
|
name=payload.name,
|
|
direction=payload.direction,
|
|
open_date=parse_date(payload.open_date, "开课时间"),
|
|
close_date=parse_date(payload.close_date, "结课时间"),
|
|
classroom=payload.classroom,
|
|
capacity=payload.capacity,
|
|
status=payload.status,
|
|
head_teacher_id=payload.head_teacher_id,
|
|
advisor_id=payload.advisor_id,
|
|
description=payload.description,
|
|
)
|
|
if payload.teacher_ids:
|
|
all_teachers = {t.id: t for t in TeacherDao.all(db, TeacherDao.build_stmt())}
|
|
missing = [tid for tid in payload.teacher_ids if tid not in all_teachers]
|
|
if missing:
|
|
raise NotFoundError(f"老师不存在:{missing}")
|
|
klass.teachers = [all_teachers[tid] for tid in payload.teacher_ids]
|
|
db.add(klass)
|
|
db.flush()
|
|
db.commit()
|
|
db.refresh(klass)
|
|
return ok(_to_out(klass, 0), msg=f"班级 {klass.name} 创建成功,编号 {klass.class_no}")
|
|
|
|
|
|
@router.get("/{class_id}", response_model=Resp[ClazzOut], summary="班级详情")
|
|
def get_class(db: DbSession, _: ReadAccount, class_id: int):
|
|
klass = ClazzDao.get_or_404(db, class_id, "班级")
|
|
counts = ClazzDao.student_counts(db)
|
|
return ok(_to_out(klass, counts.get(klass.id, 0)))
|
|
|
|
|
|
@router.put("/{class_id}", response_model=Resp[ClazzOut], summary="更新班级")
|
|
def update_class(db: DbSession, _: WriteAccount, class_id: int, payload: ClazzUpdate):
|
|
klass = ClazzDao.get_or_404(db, class_id, "班级")
|
|
data = payload.model_dump(exclude_unset=True)
|
|
_check_refs(db, data.get("head_teacher_id"), data.get("advisor_id"), data.get("teacher_ids"))
|
|
|
|
for field in ("open_date", "close_date"):
|
|
if field in data:
|
|
setattr(klass, field, parse_date(data.pop(field), field))
|
|
teacher_ids = data.pop("teacher_ids", None)
|
|
|
|
for key, value in data.items():
|
|
if value is not None and hasattr(klass, key):
|
|
setattr(klass, key, value)
|
|
|
|
if teacher_ids is not None:
|
|
all_teachers = {t.id: t for t in TeacherDao.all(db, TeacherDao.build_stmt())}
|
|
missing = [tid for tid in teacher_ids if tid not in all_teachers]
|
|
if missing:
|
|
raise NotFoundError(f"老师不存在:{missing}")
|
|
klass.teachers = [all_teachers[tid] for tid in teacher_ids]
|
|
|
|
db.commit()
|
|
db.refresh(klass)
|
|
counts = ClazzDao.student_counts(db)
|
|
return ok(_to_out(klass, counts.get(klass.id, 0)), msg="更新成功")
|
|
|
|
|
|
@router.delete("/{class_id}", summary="逻辑删除班级(班内还有学生时拒绝)")
|
|
def delete_class(db: DbSession, _: WriteAccount, class_id: int):
|
|
klass = ClazzDao.get_or_404(db, class_id, "班级")
|
|
counts = ClazzDao.student_counts(db)
|
|
if counts.get(class_id, 0) > 0:
|
|
# 直接删会让这些学生"无班可归",统计里凭空少人,所以这里拦一道
|
|
raise BusinessError(
|
|
f"班级「{klass.name}」下还有 {counts[class_id]} 名学生,请先转班或删除学生后再删班级"
|
|
)
|
|
klass.soft_delete()
|
|
db.commit()
|
|
return ok(msg=f"已删除班级 {klass.name}")
|
|
|
|
|
|
def _direction_prefix(text: str) -> str:
|
|
letters = "".join(ch for ch in text if ch.isascii() and ch.isalpha()).upper()
|
|
return letters[:6] or "CLS"
|
|
|
|
|
|
def _check_refs(db, head_teacher_id, advisor_id, teacher_ids) -> None:
|
|
if head_teacher_id is not None and TeacherDao.get(db, head_teacher_id) is None:
|
|
raise NotFoundError(f"班主任(老师 id={head_teacher_id})不存在")
|
|
if advisor_id is not None and AdvisorDao.get(db, advisor_id) is None:
|
|
raise NotFoundError(f"顾问(id={advisor_id})不存在")
|
|
if teacher_ids:
|
|
exist = {t.id for t in TeacherDao.all(db, TeacherDao.build_stmt())}
|
|
missing = [tid for tid in teacher_ids if tid not in exist]
|
|
if missing:
|
|
raise NotFoundError(f"老师不存在:{missing}")
|