Files
certificate-system/backend/app/api/routes/admin_certificates.py
2026-06-23 13:59:56 +08:00

320 lines
13 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.core.security import generate_public_token, hash_token
from app.db.session import get_db
from app.models import AdminUser, Certificate, CertificateAccessToken, Learner, ProjectCourse
from app.schemas.certificate import CertificateCreate, CertificateOut, PdfPregenerationJobCreate, PdfPregenerationJobOut
from app.services.certificate_number import build_certificate_no
from app.services.logs import log_action
from app.services.pdf import PdfGenerationBusy, render_certificate_pdf
from app.services.pdf_pregeneration import pdf_pregeneration_manager
from app.services.system_settings import get_pdf_generation_concurrency_limit
router = APIRouter()
def _create_token(db: Session, certificate_id: int, token_type: str) -> int:
raw_token = generate_public_token()
token = CertificateAccessToken(
certificate_id=certificate_id,
token_hash=hash_token(raw_token),
token_value=raw_token,
token_type=token_type,
)
db.add(token)
db.flush()
return token.id
@router.get("", response_model=list[CertificateOut])
def list_certificates(
keyword: str | None = Query(default=None),
status_value: str | None = Query(default=None, alias="status"),
pdf_status: str | None = Query(default=None),
import_batch_id: int | None = Query(default=None),
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[dict[str, object]]:
query = db.query(Certificate, Learner).outerjoin(Learner, Learner.id == Certificate.learner_id).order_by(Certificate.id.desc())
if keyword:
like = f"%{keyword}%"
query = query.filter(
or_(
Certificate.certificate_no.like(like),
Certificate.certificate_name.like(like),
Certificate.project_code.like(like),
Certificate.course_name.like(like),
Certificate.stage_name.like(like),
Learner.current_name.like(like),
)
)
if status_value:
query = query.filter(Certificate.status == status_value)
if pdf_status:
query = query.filter(Certificate.pdf_status == pdf_status)
if import_batch_id:
query = query.filter(Certificate.import_batch_id == import_batch_id)
return [_certificate_payload(certificate, learner) for certificate, learner in query.limit(200).all()]
@router.post("", response_model=CertificateOut, status_code=status.HTTP_201_CREATED)
def create_certificate(
payload: CertificateCreate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> Certificate:
learner = db.get(Learner, payload.learner_id)
if not learner:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
project = db.query(ProjectCourse).filter(ProjectCourse.code == payload.project_code, ProjectCourse.status == "active").first()
if not project:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project code is inactive or missing")
certificate = Certificate(
learner_id=payload.learner_id,
project_code=payload.project_code,
certificate_no="PENDING",
certificate_name=project.name,
class_name=payload.class_name,
course_name=payload.course_name or project.default_course_name or project.name,
stage_name=payload.stage_name or project.default_stage_name,
issue_date=payload.issue_date,
issuer_name=payload.issuer_name or project.default_issuer_name,
remark=payload.remark,
)
db.add(certificate)
db.flush()
certificate.certificate_no = build_certificate_no(certificate.id, certificate.project_code, certificate.issue_date)
certificate.public_token_id = _create_token(db, certificate.id, "public_link")
certificate.qr_token_id = _create_token(db, certificate.id, "qr_verify")
log_action(db, admin, "create_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name, "project_code": certificate.project_code, "issue_date": certificate.issue_date})
db.commit()
db.refresh(certificate)
return certificate
@router.get("/pdf-pregeneration-jobs", response_model=list[PdfPregenerationJobOut])
def list_pdf_pregeneration_jobs(
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[dict[str, object]]:
return [job.to_dict() for job in pdf_pregeneration_manager.list()]
@router.post("/pdf-pregeneration-jobs", response_model=PdfPregenerationJobOut, status_code=status.HTTP_201_CREATED)
def start_pdf_pregeneration_job(
payload: PdfPregenerationJobCreate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> dict[str, object]:
certificate_ids = _resolve_pregeneration_certificate_ids(db, payload)
if not certificate_ids:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="没有可预生成的证书")
concurrency_limit = get_pdf_generation_concurrency_limit(db)
job = pdf_pregeneration_manager.start(certificate_ids, concurrency_limit)
log_action(
db,
admin,
"start_pdf_pregeneration",
"certificate",
job.id,
detail={
"total": job.total,
"concurrency_limit": concurrency_limit,
"import_batch_id": payload.import_batch_id,
},
)
db.commit()
return job.to_dict()
@router.get("/pdf-pregeneration-jobs/{job_id}", response_model=PdfPregenerationJobOut)
def get_pdf_pregeneration_job(
job_id: str,
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> dict[str, object]:
job = pdf_pregeneration_manager.get(job_id)
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="预生成任务不存在或已过期")
return job.to_dict()
@router.get("/{certificate_id}", response_model=CertificateOut)
def get_certificate(
certificate_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> Certificate:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
return certificate
def _certificate_payload(certificate: Certificate, learner: Learner | None = None) -> dict[str, object]:
return {
"id": certificate.id,
"learner_id": certificate.learner_id,
"learner_name": learner.current_name if learner else None,
"import_batch_id": certificate.import_batch_id,
"project_code": certificate.project_code,
"certificate_no": certificate.certificate_no,
"certificate_name": certificate.certificate_name,
"class_name": certificate.class_name,
"course_name": certificate.course_name,
"stage_name": certificate.stage_name,
"issue_date": certificate.issue_date,
"issuer_name": certificate.issuer_name,
"status": certificate.status,
"pdf_status": certificate.pdf_status,
"created_at": certificate.created_at,
"updated_at": certificate.updated_at,
}
def _resolve_pregeneration_certificate_ids(db: Session, payload: PdfPregenerationJobCreate) -> list[int]:
ids: list[int] = []
if payload.import_batch_id:
ids.extend(
item[0]
for item in db.query(Certificate.id)
.filter(Certificate.import_batch_id == payload.import_batch_id)
.filter(Certificate.status == "valid")
.order_by(Certificate.id.asc())
.all()
)
selected_ids = _dedupe_ids(payload.certificate_ids)
if selected_ids:
valid_selected_ids = {
item[0]
for item in db.query(Certificate.id)
.filter(Certificate.id.in_(selected_ids))
.filter(Certificate.status == "valid")
.all()
}
ids.extend(certificate_id for certificate_id in selected_ids if certificate_id in valid_selected_ids)
seen: set[int] = set()
result: list[int] = []
for item in ids:
if item not in seen:
seen.add(item)
result.append(item)
return result
def _dedupe_ids(values: list[int]) -> list[int]:
seen: set[int] = set()
result: list[int] = []
for value in values:
if value not in seen:
seen.add(value)
result.append(value)
return result
@router.get("/{certificate_id}/preview")
def preview_certificate(
certificate_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> dict[str, object]:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
learner = db.get(Learner, certificate.learner_id)
public_token = db.get(CertificateAccessToken, certificate.public_token_id) if certificate.public_token_id else None
qr_token = db.get(CertificateAccessToken, certificate.qr_token_id) if certificate.qr_token_id else None
return {
"certificate_no": certificate.certificate_no,
"learner_name": learner.current_name if learner else "",
"certificate_name": certificate.certificate_name,
"project_code": certificate.project_code,
"course_name": certificate.course_name,
"stage_name": certificate.stage_name,
"issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name,
"status": certificate.status,
"public_url": f"/cert/{public_token.token_value}" if public_token else "",
"verify_url": f"/verify/{qr_token.token_value}" if qr_token else "",
}
@router.get("/{certificate_id}/download")
def download_certificate(
certificate_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> FileResponse:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
if certificate.status != "valid":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Voided certificate cannot download normal PDF")
learner = db.get(Learner, certificate.learner_id)
token = db.get(CertificateAccessToken, certificate.qr_token_id or certificate.public_token_id)
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
if not learner or not token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Certificate data is incomplete")
try:
pdf_path = render_certificate_pdf(
certificate,
learner,
project,
token,
concurrency_limit=get_pdf_generation_concurrency_limit(db),
)
except PdfGenerationBusy as exc:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(exc)) from exc
db.commit()
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf")
@router.post("/{certificate_id}/void", response_model=CertificateOut)
def void_certificate(
certificate_id: int,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> Certificate:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
certificate.status = "voided"
certificate.pdf_status = "not_generated"
certificate.pdf_file_path = None
learner = db.get(Learner, certificate.learner_id)
log_action(db, admin, "void_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name if learner else "", "previous_status": "valid", "status": "voided"})
db.commit()
db.refresh(certificate)
return certificate
@router.post("/{certificate_id}/token/regenerate", response_model=CertificateOut)
def regenerate_public_token(
certificate_id: int,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> Certificate:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
if certificate.public_token_id:
old_token = db.get(CertificateAccessToken, certificate.public_token_id)
if old_token:
old_token.status = "revoked"
certificate.public_token_id = _create_token(db, certificate.id, "public_link")
learner = db.get(Learner, certificate.learner_id)
log_action(
db,
admin,
"regenerate_public_token",
"certificate",
certificate.id,
{"certificate_no": certificate.certificate_no, "learner_name": learner.current_name if learner else ""},
)
db.commit()
db.refresh(certificate)
return certificate