新增多证书模板管理流程
This commit is contained in:
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