新增课程起止日期管理
This commit is contained in:
@@ -82,6 +82,8 @@ def create_certificate(
|
||||
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,
|
||||
course_start_date=payload.course_start_date,
|
||||
course_end_date=payload.course_end_date,
|
||||
issue_date=payload.issue_date,
|
||||
issuer_name=payload.issuer_name or project.default_issuer_name,
|
||||
remark=payload.remark,
|
||||
@@ -166,6 +168,8 @@ def _certificate_payload(certificate: Certificate, learner: Learner | None = Non
|
||||
"class_name": certificate.class_name,
|
||||
"course_name": certificate.course_name,
|
||||
"stage_name": certificate.stage_name,
|
||||
"course_start_date": certificate.course_start_date,
|
||||
"course_end_date": certificate.course_end_date,
|
||||
"issue_date": certificate.issue_date,
|
||||
"issuer_name": certificate.issuer_name,
|
||||
"status": certificate.status,
|
||||
@@ -234,6 +238,8 @@ def preview_certificate(
|
||||
"project_code": certificate.project_code,
|
||||
"course_name": certificate.course_name,
|
||||
"stage_name": certificate.stage_name,
|
||||
"course_start_date": certificate.course_start_date.isoformat() if certificate.course_start_date else None,
|
||||
"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,
|
||||
"status": certificate.status,
|
||||
|
||||
@@ -44,6 +44,8 @@ def export_certificates(
|
||||
"证书名称",
|
||||
"课程名称",
|
||||
"阶段名称",
|
||||
"课程开始日期",
|
||||
"课程结束日期",
|
||||
"发证日期",
|
||||
"证书状态",
|
||||
"PDF状态",
|
||||
@@ -60,6 +62,8 @@ def export_certificates(
|
||||
certificate.certificate_name,
|
||||
certificate.course_name,
|
||||
certificate.stage_name,
|
||||
certificate.course_start_date.isoformat() if certificate.course_start_date else "",
|
||||
certificate.course_end_date.isoformat() if certificate.course_end_date else "",
|
||||
certificate.issue_date.isoformat(),
|
||||
certificate.status,
|
||||
certificate.pdf_status,
|
||||
|
||||
@@ -6,6 +6,9 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.comments import Comment
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import require_roles
|
||||
@@ -31,23 +34,24 @@ router = APIRouter()
|
||||
COL_NAME = "\u59d3\u540d"
|
||||
COL_PHONE = "\u624b\u673a\u53f7"
|
||||
COL_PROJECT = "\u9879\u76ee\u4ee3\u7801"
|
||||
COL_COURSE_START_DATE = "课程开始日期"
|
||||
COL_COURSE_END_DATE = "课程结束日期"
|
||||
COL_ISSUE_DATE = "\u53d1\u8bc1\u65e5\u671f"
|
||||
|
||||
TEMPLATE_HEADERS = [
|
||||
COL_NAME,
|
||||
COL_PHONE,
|
||||
COL_PROJECT,
|
||||
COL_COURSE_START_DATE,
|
||||
COL_COURSE_END_DATE,
|
||||
COL_ISSUE_DATE,
|
||||
]
|
||||
REQUIRED_HEADERS = [COL_NAME, COL_PHONE, COL_PROJECT, COL_ISSUE_DATE]
|
||||
REQUIRED_HEADERS = TEMPLATE_HEADERS
|
||||
|
||||
|
||||
@router.get("/template")
|
||||
def download_template(_: AdminUser = Depends(require_roles("system_admin", "certificate_admin"))) -> StreamingResponse:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "\u8bc1\u4e66\u5bfc\u5165\u6a21\u677f"
|
||||
sheet.append(TEMPLATE_HEADERS)
|
||||
workbook = build_import_template_workbook()
|
||||
stream_path = data_path("exports") / "certificate-import-template.xlsx"
|
||||
workbook.save(stream_path)
|
||||
file_handle = stream_path.open("rb")
|
||||
@@ -58,6 +62,16 @@ def download_template(_: AdminUser = Depends(require_roles("system_admin", "cert
|
||||
)
|
||||
|
||||
|
||||
def build_import_template_workbook() -> Workbook:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "\u8bc1\u4e66\u5bfc\u5165\u6a21\u677f"
|
||||
sheet.append(TEMPLATE_HEADERS)
|
||||
_format_template_sheet(sheet)
|
||||
_add_template_instructions(workbook)
|
||||
return workbook
|
||||
|
||||
|
||||
@router.post("", response_model=ImportBatchOut, status_code=status.HTTP_201_CREATED)
|
||||
def upload_import_file(
|
||||
file: UploadFile = File(...),
|
||||
@@ -164,6 +178,8 @@ def confirm_import_batch(
|
||||
for row in rows:
|
||||
row_data = json.loads(row.raw_json or "{}")
|
||||
learner = upsert_learner(db, row_data)
|
||||
course_start_date = parse_date(row_data[COL_COURSE_START_DATE], COL_COURSE_START_DATE)
|
||||
course_end_date = parse_date(row_data[COL_COURSE_END_DATE], COL_COURSE_END_DATE)
|
||||
issue_date = parse_issue_date(row_data[COL_ISSUE_DATE])
|
||||
project_code = str(row_data[COL_PROJECT]).strip().upper()
|
||||
project = db.query(ProjectCourse).filter(ProjectCourse.code == project_code, ProjectCourse.status == "active").first()
|
||||
@@ -172,7 +188,7 @@ 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, issue_date)
|
||||
duplicate = find_duplicate_certificate(db, learner.id, project, course_start_date, course_end_date, issue_date)
|
||||
if duplicate:
|
||||
row.status = "skipped"
|
||||
row.error_message = "\u5df2\u5b58\u5728\uff0c\u65e0\u9700\u5904\u7406"
|
||||
@@ -187,6 +203,8 @@ def confirm_import_batch(
|
||||
certificate_name=project.default_certificate_name,
|
||||
course_name=project.default_course_name,
|
||||
stage_name=project.default_stage_name,
|
||||
course_start_date=course_start_date,
|
||||
course_end_date=course_end_date,
|
||||
issue_date=issue_date,
|
||||
issuer_name=project.default_issuer_name,
|
||||
remark=None,
|
||||
@@ -266,8 +284,14 @@ def row_errors(row_data: dict[str, object], active_codes: set[str]) -> list[str]
|
||||
project_code = str(row_data.get(COL_PROJECT) or "").strip().upper()
|
||||
if project_code and project_code not in active_codes:
|
||||
errors.append("Project code is inactive or missing")
|
||||
if row_data.get(COL_ISSUE_DATE) and not date_is_valid(row_data[COL_ISSUE_DATE]):
|
||||
errors.append("Issue date format is invalid")
|
||||
for column in [COL_COURSE_START_DATE, COL_COURSE_END_DATE, COL_ISSUE_DATE]:
|
||||
if row_data.get(column) and not date_is_valid(row_data[column]):
|
||||
errors.append(f"{column}格式错误,请使用YYYY-MM-DD,例如2026-06-01")
|
||||
if all(row_data.get(column) and date_is_valid(row_data[column]) for column in [COL_COURSE_START_DATE, COL_COURSE_END_DATE]):
|
||||
start_date = parse_date(row_data[COL_COURSE_START_DATE], COL_COURSE_START_DATE)
|
||||
end_date = parse_date(row_data[COL_COURSE_END_DATE], COL_COURSE_END_DATE)
|
||||
if end_date < start_date:
|
||||
errors.append("课程结束日期不能早于课程开始日期")
|
||||
return errors
|
||||
|
||||
|
||||
@@ -295,7 +319,14 @@ def get_active_project(db: Session, project_code: str) -> ProjectCourse:
|
||||
return project
|
||||
|
||||
|
||||
def find_duplicate_certificate(db: Session, learner_id: int, project: ProjectCourse, issue_date: date) -> Certificate | None:
|
||||
def find_duplicate_certificate(
|
||||
db: Session,
|
||||
learner_id: int,
|
||||
project: ProjectCourse,
|
||||
course_start_date: date,
|
||||
course_end_date: date,
|
||||
issue_date: date,
|
||||
) -> Certificate | None:
|
||||
return (
|
||||
db.query(Certificate)
|
||||
.filter(Certificate.learner_id == learner_id)
|
||||
@@ -303,6 +334,8 @@ def find_duplicate_certificate(db: Session, learner_id: int, project: ProjectCou
|
||||
.filter(Certificate.certificate_name == project.default_certificate_name)
|
||||
.filter(Certificate.course_name == project.default_course_name)
|
||||
.filter(Certificate.stage_name == project.default_stage_name)
|
||||
.filter(Certificate.course_start_date == course_start_date)
|
||||
.filter(Certificate.course_end_date == course_end_date)
|
||||
.filter(Certificate.issue_date == issue_date)
|
||||
.first()
|
||||
)
|
||||
@@ -329,6 +362,10 @@ def optional_text(value: object) -> str | None:
|
||||
|
||||
|
||||
def parse_issue_date(value: object) -> date:
|
||||
return parse_date(value, COL_ISSUE_DATE)
|
||||
|
||||
|
||||
def parse_date(value: object, field_name: str = "日期") -> date:
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
@@ -339,7 +376,7 @@ def parse_issue_date(value: object) -> date:
|
||||
return datetime.strptime(text, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid issue date: {text}")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name}格式错误,请使用YYYY-MM-DD:{text}")
|
||||
|
||||
|
||||
def date_is_valid(value: object) -> bool:
|
||||
@@ -361,3 +398,62 @@ def write_error_report(db: Session, batch_id: int) -> Path:
|
||||
report_path = data_path("error-reports") / f"import-errors-{batch_id}.xlsx"
|
||||
workbook.save(report_path)
|
||||
return report_path
|
||||
|
||||
|
||||
def _format_template_sheet(sheet) -> None:
|
||||
header_fill = PatternFill("solid", fgColor="208A87")
|
||||
for cell in sheet[1]:
|
||||
cell.fill = header_fill
|
||||
cell.font = Font(color="FFFFFF", bold=True)
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
sheet.freeze_panes = "A2"
|
||||
sheet.auto_filter.ref = f"A1:{sheet.cell(1, len(TEMPLATE_HEADERS)).coordinate}"
|
||||
widths = [16, 18, 16, 18, 18, 18]
|
||||
for index, width in enumerate(widths, start=1):
|
||||
sheet.column_dimensions[sheet.cell(1, index).column_letter].width = width
|
||||
sheet.column_dimensions["B"].number_format = "@"
|
||||
for column_index in [4, 5, 6]:
|
||||
column_letter = sheet.cell(1, column_index).column_letter
|
||||
sheet.column_dimensions[column_letter].number_format = "yyyy-mm-dd"
|
||||
validation = DataValidation(type="date", operator="between", formula1="DATE(2000,1,1)", formula2="DATE(2100,12,31)", allow_blank=False)
|
||||
validation.promptTitle = "日期格式"
|
||||
validation.prompt = "请按 YYYY-MM-DD 填写,例如 2026-06-01"
|
||||
validation.errorTitle = "日期格式错误"
|
||||
validation.error = "请填写 2000-01-01 至 2100-12-31 之间的有效日期"
|
||||
validation.errorStyle = "stop"
|
||||
validation.showInputMessage = True
|
||||
validation.showErrorMessage = True
|
||||
sheet.add_data_validation(validation)
|
||||
validation.add(f"{column_letter}2:{column_letter}5000")
|
||||
sheet.cell(1, column_index).comment = Comment("必填。请使用 YYYY-MM-DD 格式,例如 2026-06-01。", "证书管理系统")
|
||||
|
||||
|
||||
def _add_template_instructions(workbook: Workbook) -> None:
|
||||
sheet = workbook.create_sheet("填写说明")
|
||||
sheet.append(["字段", "是否必填", "格式或示例", "填写说明"])
|
||||
rows = [
|
||||
(COL_NAME, "是", "张三", "填写学员真实姓名"),
|
||||
(COL_PHONE, "是", "13800000000", "建议将单元格设为文本,避免手机号格式变化"),
|
||||
(COL_PROJECT, "是", "DBY", "填写系统中已启用的项目代码"),
|
||||
(COL_COURSE_START_DATE, "是", "2026-06-01", "课程实际开始日期,必须使用 YYYY-MM-DD"),
|
||||
(COL_COURSE_END_DATE, "是", "2026-06-30", "不得早于课程开始日期,必须使用 YYYY-MM-DD"),
|
||||
(COL_ISSUE_DATE, "是", "2026-07-05", "证书签发日期,必须使用 YYYY-MM-DD"),
|
||||
]
|
||||
for row in rows:
|
||||
sheet.append(row)
|
||||
sheet.append([])
|
||||
sheet.append(["重要提示", "请在“证书导入模板”工作表填写正式数据,不要修改第一行列名。日期统一填写为 YYYY-MM-DD,例如 2026-06-01。"])
|
||||
sheet.merge_cells(start_row=9, start_column=2, end_row=9, end_column=4)
|
||||
for cell in sheet[1]:
|
||||
cell.fill = PatternFill("solid", fgColor="208A87")
|
||||
cell.font = Font(color="FFFFFF", bold=True)
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
sheet["A9"].font = Font(color="C00000", bold=True)
|
||||
sheet["B9"].font = Font(color="C00000", bold=True)
|
||||
sheet["B9"].alignment = Alignment(wrap_text=True, vertical="center")
|
||||
sheet.row_dimensions[9].height = 34
|
||||
sheet.column_dimensions["A"].width = 20
|
||||
sheet.column_dimensions["B"].width = 16
|
||||
sheet.column_dimensions["C"].width = 22
|
||||
sheet.column_dimensions["D"].width = 54
|
||||
sheet.freeze_panes = "A2"
|
||||
|
||||
@@ -176,6 +176,8 @@ def public_certificate_payload(db: Session, certificate: Certificate, learner: L
|
||||
"project_code": certificate.project_code,
|
||||
"course_name": certificate.course_name,
|
||||
"stage_name": certificate.stage_name,
|
||||
"course_start_date": certificate.course_start_date.isoformat() if certificate.course_start_date else None,
|
||||
"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,
|
||||
"status": certificate.status,
|
||||
|
||||
@@ -33,6 +33,8 @@ class Certificate(Base):
|
||||
class_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
course_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
stage_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
course_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
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))
|
||||
status: Mapped[str] = mapped_column(String(32), default="valid")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, ValidationInfo, field_validator
|
||||
|
||||
|
||||
class CertificateCreate(BaseModel):
|
||||
@@ -10,6 +10,8 @@ class CertificateCreate(BaseModel):
|
||||
class_name: str | None = Field(default=None, max_length=128)
|
||||
course_name: str | None = Field(default=None, max_length=128)
|
||||
stage_name: str | None = Field(default=None, max_length=128)
|
||||
course_start_date: date
|
||||
course_end_date: date
|
||||
issue_date: date
|
||||
issuer_name: str = Field(min_length=1, max_length=128)
|
||||
remark: str | None = None
|
||||
@@ -19,6 +21,14 @@ class CertificateCreate(BaseModel):
|
||||
def normalize_project_code(cls, value: str) -> str:
|
||||
return value.strip().upper()
|
||||
|
||||
@field_validator("course_end_date")
|
||||
@classmethod
|
||||
def validate_course_period(cls, value: date, info: ValidationInfo) -> date:
|
||||
start_date = info.data.get("course_start_date")
|
||||
if start_date and value < start_date:
|
||||
raise ValueError("课程结束日期不能早于课程开始日期")
|
||||
return value
|
||||
|
||||
|
||||
class CertificateOut(BaseModel):
|
||||
id: int
|
||||
@@ -31,6 +41,8 @@ class CertificateOut(BaseModel):
|
||||
class_name: str | None
|
||||
course_name: str | None
|
||||
stage_name: str | None
|
||||
course_start_date: date | None
|
||||
course_end_date: date | None
|
||||
issue_date: date
|
||||
issuer_name: str
|
||||
status: str
|
||||
|
||||
@@ -121,6 +121,8 @@ def render_certificate_image(certificate: Certificate, learner: Learner, project
|
||||
text_scale = (x_scale + y_scale) / 2
|
||||
|
||||
issue_year, issue_month, issue_day = date_parts(certificate.issue_date)
|
||||
start_year, start_month, start_day = date_parts(certificate.course_start_date or certificate.issue_date)
|
||||
end_year, end_month, end_day = date_parts(certificate.course_end_date or certificate.issue_date)
|
||||
course_name = certificate.course_name or certificate.certificate_name or (project.name if project else certificate.project_code)
|
||||
stage_input = (certificate.stage_name or "").strip()
|
||||
stage_name = f"{stage_input}\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002" if stage_input else "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
|
||||
@@ -138,17 +140,17 @@ def render_certificate_image(certificate: Certificate, learner: Learner, project
|
||||
x = 185.0 * x_scale
|
||||
y = 494 * y_scale
|
||||
x = draw_inline(draw, x, y, "\u5728", 24, 18, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, start_year, 24, 8, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, start_month, 24, 6, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, start_day, 24, 4, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, end_year, 24, 8, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, end_month, 24, 6, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, end_day, 24, 4, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8, scale=text_scale, x_scale=x_scale)
|
||||
draw_inline_flow(
|
||||
draw,
|
||||
|
||||
Reference in New Issue
Block a user