新增多证书模板管理流程

This commit is contained in:
Certificate System
2026-08-13 18:06:05 +08:00
parent eac19ea593
commit abbefc4b48
27 changed files with 774 additions and 61 deletions

View File

@@ -3,6 +3,7 @@ from fastapi import APIRouter
from app.api.routes import (
admin_backups,
admin_certificates,
admin_certificate_templates,
admin_dashboard,
admin_exports,
admin_imports,
@@ -23,6 +24,7 @@ api_router.include_router(admin_projects.router, prefix="/admin/projects", tags=
api_router.include_router(admin_dashboard.router, prefix="/admin/dashboard", tags=["admin-dashboard"])
api_router.include_router(admin_learners.router, prefix="/admin/learners", tags=["admin-learners"])
api_router.include_router(admin_certificates.router, prefix="/admin/certificates", tags=["admin-certificates"])
api_router.include_router(admin_certificate_templates.router, prefix="/admin/certificate-templates", tags=["admin-certificate-templates"])
api_router.include_router(admin_imports.router, prefix="/admin/import-batches", tags=["admin-imports"])
api_router.include_router(admin_exports.router, prefix="/admin/exports", tags=["admin-exports"])
api_router.include_router(admin_logs.router, prefix="/admin/logs", tags=["admin-logs"])

View File

@@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from app.api.deps import require_roles
from app.models import AdminUser
from app.schemas.certificate_template import CertificateTemplateOut
from app.services.certificate_templates import get_certificate_template, list_certificate_templates
router = APIRouter()
@router.get("", response_model=list[CertificateTemplateOut])
def list_templates(
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[dict[str, object]]:
return [template.to_dict() for template in list_certificate_templates()]
@router.get("/{template_code}/preview")
def preview_template(template_code: str) -> FileResponse:
try:
template = get_certificate_template(template_code)
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return FileResponse(template.asset_path, media_type="image/png")

View File

@@ -13,6 +13,7 @@ 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
from app.services.certificate_templates import get_certificate_template
router = APIRouter()
@@ -73,6 +74,11 @@ def create_certificate(
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")
template_code = payload.template_code or project.default_template_code
try:
template_code = get_certificate_template(template_code).code
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
certificate = Certificate(
learner_id=payload.learner_id,
@@ -86,6 +92,7 @@ def create_certificate(
course_end_date=payload.course_end_date,
issue_date=payload.issue_date,
issuer_name=project.default_issuer_name,
template_code=template_code,
remark=payload.remark,
)
db.add(certificate)
@@ -172,6 +179,7 @@ def _certificate_payload(certificate: Certificate, learner: Learner | None = Non
"course_end_date": certificate.course_end_date,
"issue_date": certificate.issue_date,
"issuer_name": certificate.issuer_name,
"template_code": certificate.template_code,
"status": certificate.status,
"pdf_status": certificate.pdf_status,
"created_at": certificate.created_at,
@@ -242,6 +250,7 @@ def preview_certificate(
"course_end_date": certificate.course_end_date.isoformat() if certificate.course_end_date else None,
"issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name,
"template_code": certificate.template_code,
"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 "",

View File

@@ -41,6 +41,7 @@ def export_certificates(
"证书编号",
"证书对外直达链接",
"项目代码",
"证书模板",
"证书名称",
"课程名称",
"阶段名称",
@@ -59,6 +60,7 @@ def export_certificates(
certificate.certificate_no,
f"{settings.public_base_url}/cert/{token.token_value}",
certificate.project_code,
certificate.template_code,
certificate.certificate_name,
certificate.course_name,
certificate.stage_name,

View File

@@ -3,7 +3,7 @@ import shutil
from datetime import date, datetime
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from openpyxl import Workbook, load_workbook
from openpyxl.comments import Comment
@@ -28,6 +28,7 @@ from app.models import (
from app.schemas.import_batch import ImportBatchOut
from app.services.certificate_number import build_certificate_no
from app.services.logs import log_action
from app.services.certificate_templates import get_certificate_template
router = APIRouter()
@@ -74,18 +75,23 @@ def build_import_template_workbook() -> Workbook:
@router.post("", response_model=ImportBatchOut, status_code=status.HTTP_201_CREATED)
def upload_import_file(
template_code: str = Form(...),
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> ImportBatch:
if not file.filename or not file.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only .xlsx files are supported")
try:
template_code = get_certificate_template(template_code).code
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
upload_path = data_path("uploads") / file.filename
with upload_path.open("wb") as target:
shutil.copyfileobj(file.file, target)
batch = ImportBatch(filename=file.filename, file_path=str(upload_path), created_by=admin.id)
batch = ImportBatch(filename=file.filename, file_path=str(upload_path), template_code=template_code, created_by=admin.id)
db.add(batch)
db.flush()
validate_batch(db, batch, upload_path)
@@ -188,7 +194,15 @@ def confirm_import_batch(
row.error_message = f"Project code is inactive or missing: {project_code}"
failed_rows += 1
continue
duplicate = find_duplicate_certificate(db, learner.id, project, course_start_date, course_end_date, issue_date)
duplicate = find_duplicate_certificate(
db,
learner.id,
project,
course_start_date,
course_end_date,
issue_date,
batch.template_code,
)
if duplicate:
row.status = "skipped"
row.error_message = "\u5df2\u5b58\u5728\uff0c\u65e0\u9700\u5904\u7406"
@@ -207,6 +221,7 @@ def confirm_import_batch(
course_end_date=course_end_date,
issue_date=issue_date,
issuer_name=project.default_issuer_name,
template_code=batch.template_code,
remark=None,
)
db.add(certificate)
@@ -326,6 +341,7 @@ def find_duplicate_certificate(
course_start_date: date,
course_end_date: date,
issue_date: date,
template_code: str,
) -> Certificate | None:
return (
db.query(Certificate)
@@ -337,6 +353,7 @@ def find_duplicate_certificate(
.filter(Certificate.course_start_date == course_start_date)
.filter(Certificate.course_end_date == course_end_date)
.filter(Certificate.issue_date == issue_date)
.filter(Certificate.template_code == template_code)
.first()
)

View File

@@ -6,6 +6,7 @@ from app.db.session import get_db
from app.models import AdminUser, ProjectCourse
from app.schemas.project import ProjectCourseCreate, ProjectCourseOut, ProjectCourseUpdate
from app.services.logs import diff_values, log_action
from app.services.certificate_templates import get_certificate_template
router = APIRouter()
@@ -24,6 +25,10 @@ def create_project(
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> ProjectCourse:
try:
template_code = get_certificate_template(payload.default_template_code).code
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if db.query(ProjectCourse).filter(ProjectCourse.code == payload.code).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目代码已存在")
project = ProjectCourse(
@@ -33,6 +38,7 @@ def create_project(
default_course_name=payload.default_course_name,
default_stage_name=payload.default_stage_name,
default_issuer_name=payload.default_issuer_name,
default_template_code=template_code,
)
db.add(project)
log_action(db, admin, "create_project", "project_course", detail={"code": payload.code, "name": payload.name, "status": project.status})
@@ -56,6 +62,7 @@ def update_project(
"default_course_name": project.default_course_name,
"default_stage_name": project.default_stage_name,
"default_issuer_name": project.default_issuer_name,
"default_template_code": project.default_template_code,
"status": project.status,
}
if payload.name is not None:
@@ -67,6 +74,12 @@ def update_project(
project.default_stage_name = payload.default_stage_name
if payload.default_issuer_name is not None:
project.default_issuer_name = payload.default_issuer_name
if payload.default_template_code is not None:
try:
template_code = get_certificate_template(payload.default_template_code).code
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
project.default_template_code = template_code
if payload.status is not None:
project.status = payload.status
after = {
@@ -74,6 +87,7 @@ def update_project(
"default_course_name": project.default_course_name,
"default_stage_name": project.default_stage_name,
"default_issuer_name": project.default_issuer_name,
"default_template_code": project.default_template_code,
"status": project.status,
}
log_action(db, admin, "update_project", "project_course", project.id, {"code": project.code, "name": project.name, "changes": diff_values(before, after, list(after.keys()))})

View File

@@ -180,6 +180,7 @@ def public_certificate_payload(db: Session, certificate: Certificate, learner: L
"course_end_date": certificate.course_end_date.isoformat() if certificate.course_end_date else None,
"issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name,
"template_code": certificate.template_code,
"status": certificate.status,
"pdf_status": certificate.pdf_status,
"public_token": token_value,