|
| 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") |
0 commit comments