206 lines
8.9 KiB
Python
206 lines
8.9 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
|
|
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.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"),
|
|
db: Session = Depends(get_db),
|
|
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
|
) -> list[Certificate]:
|
|
query = db.query(Certificate).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),
|
|
)
|
|
)
|
|
if status_value:
|
|
query = query.filter(Certificate.status == status_value)
|
|
return query.limit(100).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("/{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
|
|
|
|
|
|
@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
|