Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""add president terms and signatures

Adds the 회장(president) domain: president_terms (tracks who is the 현직
회장 with an at-most-one-open-term invariant enforced at the DB level via a
MySQL 8 generated column + unique index) and certificate_signatures (one
signature image per president, used by the activity-certificate feature).

Revision ID: d1e2f3a4b5c6
Revises: a1b2c3d4e5f6
Create Date: 2026-07-18 00:00:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa
from sqlalchemy.dialects import mysql

from alembic import op

revision: str = "d1e2f3a4b5c6"
down_revision: Union[str, None] = "a1b2c3d4e5f6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"president_terms",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("started_at", sa.Date(), nullable=False),
sa.Column("ended_at", sa.Date(), nullable=True),
# 현직 회장 불변식(열린 임기는 최대 1개)을 DB 레벨에서 강제하는 생성
# 컬럼. ended_at이 NULL이면 1, 아니면 NULL 이 되고, MySQL UNIQUE
# 인덱스는 NULL끼리 충돌하지 않으므로 종료된 임기는 여러 개 허용되지만
# 열린 임기는 uq_president_current 유니크 인덱스에 의해 하나만
# 허용된다.
sa.Column(
"is_current",
mysql.TINYINT(),
sa.Computed("IF(ended_at IS NULL, 1, NULL)", persisted=False),
nullable=True,
),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("is_current", name="uq_president_current"),
)
op.create_index(
"idx_president_terms_user_id", "president_terms", ["user_id"], unique=False
)

op.create_table(
"certificate_signatures",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("object_key", sa.String(length=500), nullable=False),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", name="uq_certificate_signatures_user_id"),
)


def downgrade() -> None:
op.drop_table("certificate_signatures")

# NOTE: idx_president_terms_user_id is the *only* index covering
# president_terms' FK column, so MySQL rejects an explicit DROP INDEX on
# it while the FK constraint is still attached (error 1553). drop_table
# removes a table's FKs and indexes together, so it is intentionally
# left for drop_table to clean up rather than dropped explicitly first.
op.drop_table("president_terms")
2 changes: 2 additions & 0 deletions app/deps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
get_current_user,
require_admin,
require_associate,
require_president,
require_regular,
)
from app.deps.project import require_leader_or_admin
Expand All @@ -11,5 +12,6 @@
"require_associate",
"require_regular",
"require_admin",
"require_president",
"require_leader_or_admin",
]
22 changes: 22 additions & 0 deletions app/deps/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
from app.config.cookies import ACCESS_TOKEN_COOKIE_NAME
from app.config.database import get_db
from app.config.secrets import JWT_SECRET_KEY
from app.exceptions import NotPresidentError
from app.models import Qualification, User
from app.services import UserService
from app.services.certificate import PresidentService

JWT_ALGORITHM = "HS256"

Expand Down Expand Up @@ -101,3 +103,23 @@ async def require_admin(user: User = Depends(get_current_user)) -> User:
status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required"
)
return user


async def require_president(
user: User = Depends(get_current_user), db: Session = Depends(get_db)
) -> User:
"""
현직 회장만 접근 가능.

현직 회장 = `president_terms`에서 ended_at IS NULL인 임기의 user. 관리자
권한(is_admin)만으로는 충분하지 않다. 이 파일의 기존 가드들과 동일하게
HTTPException을 사용한다(메시지는 `NotPresidentError`의 한국어 메시지를
재사용해 단일 출처를 유지한다).
"""
term = PresidentService.get_current(db)
if term is None or term.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=NotPresidentError().message,
)
return user
49 changes: 49 additions & 0 deletions app/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,52 @@ class ObjectStorageError(AppError):

def __init__(self, message: str = "Object storage operation failed"):
super().__init__("OBJECT_STORAGE_ERROR", message, 502)


# === Certificate of activities (활동증명서) — president / signature ===
class NotPresidentError(AppError):
"""403 - Action requires the current president"""

def __init__(self, message: str = "회장만 수행할 수 있는 기능입니다."):
super().__init__("NOT_PRESIDENT", message, 403)


class InvalidSignatureFileError(AppError):
"""400 - Uploaded signature is not a valid PNG/JPEG/WebP/GIF image"""

def __init__(self, message: str = "이미지 파일(PNG, JPG, WEBP, GIF)이 맞는지 확인해주세요."):
super().__init__("INVALID_SIGNATURE_FILE", message, 400)


class SignatureFileTooLargeError(AppError):
"""413 - Uploaded signature exceeds the maximum byte size"""

def __init__(self, message: str = "파일 용량이 너무 큽니다. (최대 5MB)"):
super().__init__("SIGNATURE_FILE_TOO_LARGE", message, 413)


class PresidentAppointmentConflictError(AppError):
"""409 - Lost a race with another concurrent president appointment"""

def __init__(self, message: str = "다른 회장 임명 요청과 충돌했습니다. 다시 시도해주세요."):
super().__init__("PRESIDENT_APPOINTMENT_CONFLICT", message, 409)


class InvalidPresidentTermError(AppError):
"""400 - New president term's started_at is invalid (before the current
term's start date, or in the future -- appointment takes effect
immediately, so a future-dated started_at would grant/revoke
`require_president` access ahead of the intended date)"""

def __init__(
self,
message: str = "새 임기 시작일은 현직 회장의 임기 시작일보다 빠를 수 없습니다.",
):
super().__init__("INVALID_PRESIDENT_TERM", message, 400)


class SignatureUploadConflictError(AppError):
"""409 - Lost a race with another concurrent signature upload/replace"""

def __init__(self, message: str = "다른 서명 등록 요청과 충돌했습니다. 다시 시도해주세요."):
super().__init__("SIGNATURE_UPLOAD_CONFLICT", message, 409)
3 changes: 2 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ async def app_error_handler(request: Request, exc: AppError):
# ==============================
# ROUTERS
# ==============================
from app.routes import auth, profile_image, projects, requests, users
from app.routes import auth, certificates, profile_image, projects, requests, users

app.include_router(auth.router, prefix="/auth", tags=["Auth"])
app.include_router(users.router, prefix="/users", tags=["Users"])
Expand All @@ -84,6 +84,7 @@ async def app_error_handler(request: Request, exc: AppError):
app.include_router(
profile_image.router, prefix="/profile-image", tags=["Profile Image"]
)
app.include_router(certificates.router, prefix="/certificates", tags=["Certificates"])

# Dev-only auth endpoints (only included in local/dev environments)
if ENV in ("local", "dev"):
Expand Down
4 changes: 4 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from app.models.audit_log import AuditLog
from app.models.certificate import CertificateSignature
from app.models.enums import (
ActivityStatus,
ApprovalStatus,
Expand All @@ -9,6 +10,7 @@
Qualification,
UserRole,
)
from app.models.president import PresidentTerm
from app.models.project import Project
from app.models.project_member import ProjectMember
from app.models.request import ApprovalRequest, RequestReviewer
Expand All @@ -31,4 +33,6 @@
"ProjectMember",
"ApprovalRequest",
"RequestReviewer",
"PresidentTerm",
"CertificateSignature",
]
23 changes: 23 additions & 0 deletions app/models/certificate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from sqlalchemy import Column, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship

from app.config.database import Base
from app.models.base import TimestampMixin


class CertificateSignature(Base, TimestampMixin):
"""회장 서명 이미지(PNG). 회장 1인당 서명 1개만 등록 가능."""

__tablename__ = "certificate_signatures"

id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
object_key = Column(String(500), nullable=False)

user = relationship("User", foreign_keys=[user_id])

__table_args__ = (
UniqueConstraint("user_id", name="uq_certificate_signatures_user_id"),
)
48 changes: 48 additions & 0 deletions app/models/president.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from sqlalchemy import (
Column,
Computed,
Date,
ForeignKey,
Index,
Integer,
UniqueConstraint,
)
from sqlalchemy.dialects.mysql import TINYINT
from sqlalchemy.orm import relationship

from app.config.database import Base
from app.models.base import TimestampMixin


class PresidentTerm(Base, TimestampMixin):
"""회장 임기.

현직 회장 = ended_at IS NULL인 행. 동시에 두 개 이상의 임기가 열려 있으면
안 되므로, MySQL 8 생성 컬럼(is_current)과 UNIQUE 인덱스로 이 불변식을 DB
레벨에서 강제한다: ended_at이 NULL이면 is_current=1, 그렇지 않으면 NULL이
되고 MySQL의 UNIQUE 인덱스는 NULL끼리 충돌하지 않으므로 종료된 임기는 여러
개 허용되지만 열린 임기는 최대 하나만 허용된다.
"""

__tablename__ = "president_terms"

id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)

started_at = Column(Date, nullable=False)
ended_at = Column(Date, nullable=True)

is_current = Column(
TINYINT,
Computed("IF(ended_at IS NULL, 1, NULL)", persisted=False),
nullable=True,
)

user = relationship("User", foreign_keys=[user_id])

__table_args__ = (
Index("idx_president_terms_user_id", "user_id"),
UniqueConstraint("is_current", name="uq_president_current"),
)
4 changes: 2 additions & 2 deletions app/routes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from app.routes import auth, profile_image, projects, users
from app.routes import auth, certificates, profile_image, projects, users

__all__ = ["auth", "users", "projects", "profile_image"]
__all__ = ["auth", "users", "projects", "profile_image", "certificates"]
Loading
Loading