Files
certificate-system/backend/app/api/routes/admin_learners.py
2026-08-14 11:37:09 +08:00

252 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import StreamingResponse
from openpyxl import Workbook, load_workbook
from openpyxl.comments import Comment
from openpyxl.styles import Alignment, Font, PatternFill
from sqlalchemy import func
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.db.session import get_db
from app.models import AdminUser, Certificate, Learner, LearnerNameHistory
from app.schemas.learner import LearnerCreate, LearnerImportResult, LearnerOut, LearnerUpdate
from app.services.learner_identity import LearnerIdentityConflict, normalize_name, normalize_phone, resolve_learner
from app.services.logs import diff_values, log_action, mask_phone
router = APIRouter()
LEARNER_IMPORT_HEADERS = ["姓名", "手机号", "学员编号", "备注"]
@router.get("", response_model=list[LearnerOut])
def list_learners(
keyword: str | None = Query(default=None),
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[dict[str, object]]:
count_subquery = (
db.query(Certificate.learner_id.label("learner_id"), func.count(Certificate.id).label("certificate_count"))
.group_by(Certificate.learner_id)
.subquery()
)
query = (
db.query(Learner, func.coalesce(count_subquery.c.certificate_count, 0))
.outerjoin(count_subquery, count_subquery.c.learner_id == Learner.id)
.filter(Learner.status != "deleted")
.order_by(Learner.id.desc())
)
if keyword:
like = f"%{keyword}%"
query = query.filter(or_(Learner.current_name.like(like), Learner.phone.like(like), Learner.student_no.like(like)))
return [{**LearnerOut.model_validate(learner).model_dump(), "certificate_count": certificate_count} for learner, certificate_count in query.limit(100).all()]
@router.post("", response_model=LearnerOut, status_code=status.HTTP_201_CREATED)
def create_learner(
payload: LearnerCreate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> Learner:
try:
learner, created = resolve_learner(db, payload.current_name, payload.phone, source="manual", create_if_missing=True)
except (ValueError, LearnerIdentityConflict) as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
assert learner is not None
learner.student_no = payload.student_no
learner.remark = payload.remark
action = "create_learner" if created else "update_learner"
log_action(db, admin, action, "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone), "status": learner.status})
db.commit()
db.refresh(learner)
return learner
@router.get("/import-template")
def download_learner_import_template(
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> StreamingResponse:
workbook = build_learner_import_template()
stream = BytesIO()
workbook.save(stream)
stream.seek(0)
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="learner-import-template.xlsx"'},
)
@router.post("/import", response_model=LearnerImportResult)
def import_learners(
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> dict[str, object]:
if not file.filename or not file.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 .xlsx 文件")
try:
workbook = load_workbook(file.file, read_only=True, data_only=True)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件无法读取") from exc
sheet = workbook.active
first_row = next(sheet.iter_rows(min_row=1, max_row=1), None)
headers = [cell.value for cell in first_row] if first_row else []
header_index = {name: idx for idx, name in enumerate(headers)}
missing = [name for name in LEARNER_IMPORT_HEADERS[:2] if name not in header_index]
if missing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"缺少必填列:{''.join(missing)}")
result: dict[str, object] = {"total_rows": 0, "created_rows": 0, "updated_rows": 0, "unchanged_rows": 0, "failed_rows": 0, "errors": []}
seen_phones: dict[str, str] = {}
for row_no, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
if not any(row):
continue
result["total_rows"] += 1
values = {name: row[index] if index < len(row) else None for name, index in header_index.items()}
try:
name = normalize_name(str(values.get("姓名") or ""))
phone = normalize_phone(str(values.get("手机号") or ""))
if not name:
raise ValueError("姓名不能为空")
if phone in seen_phones and seen_phones[phone] != name:
raise LearnerIdentityConflict(f"文件内手机号重复且姓名不一致:{seen_phones[phone]} / {name}")
seen_phones[phone] = name
learner, created = resolve_learner(db, name, phone, source="learner_import", create_if_missing=True)
assert learner is not None
changed = False
student_no = _optional_cell(values.get("学员编号"))
remark = _optional_cell(values.get("备注"))
if student_no is not None and learner.student_no != student_no:
learner.student_no = student_no
changed = True
if remark is not None and learner.remark != remark:
learner.remark = remark
changed = True
if created:
result["created_rows"] += 1
elif changed:
result["updated_rows"] += 1
else:
result["unchanged_rows"] += 1
except (ValueError, LearnerIdentityConflict) as exc:
result["failed_rows"] += 1
result["errors"].append({"row_no": row_no, "message": str(exc)})
log_action(db, admin, "import_learners", "learner", detail={key: value for key, value in result.items() if key != "errors"})
db.commit()
return result
def build_learner_import_template() -> Workbook:
workbook = Workbook()
sheet = workbook.active
sheet.title = "学员导入模板"
sheet.append(LEARNER_IMPORT_HEADERS)
examples = {
"姓名": ("张三", "必填。填写学员真实姓名"),
"手机号": ("13800000000", "必填。11位手机号系统中已存在时姓名必须一致"),
"学员编号": ("HY20260001", "选填。内部管理编号"),
"备注": ("2026年大本营学员", "选填。内部备注"),
}
for index, header in enumerate(LEARNER_IMPORT_HEADERS, start=1):
cell = sheet.cell(1, index)
cell.fill = PatternFill("solid", fgColor="208A87")
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.comment = Comment(examples[header][1], "证书管理系统")
sheet.column_dimensions[cell.column_letter].width = 18 if header != "备注" else 32
sheet.column_dimensions["B"].number_format = "@"
instruction = workbook.create_sheet("填写说明")
instruction.append(["字段", "是否必填", "示例", "填写说明"])
for header in LEARNER_IMPORT_HEADERS:
instruction.append([header, "" if header in LEARNER_IMPORT_HEADERS[:2] else "", examples[header][0], examples[header][1]])
instruction.column_dimensions["A"].width = 18
instruction.column_dimensions["B"].width = 14
instruction.column_dimensions["C"].width = 22
instruction.column_dimensions["D"].width = 52
sheet.freeze_panes = "A2"
return workbook
def _optional_cell(value: object) -> str | None:
text = str(value).strip() if value is not None else ""
return text or None
@router.get("/{learner_id}", response_model=LearnerOut)
def get_learner(
learner_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> Learner:
learner = db.get(Learner, learner_id)
if not learner:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
return learner
@router.put("/{learner_id}", response_model=LearnerOut)
def update_learner(
learner_id: int,
payload: LearnerUpdate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> Learner:
learner = db.get(Learner, learner_id)
if not learner:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
try:
normalized_phone = normalize_phone(payload.phone)
normalized_name = normalize_name(payload.current_name)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
duplicate = db.query(Learner).filter(Learner.phone == normalized_phone, Learner.id != learner_id).first()
if duplicate:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Phone already exists")
before = {
"phone": learner.phone,
"current_name": learner.current_name,
"student_no": learner.student_no,
"status": learner.status,
"remark": learner.remark,
}
if learner.current_name != normalized_name:
db.add(LearnerNameHistory(learner_id=learner.id, name=normalized_name, source="manual"))
learner.phone = normalized_phone
learner.current_name = normalized_name
learner.student_no = payload.student_no
learner.status = payload.status
learner.remark = payload.remark
after = {
"phone": learner.phone,
"current_name": learner.current_name,
"student_no": learner.student_no,
"status": learner.status,
"remark": learner.remark,
}
changes = diff_values(before, after, list(after.keys()))
if "phone" in changes:
changes["phone"] = {"before": mask_phone(changes["phone"]["before"]), "after": mask_phone(changes["phone"]["after"])}
log_action(db, admin, "update_learner", "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone), "changes": changes})
db.commit()
db.refresh(learner)
return learner
@router.delete("/{learner_id}")
def delete_learner(
learner_id: int,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> dict[str, bool]:
learner = db.get(Learner, learner_id)
if not learner:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
learner.status = "deleted"
log_action(db, admin, "delete_learner", "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone)})
db.commit()
return {"ok": True}