新增多证书模板管理流程
This commit is contained in:
@@ -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"])
|
||||
|
||||
25
backend/app/api/routes/admin_certificate_templates.py
Normal file
25
backend/app/api/routes/admin_certificate_templates.py
Normal 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")
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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()))})
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
backend/app/assets/certificate-template-practice-camp.png
Normal file
BIN
backend/app/assets/certificate-template-practice-camp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 699 KiB |
@@ -37,6 +37,7 @@ def seed_defaults(db: Session, admin_username: str, admin_password: str) -> None
|
||||
default_course_name=name,
|
||||
default_stage_name=None,
|
||||
default_issuer_name="本公司",
|
||||
default_template_code="classic",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class ProjectCourse(Base):
|
||||
default_course_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
default_stage_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
default_issuer_name: Mapped[str] = mapped_column(String(128), default="本公司")
|
||||
default_template_code: Mapped[str] = mapped_column(String(32), default="classic")
|
||||
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
@@ -37,6 +38,7 @@ class Certificate(Base):
|
||||
course_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
issue_date: Mapped[date] = mapped_column(Date)
|
||||
issuer_name: Mapped[str] = mapped_column(String(128))
|
||||
template_code: Mapped[str] = mapped_column(String(32), default="classic", index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="valid")
|
||||
pdf_status: Mapped[str] = mapped_column(String(32), default="not_generated")
|
||||
pdf_file_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
@@ -12,6 +12,7 @@ class ImportBatch(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
file_path: Mapped[str] = mapped_column(String(512))
|
||||
template_code: Mapped[str] = mapped_column(String(32), default="classic")
|
||||
status: Mapped[str] = mapped_column(String(32), default="uploaded")
|
||||
total_rows: Mapped[int] = mapped_column(Integer, default=0)
|
||||
valid_rows: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
@@ -13,6 +13,7 @@ class CertificateCreate(BaseModel):
|
||||
course_start_date: date
|
||||
course_end_date: date
|
||||
issue_date: date
|
||||
template_code: str | None = Field(default=None, min_length=1, max_length=32)
|
||||
remark: str | None = None
|
||||
|
||||
@field_validator("project_code")
|
||||
@@ -44,6 +45,7 @@ class CertificateOut(BaseModel):
|
||||
course_end_date: date | None
|
||||
issue_date: date
|
||||
issuer_name: str
|
||||
template_code: str
|
||||
status: str
|
||||
pdf_status: str
|
||||
created_at: datetime
|
||||
|
||||
10
backend/app/schemas/certificate_template.py
Normal file
10
backend/app/schemas/certificate_template.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CertificateTemplateOut(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
description: str
|
||||
dynamic_fields: list[str]
|
||||
preview_url: str
|
||||
status: str
|
||||
@@ -6,6 +6,7 @@ from pydantic import BaseModel
|
||||
class ImportBatchOut(BaseModel):
|
||||
id: int
|
||||
filename: str
|
||||
template_code: str
|
||||
status: str
|
||||
total_rows: int
|
||||
valid_rows: int
|
||||
|
||||
@@ -10,6 +10,7 @@ class ProjectCourseCreate(BaseModel):
|
||||
default_course_name: str | None = Field(default=None, max_length=128)
|
||||
default_stage_name: str | None = Field(default=None, max_length=128)
|
||||
default_issuer_name: str = Field(default="本公司", min_length=1, max_length=128)
|
||||
default_template_code: str = Field(default="classic", min_length=1, max_length=32)
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
@@ -31,6 +32,7 @@ class ProjectCourseUpdate(BaseModel):
|
||||
default_course_name: str | None = Field(default=None, max_length=128)
|
||||
default_stage_name: str | None = Field(default=None, max_length=128)
|
||||
default_issuer_name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
default_template_code: str | None = Field(default=None, min_length=1, max_length=32)
|
||||
status: str | None = Field(default=None, pattern="^(active|disabled)$")
|
||||
|
||||
@field_validator("default_certificate_name", "default_course_name", "default_stage_name")
|
||||
@@ -50,6 +52,7 @@ class ProjectCourseOut(BaseModel):
|
||||
default_course_name: str | None
|
||||
default_stage_name: str | None
|
||||
default_issuer_name: str
|
||||
default_template_code: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
61
backend/app/services/certificate_templates.py
Normal file
61
backend/app/services/certificate_templates.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CertificateTemplateDefinition:
|
||||
code: str
|
||||
name: str
|
||||
description: str
|
||||
asset_filename: str
|
||||
dynamic_fields: tuple[str, ...]
|
||||
status: str = "active"
|
||||
|
||||
@property
|
||||
def asset_path(self) -> Path:
|
||||
return Path(__file__).resolve().parents[1] / "assets" / self.asset_filename
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"code": self.code,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"dynamic_fields": list(self.dynamic_fields),
|
||||
"preview_url": f"/api/admin/certificate-templates/{self.code}/preview",
|
||||
"status": self.status,
|
||||
}
|
||||
|
||||
|
||||
CLASSIC_TEMPLATE_CODE = "classic"
|
||||
PRACTICE_CAMP_TEMPLATE_CODE = "practice-camp"
|
||||
|
||||
CERTIFICATE_TEMPLATES = {
|
||||
CLASSIC_TEMPLATE_CODE: CertificateTemplateDefinition(
|
||||
code=CLASSIC_TEMPLATE_CODE,
|
||||
name="经典结业证书",
|
||||
description="通用课程结业证书,正文包含课程名称、阶段、课程时间和发证日期。",
|
||||
asset_filename="certificate-template.png",
|
||||
dynamic_fields=("姓名", "课程名称", "阶段名称", "课程开始日期", "课程结束日期", "发证日期"),
|
||||
),
|
||||
PRACTICE_CAMP_TEMPLATE_CODE: CertificateTemplateDefinition(
|
||||
code=PRACTICE_CAMP_TEMPLATE_CODE,
|
||||
name="实修大本营结业证书",
|
||||
description="人本智慧五个月线上实修大本营专用版,只填写姓名、课程开始日期、课程结束日期和发证日期。",
|
||||
asset_filename="certificate-template-practice-camp.png",
|
||||
dynamic_fields=("姓名", "课程开始日期", "课程结束日期", "发证日期"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def list_certificate_templates() -> list[CertificateTemplateDefinition]:
|
||||
return list(CERTIFICATE_TEMPLATES.values())
|
||||
|
||||
|
||||
def get_certificate_template(code: str | None) -> CertificateTemplateDefinition:
|
||||
normalized_code = (code or CLASSIC_TEMPLATE_CODE).strip().lower()
|
||||
template = CERTIFICATE_TEMPLATES.get(normalized_code)
|
||||
if not template or template.status != "active":
|
||||
raise ValueError(f"未知或已停用的证书模板:{normalized_code}")
|
||||
if not template.asset_path.exists():
|
||||
raise FileNotFoundError(f"证书模板图片不存在:{template.asset_filename}")
|
||||
return template
|
||||
@@ -8,6 +8,11 @@ from PIL import Image, ImageDraw, ImageFont
|
||||
from app.core.config import settings
|
||||
from app.core.paths import data_path
|
||||
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
|
||||
from app.services.certificate_templates import (
|
||||
CLASSIC_TEMPLATE_CODE,
|
||||
PRACTICE_CAMP_TEMPLATE_CODE,
|
||||
get_certificate_template,
|
||||
)
|
||||
|
||||
DESIGN_WIDTH = 1024
|
||||
DESIGN_HEIGHT = 759
|
||||
@@ -70,13 +75,8 @@ def mark_pdf_generated(certificate: Certificate, output_path: Path) -> None:
|
||||
update_pdf_access_time(certificate)
|
||||
|
||||
|
||||
def certificate_template_path() -> Path:
|
||||
assets_dir = Path(__file__).resolve().parents[1] / "assets"
|
||||
for name in ["certificate-template.png", "certificate-template.jpg"]:
|
||||
path = assets_dir / name
|
||||
if path.exists():
|
||||
return path
|
||||
raise FileNotFoundError("Certificate template image not found")
|
||||
def certificate_template_path(template_code: str = CLASSIC_TEMPLATE_CODE) -> Path:
|
||||
return get_certificate_template(template_code).asset_path
|
||||
|
||||
|
||||
def pdf_resolution(image: Image.Image) -> float:
|
||||
@@ -91,7 +91,8 @@ def render_certificate_pdf(
|
||||
concurrency_limit: int = 2,
|
||||
) -> Path:
|
||||
output_path = pdf_cache_path(certificate.certificate_no)
|
||||
template_path = certificate_template_path()
|
||||
template_code = getattr(certificate, "template_code", CLASSIC_TEMPLATE_CODE)
|
||||
template_path = certificate_template_path(template_code)
|
||||
latest_renderer_mtime = max(template_path.stat().st_mtime, Path(__file__).stat().st_mtime)
|
||||
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
|
||||
mark_pdf_generated(certificate, output_path)
|
||||
@@ -112,7 +113,13 @@ def render_certificate_pdf(
|
||||
|
||||
|
||||
def render_certificate_image(certificate: Certificate, learner: Learner, project: ProjectCourse | None) -> Image.Image:
|
||||
template = certificate_template_path()
|
||||
if getattr(certificate, "template_code", CLASSIC_TEMPLATE_CODE) == PRACTICE_CAMP_TEMPLATE_CODE:
|
||||
return render_practice_camp_certificate_image(certificate, learner)
|
||||
return render_classic_certificate_image(certificate, learner, project)
|
||||
|
||||
|
||||
def render_classic_certificate_image(certificate: Certificate, learner: Learner, project: ProjectCourse | None) -> Image.Image:
|
||||
template = certificate_template_path(CLASSIC_TEMPLATE_CODE)
|
||||
image = Image.open(template).convert("RGB")
|
||||
base_image = image.copy()
|
||||
draw = ImageDraw.Draw(image)
|
||||
@@ -174,6 +181,62 @@ def render_certificate_image(certificate: Certificate, learner: Learner, project
|
||||
return image
|
||||
|
||||
|
||||
def render_practice_camp_certificate_image(certificate: Certificate, learner: Learner) -> Image.Image:
|
||||
design_width = 1854
|
||||
design_height = 1359
|
||||
image = Image.open(certificate_template_path(PRACTICE_CAMP_TEMPLATE_CODE)).convert("RGB")
|
||||
draw = ImageDraw.Draw(image)
|
||||
x_scale = image.width / design_width
|
||||
y_scale = image.height / design_height
|
||||
text_scale = (x_scale + y_scale) / 2
|
||||
|
||||
start_year, start_month, _ = date_parts(certificate.course_start_date or certificate.issue_date)
|
||||
end_year, end_month, _ = date_parts(certificate.course_end_date or certificate.issue_date)
|
||||
issue_year, issue_month, issue_day = date_parts(certificate.issue_date)
|
||||
|
||||
draw.text(
|
||||
(568 * x_scale, 542 * y_scale),
|
||||
learner.current_name,
|
||||
fill="#202020",
|
||||
font=font(42, "kai", True, text_scale),
|
||||
anchor="ms",
|
||||
)
|
||||
for value, x in [
|
||||
(start_year, 474),
|
||||
(start_month, 630),
|
||||
(end_year, 888),
|
||||
(end_month, 1058),
|
||||
]:
|
||||
draw.text(
|
||||
(x * x_scale, 668 * y_scale),
|
||||
value,
|
||||
fill="#202020",
|
||||
font=font(41, "song", False, text_scale),
|
||||
anchor="ms",
|
||||
)
|
||||
|
||||
issue_date_text = f"{issue_year}年{issue_month}月{issue_day}日"
|
||||
issue_date_font = font(36, "song", False, text_scale)
|
||||
issue_date_position = (568 * x_scale, 1158 * y_scale)
|
||||
draw.rectangle(
|
||||
(
|
||||
442 * x_scale,
|
||||
1118 * y_scale,
|
||||
696 * x_scale,
|
||||
1168 * y_scale,
|
||||
),
|
||||
fill="#ffffff",
|
||||
)
|
||||
draw.text(
|
||||
issue_date_position,
|
||||
issue_date_text,
|
||||
fill="#202020",
|
||||
font=issue_date_font,
|
||||
anchor="ms",
|
||||
)
|
||||
return image
|
||||
|
||||
|
||||
def font(size: int, kind: str = "song", bold: bool = False, scale: float = 1.0) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
choices = {
|
||||
"kai": [
|
||||
|
||||
Reference in New Issue
Block a user