Skip to content

Commit 38aba24

Browse files
committed
feat: 활동증명서 렌더링(PDF) + 운영진 초안 생성
1 parent 0be4be5 commit 38aba24

20 files changed

Lines changed: 2234 additions & 33 deletions

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,9 @@ OCI_PUBLIC_BASE_URL=
3434
# AWS Secrets Manager (for dev/prod environments)
3535
# AWS_SECRET_NAME=waffice-db-credentials
3636
# AWS_REGION=ap-northeast-2
37+
38+
# Certificate of activities (활동증명서)
39+
# Base URL used to build the 원본 확인(verification) link embedded in issued PDFs:
40+
# "{CERT_VERIFY_BASE_URL}/verify/{issue_number}". The /verify endpoint itself is
41+
# out of scope for this PR; only the link is rendered.
42+
CERT_VERIFY_BASE_URL=https://waffice.wafflestudio.com

.github/workflows/ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ jobs:
1919
- name: Install uv
2020
uses: astral-sh/setup-uv@v4
2121

22+
- name: Install WeasyPrint system dependencies
23+
run: |
24+
sudo apt-get update
25+
sudo apt-get install -y --no-install-recommends \
26+
libpango-1.0-0 \
27+
libpangoft2-1.0-0 \
28+
libcairo2 \
29+
libgdk-pixbuf-2.0-0 \
30+
libffi8 \
31+
shared-mime-info \
32+
fonts-noto-cjk
33+
2234
- name: Install dependencies
2335
run: uv sync --dev
2436

Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ FROM python:3.11-slim
2121
# Set working directory
2222
WORKDIR /app
2323

24+
# System libraries required by WeasyPrint (활동증명서 PDF 렌더링) at runtime,
25+
# plus a Korean-capable font so Korean text in the PDF doesn't render as tofu.
26+
RUN apt-get update \
27+
&& apt-get install -y --no-install-recommends \
28+
libpango-1.0-0 \
29+
libpangoft2-1.0-0 \
30+
libcairo2 \
31+
libgdk-pixbuf-2.0-0 \
32+
libffi8 \
33+
shared-mime-info \
34+
fonts-noto-cjk \
35+
&& rm -rf /var/lib/apt/lists/*
36+
2437
# Install uv in runtime (needed to run the app)
2538
RUN pip install --no-cache-dir uv
2639

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""add certificates and events
2+
3+
Adds the certificates (the issuance/draft records) and certificate_events
4+
(append-only processing history) tables. This builds on top of the
5+
president_terms/certificate_signatures tables added by
6+
d1e2f3a4b5c6_add_president_terms_and_signatures.py.
7+
8+
Revision ID: e2f3a4b5c6d7
9+
Revises: d1e2f3a4b5c6
10+
Create Date: 2026-07-19 00:00:00.000000
11+
12+
"""
13+
14+
from typing import Sequence, Union
15+
16+
import sqlalchemy as sa
17+
from sqlalchemy.dialects import mysql
18+
19+
from alembic import op
20+
21+
revision: str = "e2f3a4b5c6d7"
22+
down_revision: Union[str, None] = "d1e2f3a4b5c6"
23+
branch_labels: Union[str, Sequence[str], None] = None
24+
depends_on: Union[str, Sequence[str], None] = None
25+
26+
27+
def upgrade() -> None:
28+
op.create_table(
29+
"certificates",
30+
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
31+
sa.Column("user_id", sa.Integer(), nullable=False),
32+
sa.Column("requested_by_id", sa.Integer(), nullable=True),
33+
sa.Column(
34+
"kind",
35+
sa.Enum("SELF", "DRAFT", name="certificatekind"),
36+
nullable=False,
37+
),
38+
sa.Column(
39+
"status",
40+
sa.Enum("ISSUED", "ORIGINAL_PENDING", name="certificatestatus"),
41+
nullable=False,
42+
),
43+
# 운영진용 발급 이력(list_history) 정렬 우선순위 생성 컬럼(ORIGINAL_
44+
# PENDING -> 1, 아니면 0). president_terms.is_current와 같은 패턴.
45+
sa.Column(
46+
"pending_priority",
47+
mysql.TINYINT(),
48+
sa.Computed("IF(status = 'ORIGINAL_PENDING', 1, 0)", persisted=False),
49+
nullable=False,
50+
),
51+
sa.Column("issue_number", sa.CHAR(length=36), nullable=True),
52+
sa.Column("verification_token_hash", sa.String(length=64), nullable=True),
53+
sa.Column("options", sa.JSON(), nullable=False),
54+
sa.Column("snapshot", sa.JSON(), nullable=True),
55+
sa.Column("pdf_object_key", sa.String(length=500), nullable=True),
56+
sa.Column("issued_at", sa.BigInteger(), nullable=True),
57+
sa.Column("expires_at", sa.BigInteger(), nullable=True),
58+
sa.Column("created_at", sa.BigInteger(), nullable=False),
59+
sa.Column("updated_at", sa.BigInteger(), nullable=False),
60+
sa.Column("deleted_at", sa.BigInteger(), nullable=True),
61+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
62+
sa.ForeignKeyConstraint(["requested_by_id"], ["users.id"], ondelete="SET NULL"),
63+
sa.PrimaryKeyConstraint("id"),
64+
sa.UniqueConstraint("issue_number", name="uq_certificates_issue_number"),
65+
)
66+
# list_own의 WHERE user_id = ? ORDER BY created_at DESC, id DESC (+ 동일
67+
# 형태의 커서 술어)를 그대로 커버한다. user_id가 최좌측 컬럼이므로
68+
# user_id FK가 요구하는 커버 인덱스 역할도 대신한다.
69+
op.create_index(
70+
"idx_certificates_user_created_id",
71+
"certificates",
72+
["user_id", "created_at", "id"],
73+
unique=False,
74+
)
75+
# list_history의 ORDER BY pending_priority DESC, created_at DESC, id DESC
76+
# (+ 동일 형태의 커서 술어)를 그대로 커버한다.
77+
op.create_index(
78+
"idx_certificates_history_priority",
79+
"certificates",
80+
["pending_priority", "created_at", "id"],
81+
unique=False,
82+
)
83+
op.create_index(
84+
"idx_certificates_created_at", "certificates", ["created_at"], unique=False
85+
)
86+
87+
op.create_table(
88+
"certificate_events",
89+
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
90+
sa.Column("certificate_id", sa.Integer(), nullable=False),
91+
sa.Column(
92+
"action",
93+
sa.Enum(
94+
"APPLIED",
95+
"ISSUED",
96+
"DRAFT_CREATED",
97+
"ORIGINAL_REGISTERED",
98+
name="certificateeventaction",
99+
),
100+
nullable=False,
101+
),
102+
sa.Column(
103+
"actor_type",
104+
sa.Enum(
105+
"APPLICANT",
106+
"SYSTEM",
107+
"PRESIDENT",
108+
"ADMIN",
109+
name="certificateactortype",
110+
),
111+
nullable=False,
112+
),
113+
sa.Column("actor_id", sa.Integer(), nullable=True),
114+
sa.Column("created_at", sa.BigInteger(), nullable=False),
115+
sa.ForeignKeyConstraint(
116+
["certificate_id"], ["certificates.id"], ondelete="CASCADE"
117+
),
118+
sa.ForeignKeyConstraint(["actor_id"], ["users.id"], ondelete="SET NULL"),
119+
sa.PrimaryKeyConstraint("id"),
120+
)
121+
op.create_index(
122+
"idx_certificate_events_certificate_created",
123+
"certificate_events",
124+
["certificate_id", "created_at"],
125+
unique=False,
126+
)
127+
128+
129+
def downgrade() -> None:
130+
# NOTE: idx_certificate_events_certificate_created and
131+
# idx_certificates_user_created_id (user_id가 최좌측 컬럼이므로 여전히
132+
# user_id FK를 커버한다) are each the *only* index covering their table's
133+
# FK column, so MySQL rejects an explicit DROP INDEX on them while the FK
134+
# constraint is still attached (error 1553). drop_table removes a table's
135+
# FKs and indexes together, so those two are intentionally left for
136+
# drop_table to clean up rather than dropped explicitly first.
137+
op.drop_table("certificate_events")
138+
139+
op.drop_index("idx_certificates_created_at", table_name="certificates")
140+
op.drop_index("idx_certificates_history_priority", table_name="certificates")
141+
op.drop_table("certificates")

app/deps/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
get_current_user,
33
require_admin,
44
require_associate,
5+
require_certificate_eligible,
56
require_president,
67
require_regular,
78
)
@@ -12,6 +13,7 @@
1213
"require_associate",
1314
"require_regular",
1415
"require_admin",
16+
"require_certificate_eligible",
1517
"require_president",
1618
"require_leader_or_admin",
1719
]

app/deps/auth.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from app.config.cookies import ACCESS_TOKEN_COOKIE_NAME
77
from app.config.database import get_db
88
from app.config.secrets import JWT_SECRET_KEY
9-
from app.exceptions import NotPresidentError
9+
from app.exceptions import AssociateCannotIssueCertificateError, NotPresidentError
1010
from app.models import Qualification, User
1111
from app.services import UserService
1212
from app.services.certificate import PresidentService
@@ -105,6 +105,23 @@ async def require_admin(user: User = Depends(get_current_user)) -> User:
105105
return user
106106

107107

108+
async def require_certificate_eligible(
109+
user: User = Depends(get_current_user),
110+
) -> User:
111+
"""
112+
활동증명서 발급 대상 자격 확인: 정회원/활동회원만 가능.
113+
114+
준회원(이하)은 `ASSOCIATE_CANNOT_ISSUE_CERTIFICATE`(403, AppError)로 거부한다.
115+
기존 `require_regular`는 영어 HTTPException을 던지지만, 활동증명서 API는
116+
명세가 요구하는 전용 에러 코드/한국어 메시지를 응답 바디에 실어야 하므로
117+
(`{"ok": false, "error": "ASSOCIATE_CANNOT_ISSUE_CERTIFICATE", ...}`)
118+
`app.exceptions.AppError`를 그대로 raise한다.
119+
"""
120+
if user.qualification not in (Qualification.REGULAR, Qualification.ACTIVE):
121+
raise AssociateCannotIssueCertificateError()
122+
return user
123+
124+
108125
async def require_president(
109126
user: User = Depends(get_current_user), db: Session = Depends(get_db)
110127
) -> User:

app/exceptions.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,41 @@ class SignatureUploadConflictError(AppError):
225225

226226
def __init__(self, message: str = "다른 서명 등록 요청과 충돌했습니다. 다시 시도해주세요."):
227227
super().__init__("SIGNATURE_UPLOAD_CONFLICT", message, 409)
228+
229+
230+
class AssociateCannotIssueCertificateError(AppError):
231+
"""403 - Associate-level members cannot issue certificates of activities"""
232+
233+
def __init__(self, message: str = "준회원은 활동증명서를 발급받을 수 없습니다."):
234+
super().__init__("ASSOCIATE_CANNOT_ISSUE_CERTIFICATE", message, 403)
235+
236+
237+
class PresidentNotFoundError(AppError):
238+
"""409 - No current president is registered"""
239+
240+
def __init__(self, message: str = "현재 등록된 회장이 없습니다. 운영팀에 문의해주세요."):
241+
super().__init__("PRESIDENT_NOT_FOUND", message, 409)
242+
243+
244+
class PresidentSignatureNotFoundError(AppError):
245+
"""409 - The current president has no registered signature"""
246+
247+
def __init__(
248+
self,
249+
message: str = "회장의 서명을 찾을 수 없습니다. 운영팀에 에러를 신고해주세요!",
250+
):
251+
super().__init__("PRESIDENT_SIGNATURE_NOT_FOUND", message, 409)
252+
253+
254+
class InvalidCertificateOptionsError(AppError):
255+
"""400 - Certificate options failed validation (message varies by cause)"""
256+
257+
def __init__(self, message: str = "발급 옵션이 올바르지 않습니다."):
258+
super().__init__("INVALID_CERTIFICATE_OPTIONS", message, 400)
259+
260+
261+
class CertificateRenderFailedError(AppError):
262+
"""502 - PDF rendering failed"""
263+
264+
def __init__(self, message: str = "활동증명서 생성에 실패했습니다."):
265+
super().__init__("CERTIFICATE_RENDER_FAILED", message, 502)

app/models/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
from app.models.audit_log import AuditLog
2-
from app.models.certificate import CertificateSignature
2+
from app.models.certificate import Certificate, CertificateEvent, CertificateSignature
33
from app.models.enums import (
44
ActivityStatus,
55
ApprovalStatus,
66
AuditAction,
7+
CertificateActorType,
8+
CertificateEventAction,
9+
CertificateKind,
10+
CertificateSigner,
11+
CertificateStatus,
712
GraduationStatus,
813
MemberRole,
914
ProjectStatus,
@@ -35,4 +40,11 @@
3540
"RequestReviewer",
3641
"PresidentTerm",
3742
"CertificateSignature",
43+
"Certificate",
44+
"CertificateEvent",
45+
"CertificateKind",
46+
"CertificateStatus",
47+
"CertificateEventAction",
48+
"CertificateActorType",
49+
"CertificateSigner",
3850
]

0 commit comments

Comments
 (0)