diff --git a/alembic/versions/d1e2f3a4b5c6_add_president_terms_and_signatures.py b/alembic/versions/d1e2f3a4b5c6_add_president_terms_and_signatures.py new file mode 100644 index 0000000..b51c3f8 --- /dev/null +++ b/alembic/versions/d1e2f3a4b5c6_add_president_terms_and_signatures.py @@ -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") diff --git a/app/deps/__init__.py b/app/deps/__init__.py index d2a61e9..4f2d6a1 100644 --- a/app/deps/__init__.py +++ b/app/deps/__init__.py @@ -2,6 +2,7 @@ get_current_user, require_admin, require_associate, + require_president, require_regular, ) from app.deps.project import require_leader_or_admin @@ -11,5 +12,6 @@ "require_associate", "require_regular", "require_admin", + "require_president", "require_leader_or_admin", ] diff --git a/app/deps/auth.py b/app/deps/auth.py index 6ee37b1..be91d44 100644 --- a/app/deps/auth.py +++ b/app/deps/auth.py @@ -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" @@ -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 diff --git a/app/exceptions.py b/app/exceptions.py index 1c35e19..7f82575 100644 --- a/app/exceptions.py +++ b/app/exceptions.py @@ -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) diff --git a/app/main.py b/app/main.py index be994a2..6e87d3b 100644 --- a/app/main.py +++ b/app/main.py @@ -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"]) @@ -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"): diff --git a/app/models/__init__.py b/app/models/__init__.py index d580bf1..f99ec57 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,4 +1,5 @@ from app.models.audit_log import AuditLog +from app.models.certificate import CertificateSignature from app.models.enums import ( ActivityStatus, ApprovalStatus, @@ -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 @@ -31,4 +33,6 @@ "ProjectMember", "ApprovalRequest", "RequestReviewer", + "PresidentTerm", + "CertificateSignature", ] diff --git a/app/models/certificate.py b/app/models/certificate.py new file mode 100644 index 0000000..48f70f2 --- /dev/null +++ b/app/models/certificate.py @@ -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"), + ) diff --git a/app/models/president.py b/app/models/president.py new file mode 100644 index 0000000..725badb --- /dev/null +++ b/app/models/president.py @@ -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"), + ) diff --git a/app/routes/__init__.py b/app/routes/__init__.py index dd2832e..16c5218 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -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"] diff --git a/app/routes/certificates.py b/app/routes/certificates.py new file mode 100644 index 0000000..16d1ba4 --- /dev/null +++ b/app/routes/certificates.py @@ -0,0 +1,147 @@ +"""회장(president) / 서명(signature) API. + +- President: 서명 등록/조회 (require_president). +- Staff/admin: 회장 임기 관리 (require_admin). + +라우트 등록 순서 주의: 같은 HTTP 메서드에서 리터럴 세그먼트 경로(`/signature/me`, +`/president-terms/current`, ...)는 반드시 가변 경로(`/{certificate_id}`)보다 +먼저 등록해야 Starlette가 잘못 매칭하지 않는다. (이 축소판에는 가변 경로가 +없지만, 향후 활동증명서 라우트가 합류할 때를 대비해 순서를 그대로 유지한다.) +""" + +from collections.abc import Callable +from uuid import uuid4 + +from fastapi import APIRouter, Depends, File, UploadFile +from sqlalchemy.orm import Session + +from app.config.database import get_db +from app.deps.auth import require_admin, require_president +from app.exceptions import InvalidSignatureFileError, SignatureFileTooLargeError +from app.models import User +from app.schemas import ( + PresidentTermCreate, + PresidentTermDetail, + Response, + SignatureDetail, +) +from app.services import OCIObjectStorageService, PresidentService, SignatureService + +router = APIRouter() + +MAX_SIGNATURE_FILE_SIZE = 5 * 1024 * 1024 + + +def _is_png(body: bytes) -> bool: + return body.startswith(b"\x89PNG\r\n\x1a\n") + + +def _is_jpeg(body: bytes) -> bool: + return body.startswith(b"\xff\xd8\xff") + + +def _is_webp(body: bytes) -> bool: + return len(body) >= 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP" + + +def _is_gif(body: bytes) -> bool: + return body.startswith(b"GIF87a") or body.startswith(b"GIF89a") + + +# 회장 서명으로 허용하는 이미지 포맷: content-type -> (magic-byte 검사 함수, 확장자). +# SVG는 스크립트/외부 참조를 품을 수 있어 향후 렌더러 보안상 제외한다. +SIGNATURE_IMAGE_TYPES: dict[str, tuple[Callable[[bytes], bool], str]] = { + "image/png": (_is_png, ".png"), + "image/jpeg": (_is_jpeg, ".jpg"), + "image/jpg": (_is_jpeg, ".jpg"), + "image/webp": (_is_webp, ".webp"), + "image/gif": (_is_gif, ".gif"), +} + + +# ===================================================================== +# President +# ===================================================================== +@router.get( + "/signature/me", + response_model=Response[SignatureDetail | None], + summary="내 서명 조회", + description="현직 회장이 등록해 둔 자신의 서명 이미지 정보를 조회한다.", +) +async def get_my_signature( + president: User = Depends(require_president), + db: Session = Depends(get_db), +): + signature = SignatureService.get_by_user(db, president.id) + if signature is None: + return Response(ok=True, data=None, message="등록된 서명이 없습니다.") + return Response(ok=True, data=SignatureDetail.model_validate(signature)) + + +@router.put( + "/signature/me", + response_model=Response[SignatureDetail], + summary="내 서명 등록/교체", + description="현직 회장이 서명 이미지(PNG, JPG, WEBP, GIF)를 등록하거나 기존 서명을 교체한다. 투명 배경 PNG를 권장한다 — JPEG는 배경이 불투명한 흰색 사각형으로 채워져 증명서의 성명 글자를 가릴 수 있다.", +) +async def upsert_my_signature( + file: UploadFile = File(...), + president: User = Depends(require_president), + db: Session = Depends(get_db), +): + if file.content_type not in SIGNATURE_IMAGE_TYPES: + raise InvalidSignatureFileError() + + body = await file.read() + if len(body) > MAX_SIGNATURE_FILE_SIZE: + raise SignatureFileTooLargeError() + + is_valid_magic, ext = SIGNATURE_IMAGE_TYPES[file.content_type] + if not is_valid_magic(body): + raise InvalidSignatureFileError() + + storage = OCIObjectStorageService() + object_key = f"signatures/{president.id}/{uuid4()}{ext}" + storage.upload_bytes(object_key, body, file.content_type) + + signature = SignatureService.upsert( + db, user_id=president.id, object_key=object_key, storage=storage + ) + return Response(ok=True, data=SignatureDetail.model_validate(signature)) + + +# ===================================================================== +# President term administration (admin) +# ===================================================================== +@router.post( + "/president-terms", + response_model=Response[PresidentTermDetail], + summary="회장 임명 (운영진)", + description="새 회장을 임명한다. 기존에 열려 있는 임기가 있으면 같은 트랜잭션에서 자동 종료된다.", +) +async def appoint_president( + request: PresidentTermCreate, + _admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + term = PresidentService.appoint( + db, user_id=request.user_id, started_at=request.started_at + ) + return Response(ok=True, data=PresidentTermDetail.model_validate(term)) + + +@router.get( + "/president-terms/current", + response_model=Response[PresidentTermDetail | None], + summary="현직 회장 조회 (운영진)", + description="현재 열려 있는(ended_at IS NULL) 회장 임기를 조회한다.", +) +async def get_current_president( + _admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + term = PresidentService.get_current(db) + return Response( + ok=True, + data=PresidentTermDetail.model_validate(term) if term else None, + ) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index e30342a..29da91f 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -12,6 +12,11 @@ SigninRequest, Token, ) +from app.schemas.certificate import ( + PresidentTermCreate, + PresidentTermDetail, + SignatureDetail, +) from app.schemas.common import CursorPage, Response, Website from app.schemas.project import ( MemberDetail, @@ -96,4 +101,7 @@ "RequestReviewerDetail", "ApprovalRequestListItem", "ApprovalRequestDetail", + "SignatureDetail", + "PresidentTermCreate", + "PresidentTermDetail", ] diff --git a/app/schemas/certificate.py b/app/schemas/certificate.py new file mode 100644 index 0000000..1be3102 --- /dev/null +++ b/app/schemas/certificate.py @@ -0,0 +1,38 @@ +from datetime import date + +from pydantic import BaseModel, Field + +from app.schemas.user import UserBrief + + +# === Request === +class PresidentTermCreate(BaseModel): + """회장 임명 요청 바디. 기존에 열려 있는 임기가 있으면 자동으로 종료된다.""" + + user_id: int = Field(description="새로 회장으로 임명할 회원 ID") + started_at: date = Field(description="임기 시작일", examples=["2026-01-01"]) + + +# === Response === +class SignatureDetail(BaseModel): + """회장 서명 등록 정보.""" + + id: int = Field(description="서명 등록 ID") + user_id: int = Field(description="회장(사용자) ID") + created_at: int = Field(description="최초 등록 일시 (Unix epoch)") + updated_at: int = Field(description="마지막 교체 일시 (Unix epoch)") + + model_config = {"from_attributes": True} + + +class PresidentTermDetail(BaseModel): + """회장 임기.""" + + id: int = Field(description="임기 ID") + user: UserBrief = Field(description="회장") + started_at: date = Field(description="임기 시작일") + ended_at: date | None = Field(description="임기 종료일. 현직이면 null") + created_at: int = Field(description="레코드 생성 일시 (Unix epoch)") + updated_at: int = Field(description="마지막 수정 일시 (Unix epoch)") + + model_config = {"from_attributes": True} diff --git a/app/services/__init__.py b/app/services/__init__.py index 2aa7701..f20bd60 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -1,5 +1,6 @@ from app.services.activity import ActivityService from app.services.audit_log import AuditLogService +from app.services.certificate import PresidentService, SignatureService from app.services.member import CannotRemoveSelfError, LastLeaderError, MemberService from app.services.object_storage import OCIObjectStorageService from app.services.project import ProjectService @@ -16,4 +17,6 @@ "RequestService", "LastLeaderError", "CannotRemoveSelfError", + "SignatureService", + "PresidentService", ] diff --git a/app/services/certificate.py b/app/services/certificate.py new file mode 100644 index 0000000..b16b585 --- /dev/null +++ b/app/services/certificate.py @@ -0,0 +1,140 @@ +"""회장(president) / 서명(signature) 도메인 서비스. + +`SignatureService` / `PresidentService` 두 클래스로 나뉘며, 모두 +`app/services/user.py`와 같은 스타일로 db 세션을 첫 인자로 받는 +`@staticmethod`로 구성된다. + +오브젝트 스토리지 업로드/다운로드는 라우트에서 만든 `OCIObjectStorageService` +인스턴스를 `storage` 인자로 주입받아 사용한다 (`app/routes/profile_image.py`가 +라우트에서 직접 `OCIObjectStorageService()`를 만드는 것과 같은 관례를 따르며, +테스트가 `app.routes.certificates.OCIObjectStorageService`를 monkeypatch할 수 +있도록 하기 위함이다). +""" + +from __future__ import annotations + +from datetime import date + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, joinedload + +from app.exceptions import ( + InvalidPresidentTermError, + NotFoundError, + PresidentAppointmentConflictError, + SignatureUploadConflictError, +) +from app.models import CertificateSignature, PresidentTerm +from app.services.user import UserService + + +class SignatureService: + @staticmethod + def get_by_user(db: Session, user_id: int) -> CertificateSignature | None: + """회장 1인당 서명은 최대 1개이므로 단순 조회.""" + return ( + db.query(CertificateSignature) + .filter(CertificateSignature.user_id == user_id) + .first() + ) + + @staticmethod + def upsert( + db: Session, *, user_id: int, object_key: str, storage + ) -> CertificateSignature: + """서명을 등록하거나 교체한다. + + 호출자가 `object_key`에 이미 새 이미지를 업로드해 둔 상태여야 한다. DB + commit이 실패하면 방금 업로드한 새 오브젝트를 best-effort로 삭제하고 + 예외를 다시 던진다. commit이 성공하면 그제서야 옛 오브젝트를 + best-effort로 삭제한다("교체"이므로 이전 서명 파일이 남지 않도록). + """ + existing = SignatureService.get_by_user(db, user_id) + old_object_key = existing.object_key if existing else None + + if existing is not None: + existing.object_key = object_key + signature = existing + else: + signature = CertificateSignature(user_id=user_id, object_key=object_key) + db.add(signature) + + try: + db.commit() + except IntegrityError: + # 두 요청이 동시에 이 유저의 첫 서명을 등록하면 (existing이 + # 둘 다 None으로 보여) 둘 다 INSERT를 시도한다. + # `uq_certificate_signatures_user_id` 유니크 제약이 실제 + # 무결성은 지켜주지만, 진 쪽은 `IntegrityError`를 던진다 -- + # `PresidentService.appoint`와 동일하게 깔끔한 409 도메인 + # 에러로 변환한다 (그대로 두면 구조화되지 않은 500이 된다). + db.rollback() + storage.delete_object(object_key) + raise SignatureUploadConflictError() from None + except Exception: + db.rollback() + storage.delete_object(object_key) + raise + + db.refresh(signature) + if old_object_key and old_object_key != object_key: + storage.delete_object(old_object_key) + return signature + + +class PresidentService: + @staticmethod + def get_current(db: Session) -> PresidentTerm | None: + """현직 회장 임기 = ended_at IS NULL인 행 (DB 유니크 제약상 최대 1개).""" + return ( + db.query(PresidentTerm) + .options(joinedload(PresidentTerm.user)) + .filter(PresidentTerm.ended_at.is_(None)) + .first() + ) + + @staticmethod + def appoint(db: Session, *, user_id: int, started_at: date) -> PresidentTerm: + """새 회장을 임명한다. + + 기존에 열려 있는 임기가 있으면 같은 트랜잭션에서 먼저 닫는다 + (ended_at = started_at). DB의 `uq_president_current` 유니크 제약이 + "열린 임기는 최대 1개"라는 불변식의 최종 방어선이다. + + 두 관리자가 거의 동시에 임명을 요청하면, 둘 다 이 시점의 "같은 열린 + 임기"(또는 둘 다 없음)를 보고 각자 새 임기를 추가하려 시도할 수 있다. + `uq_president_current` 유니크 제약이 실제 데이터 무결성(열린 임기 + 최대 1개)은 항상 지켜주지만, 진 쪽 트랜잭션은 `db.commit()`에서 + `IntegrityError`를 던진다 — `app/main.py`에는 `AppError` 핸들러만 + 있으므로 이를 그대로 두면 구조화되지 않은 500으로 노출된다. 여기서 + 잡아 깔끔한 409 도메인 에러로 변환한다. + + `get_current()`(및 이를 쓰는 `require_president`)는 `started_at`을 + 보지 않고 `ended_at IS NULL`만으로 "현직"을 판단한다. 따라서 + `started_at`이 미래인 임명을 그대로 허용하면, 신임 회장은 의도한 + 시작일보다 훨씬 전에 서명 업로드/조회 권한을 즉시 얻고 전임 회장은 + 즉시 잃는다 — 접근 제어 경계가 임기 시작일과 어긋난다. 이 엔드포인트는 + "지금 임명"을 의미하므로 미래 날짜는 거부한다. + """ + target = UserService.get(db, user_id) + if target is None: + raise NotFoundError("대상 회원을 찾을 수 없습니다.") + + if started_at > date.today(): + raise InvalidPresidentTermError("임기 시작일은 오늘보다 미래일 수 없습니다 (임명은 즉시 발효됩니다).") + + current = PresidentService.get_current(db) + if current is not None: + if started_at < current.started_at: + raise InvalidPresidentTermError() + current.ended_at = started_at + + term = PresidentTerm(user_id=user_id, started_at=started_at) + db.add(term) + try: + db.commit() + except IntegrityError: + db.rollback() + raise PresidentAppointmentConflictError() from None + db.refresh(term) + return term diff --git a/app/services/object_storage.py b/app/services/object_storage.py index 5238262..f5b6b23 100644 --- a/app/services/object_storage.py +++ b/app/services/object_storage.py @@ -57,22 +57,47 @@ def upload_profile_image(self, user_id: int, file: UploadFile, body: bytes) -> s object_name = ( f"profiles/{user_id}/{uuid4()}{PROFILE_IMAGE_EXTENSIONS[file.content_type]}" ) + return self.upload_bytes( + object_name, + body, + file.content_type, + error_message="Failed to upload profile image", + ) + + def delete_profile_image(self, user_id: int, url: str | None) -> None: + object_name = self.object_name_from_url(url) + if not object_name or not object_name.startswith(f"profiles/{user_id}/"): + return + self.delete_object(object_name) + + def upload_bytes( + self, + object_name: str, + body: bytes, + content_type: str, + *, + error_message: str = "Failed to upload object", + ) -> str: try: self.client.put_object( self.namespace, self.bucket, object_name, body, - content_type=file.content_type, + content_type=content_type, ) except Exception as exc: - raise ObjectStorageError("Failed to upload profile image") from exc + raise ObjectStorageError(error_message) from exc return self.public_url(object_name) - def delete_profile_image(self, user_id: int, url: str | None) -> None: - object_name = self.object_name_from_url(url) - if not object_name or not object_name.startswith(f"profiles/{user_id}/"): - return + def get_bytes(self, object_name: str) -> bytes: + try: + response = self.client.get_object(self.namespace, self.bucket, object_name) + return response.data.content + except Exception as exc: + raise ObjectStorageError("Failed to download object") from exc + + def delete_object(self, object_name: str) -> None: try: self.client.delete_object(self.namespace, self.bucket, object_name) except Exception: diff --git a/pyproject.toml b/pyproject.toml index cdf5863..d98cd4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dev = [ "black>=25.9.0", "httpx>=0.28.1", "isort>=6.0.1", + "pillow>=11.0.0", "pre-commit>=4.3.0", "pytest>=8.4.2", "testcontainers[mysql]>=4.10.0", diff --git a/tests/conftest.py b/tests/conftest.py index 97a1d95..ec562da 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import atexit import os import time -from datetime import timedelta +from datetime import date, timedelta from testcontainers.mysql import MySqlContainer @@ -37,8 +37,8 @@ from app.config.database import Base, Engine, get_db from app.main import app -from app.models import Qualification, User, UserRole -from app.services import UserService +from app.models import PresidentTerm, Qualification, User, UserRole +from app.services import PresidentService, UserService # JWT config (must match app/deps/auth.py default) JWT_SECRET_KEY = ( @@ -219,3 +219,39 @@ def active_token(active_user: User) -> str: def admin_token(admin_user: User) -> str: """Create JWT token for admin user.""" return create_access_token(admin_user.id, admin_user.email, admin_user.google_id) + + +@pytest.fixture +def president_user(db: Session) -> User: + """Create a member who will be appointed as the current president. + + Note: being the current president (an open `president_terms` row) is a + separate concept from `role=admin` — this user is a regular/active + member, not an admin, unless a test also grants admin. + """ + user = UserService.create( + db, + email="president@example.com", + name="President User", + generation="26", + qualification=Qualification.ACTIVE, + google_id="president_google_id", + ) + return user + + +@pytest.fixture +def president_token(president_user: User) -> str: + """Create JWT token for the president user.""" + return create_access_token( + president_user.id, president_user.email, president_user.google_id + ) + + +@pytest.fixture +def open_president_term(db: Session, president_user: User) -> PresidentTerm: + """Open a president term for `president_user` via the real service (so + `require_president`/`PresidentService.get_current` see a current 회장).""" + return PresidentService.appoint( + db, user_id=president_user.id, started_at=date(2026, 1, 1) + ) diff --git a/tests/test_certificates.py b/tests/test_certificates.py new file mode 100644 index 0000000..f76408f --- /dev/null +++ b/tests/test_certificates.py @@ -0,0 +1,538 @@ +"""Tests for the president / signature API (활동증명서용 회장 서명 등록/변경 + +회장 임기 관리). + +Covers `app/routes/certificates.py` and `app/services/certificate.py` +(`SignatureService` / `PresidentService`) end-to-end against a real +testcontainers MySQL 8 database. Object storage is mocked the way +`tests/test_member_detail.py` mocks it: +`monkeypatch.setattr("app.routes.certificates.OCIObjectStorageService", ...)`. +""" + +import base64 +import threading +from datetime import date, timedelta +from io import BytesIO + +import pytest +from fastapi.testclient import TestClient +from PIL import Image +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, sessionmaker + +from app.exceptions import ( + PresidentAppointmentConflictError, + SignatureUploadConflictError, +) +from app.models import CertificateSignature, PresidentTerm, User +from app.services.certificate import PresidentService, SignatureService + +pytestmark = pytest.mark.usefixtures("fake_storage") + +# A real (tiny, 1x1 transparent) PNG. +VALID_PNG_BYTES = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" +) +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" +PDF_MAGIC = b"%PDF-" + + +def _make_image_bytes(image_format: str) -> bytes: + """A tiny (4x4) *real* image in the given Pillow format -- these must be + bytes an actual image decoder would accept, not just bytes that happen to + start with the right magic number, so the signature-upload magic-byte + check is exercised against genuine files.""" + buf = BytesIO() + Image.new("RGB", (4, 4), color=(10, 20, 30)).save(buf, format=image_format) + return buf.getvalue() + + +# Real (tiny) images in each newly-accepted format, for signature uploads. +VALID_JPEG_BYTES = _make_image_bytes("JPEG") +VALID_WEBP_BYTES = _make_image_bytes("WEBP") +VALID_GIF_BYTES = _make_image_bytes("GIF") + + +def _auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def _upload_signature( + client: TestClient, + token: str, + *, + body: bytes = VALID_PNG_BYTES, + content_type="image/png", +): + return client.put( + "/certificates/signature/me", + files={"file": ("signature.png", body, content_type)}, + headers=_auth(token), + ) + + +# --------------------------------------------------------------------------- +# Object storage mock: in-memory dict shared across the requests of a single +# test (so an upload in one call is readable by `get_bytes` in another). +# +# ⑤ uses `OCIObjectStorageService()` directly in the route (not the +# `create_object_storage()` factory from PR_0), so the mock patches the class +# constructor the route imports. +# --------------------------------------------------------------------------- +@pytest.fixture +def fake_storage(monkeypatch: pytest.MonkeyPatch) -> dict: + data: dict[str, bytes] = {} + + class FakeObjectStorage: + def upload_bytes(self, object_name: str, body: bytes, content_type: str) -> str: + data[object_name] = body + return f"https://fake.local/{object_name}" + + def get_bytes(self, object_name: str) -> bytes: + return data[object_name] + + def delete_object(self, object_name: str) -> None: + data.pop(object_name, None) + + monkeypatch.setattr( + "app.routes.certificates.OCIObjectStorageService", FakeObjectStorage + ) + return data + + +class TestSignatureUpload: + """GET/PUT /certificates/signature/me (president only).""" + + def test_non_president_admin_cannot_access( + self, client: TestClient, admin_token: str + ): + response = client.get("/certificates/signature/me", headers=_auth(admin_token)) + assert response.status_code == 403 + + def test_get_signature_when_none_registered( + self, + client: TestClient, + president_token: str, + open_president_term: PresidentTerm, + ): + response = client.get( + "/certificates/signature/me", headers=_auth(president_token) + ) + assert response.status_code == 200 + body = response.json() + assert body["data"] is None + assert body["message"] == "등록된 서명이 없습니다." + + def test_upload_rejects_unsupported_content_type( + self, client: TestClient, president_token: str, open_president_term + ): + """A genuinely non-image content-type (not in the PNG/JPEG/WebP/GIF + allowlist) is rejected before the body is even magic-byte-checked.""" + response = _upload_signature( + client, + president_token, + body=PDF_MAGIC + b"1.4 not an image at all", + content_type="application/pdf", + ) + assert response.status_code == 400 + assert response.json()["error"] == "INVALID_SIGNATURE_FILE" + + def test_upload_rejects_svg_content_type( + self, client: TestClient, president_token: str, open_president_term + ): + """SVG is deliberately excluded from the allowlist -- it can embed + scripts/external references, which is unsafe -- even though it is a + common vector image format.""" + response = _upload_signature( + client, + president_token, + body=b"", + content_type="image/svg+xml", + ) + assert response.status_code == 400 + assert response.json()["error"] == "INVALID_SIGNATURE_FILE" + + def test_upload_rejects_wrong_magic_bytes_even_with_png_content_type( + self, client: TestClient, president_token: str, open_president_term + ): + """The content-type header alone is spoofable; the magic-byte check + must independently reject a renamed non-PNG file.""" + response = _upload_signature( + client, + president_token, + body=b"not-a-real-png-body", + content_type="image/png", + ) + assert response.status_code == 400 + assert response.json()["error"] == "INVALID_SIGNATURE_FILE" + + def test_upload_rejects_spoofed_content_type_with_mismatched_real_image( + self, client: TestClient, president_token: str, open_president_term + ): + """Declaring `image/jpeg` while uploading real PNG bytes must still + be rejected -- each content-type is checked against its *own* + format-specific magic bytes, not just "is this some kind of + image".""" + response = _upload_signature( + client, president_token, body=VALID_PNG_BYTES, content_type="image/jpeg" + ) + assert response.status_code == 400 + assert response.json()["error"] == "INVALID_SIGNATURE_FILE" + + def test_upload_too_large( + self, client: TestClient, president_token: str, open_president_term + ): + oversized = PNG_MAGIC + b"0" * (5 * 1024 * 1024 + 1) + response = _upload_signature(client, president_token, body=oversized) + assert response.status_code == 413 + assert response.json()["error"] == "SIGNATURE_FILE_TOO_LARGE" + + @pytest.mark.parametrize( + "content_type,body,expected_ext", + [ + ("image/png", VALID_PNG_BYTES, ".png"), + ("image/jpeg", VALID_JPEG_BYTES, ".jpg"), + ("image/webp", VALID_WEBP_BYTES, ".webp"), + ("image/gif", VALID_GIF_BYTES, ".gif"), + ], + ) + def test_upload_accepts_each_allowed_format( + self, + client: TestClient, + db: Session, + president_token: str, + president_user: User, + open_president_term: PresidentTerm, + fake_storage: dict, + content_type: str, + body: bytes, + expected_ext: str, + ): + """PNG (regression), JPEG, WebP, and GIF -- each with a real image + body of its own format -- are all accepted, and the stored + object_key gets the matching extension.""" + response = _upload_signature( + client, president_token, body=body, content_type=content_type + ) + assert response.status_code == 200 + + row = ( + db.query(CertificateSignature) + .filter(CertificateSignature.user_id == president_user.id) + .one() + ) + assert row.object_key.endswith(expected_ext) + assert row.object_key in fake_storage + + def test_upload_happy_path_and_replace( + self, + client: TestClient, + db: Session, + president_token: str, + president_user: User, + open_president_term: PresidentTerm, + fake_storage: dict, + ): + first = _upload_signature(client, president_token) + assert first.status_code == 200 + first_data = first.json()["data"] + assert first_data["user_id"] == president_user.id + + first_row = ( + db.query(CertificateSignature) + .filter(CertificateSignature.user_id == president_user.id) + .one() + ) + first_key = first_row.object_key + assert first_key in fake_storage + + # Replace: same signature id, new object_key, old object deleted. + second = _upload_signature(client, president_token) + assert second.status_code == 200 + assert second.json()["data"]["id"] == first_data["id"] + + db.expire_all() + assert ( + db.query(CertificateSignature) + .filter(CertificateSignature.user_id == president_user.id) + .count() + == 1 + ) + second_row = ( + db.query(CertificateSignature) + .filter(CertificateSignature.user_id == president_user.id) + .one() + ) + assert second_row.object_key != first_key + assert first_key not in fake_storage + assert second_row.object_key in fake_storage + + get_resp = client.get( + "/certificates/signature/me", headers=_auth(president_token) + ) + assert get_resp.status_code == 200 + assert get_resp.json()["data"]["id"] == first_data["id"] + + def test_upsert_concurrent_first_upload_returns_clean_error_not_500( + self, engine, president_user: User, monkeypatch: pytest.MonkeyPatch + ): + """Two requests racing to register the *first* signature for the + same president both read `existing = None` via + `SignatureService.get_by_user` before either commits, so both try to + INSERT a new `CertificateSignature` row. The DB + `uq_certificate_signatures_user_id` unique constraint correctly + prevents two rows for the same user_id from ever being committed, + but without exception handling around `db.commit()`, the losing + transaction's `IntegrityError` would propagate unhandled -- + `app/main.py` only registers a handler for `AppError`, so it would + surface as an unstructured 500 instead of a clean domain error. + + A second barrier pins both threads' `get_by_user` read together -- + without it, one thread's entire `upsert()` (including commit) can + finish before the other even reads, in which case the second thread + would see the first thread's committed row and take the *update* + path instead of racing on INSERT, and there would be nothing to + conflict on. + """ + session_factory = sessionmaker(bind=engine, autocommit=False, autoflush=False) + start_barrier = threading.Barrier(2) + read_barrier = threading.Barrier(2) + original_get_by_user = SignatureService.get_by_user + + def synced_get_by_user(db, user_id): + result = original_get_by_user(db, user_id) + read_barrier.wait(timeout=10) + return result + + monkeypatch.setattr( + SignatureService, "get_by_user", staticmethod(synced_get_by_user) + ) + + results: list[str] = [] + results_lock = threading.Lock() + + class _NoopStorage: + def delete_object(self, object_name: str) -> None: + pass + + def worker(object_key: str) -> None: + session = session_factory() + try: + start_barrier.wait(timeout=10) + try: + SignatureService.upsert( + session, + user_id=president_user.id, + object_key=object_key, + storage=_NoopStorage(), + ) + outcome = "ok" + except SignatureUploadConflictError: + outcome = "conflict" + with results_lock: + results.append(outcome) + finally: + session.close() + + t1 = threading.Thread(target=worker, args=("signatures/a.png",)) + t2 = threading.Thread(target=worker, args=("signatures/b.png",)) + t1.start() + t2.start() + t1.join(timeout=30) + t2.join(timeout=30) + + assert len(results) == 2 + assert results.count("ok") == 1, f"expected exactly one winner, got {results!r}" + assert results.count("conflict") == 1 + + verify_session = session_factory() + try: + count = ( + verify_session.query(CertificateSignature) + .filter(CertificateSignature.user_id == president_user.id) + .count() + ) + assert count == 1 + finally: + verify_session.close() + + +class TestPresidentTermAdministration: + """POST/GET /certificates/president-terms (admin only).""" + + def test_appoint_closes_previous_open_term( + self, + client: TestClient, + db: Session, + admin_token: str, + regular_user: User, + active_user: User, + ): + first = client.post( + "/certificates/president-terms", + json={"user_id": regular_user.id, "started_at": "2025-01-01"}, + headers=_auth(admin_token), + ) + assert first.status_code == 200 + first_term_id = first.json()["data"]["id"] + + second = client.post( + "/certificates/president-terms", + json={"user_id": active_user.id, "started_at": "2026-01-01"}, + headers=_auth(admin_token), + ) + assert second.status_code == 200 + assert second.json()["data"]["ended_at"] is None + assert second.json()["data"]["user"]["id"] == active_user.id + + db.expire_all() + old_term = db.get(PresidentTerm, first_term_id) + assert old_term.ended_at == date(2026, 1, 1) + + # DB invariant: exactly one open term. + open_count = ( + db.query(PresidentTerm).filter(PresidentTerm.ended_at.is_(None)).count() + ) + assert open_count == 1 + + def test_get_current_president( + self, + client: TestClient, + admin_token: str, + president_user: User, + open_president_term: PresidentTerm, + ): + response = client.get( + "/certificates/president-terms/current", headers=_auth(admin_token) + ) + assert response.status_code == 200 + assert response.json()["data"]["user"]["id"] == president_user.id + + def test_get_current_president_when_none_appointed( + self, client: TestClient, admin_token: str + ): + response = client.get( + "/certificates/president-terms/current", headers=_auth(admin_token) + ) + assert response.status_code == 200 + assert response.json()["data"] is None + + def test_non_admin_cannot_appoint_president( + self, client: TestClient, regular_token: str, active_user: User + ): + response = client.post( + "/certificates/president-terms", + json={"user_id": active_user.id, "started_at": "2026-01-01"}, + headers=_auth(regular_token), + ) + assert response.status_code == 403 + + def test_db_rejects_a_second_open_term_inserted_directly( + self, db: Session, regular_user: User, active_user: User + ): + """Bypasses the service layer to prove the `uq_president_current` + MySQL 8 generated-column unique index is the real backstop, not just + `PresidentService.appoint`'s application-level close-then-open.""" + db.add(PresidentTerm(user_id=regular_user.id, started_at=date(2025, 1, 1))) + db.commit() + + db.add(PresidentTerm(user_id=active_user.id, started_at=date(2025, 6, 1))) + with pytest.raises(IntegrityError): + db.commit() + db.rollback() + + def test_appoint_concurrent_conflict_returns_clean_error_not_500( + self, engine, regular_user: User, active_user: User + ): + """Two admins appointing different presidents at nearly the same + instant both pass `PresidentService.appoint`'s in-transaction + "close current, then open new" guard from their own snapshot. The DB + `uq_president_current` unique constraint correctly prevents two open + terms from ever being committed (atomicity holds), but without + exception handling around `db.commit()`, the losing transaction's + `IntegrityError` propagates unhandled -- and `app/main.py` only + registers a handler for `AppError`, so it would surface as an + unstructured 500 instead of a clean domain error. + """ + session_factory = sessionmaker(bind=engine, autocommit=False, autoflush=False) + barrier = threading.Barrier(2) + results: list[tuple[str, int]] = [] + results_lock = threading.Lock() + + def worker(user_id: int) -> None: + session = session_factory() + try: + barrier.wait() + try: + term = PresidentService.appoint( + session, user_id=user_id, started_at=date(2026, 1, 1) + ) + outcome = ("ok", term.user_id) + except PresidentAppointmentConflictError: + outcome = ("conflict", user_id) + with results_lock: + results.append(outcome) + finally: + session.close() + + t1 = threading.Thread(target=worker, args=(regular_user.id,)) + t2 = threading.Thread(target=worker, args=(active_user.id,)) + t1.start() + t2.start() + t1.join(timeout=30) + t2.join(timeout=30) + + assert len(results) == 2 + oks = [r for r in results if r[0] == "ok"] + conflicts = [r for r in results if r[0] == "conflict"] + assert len(oks) == 1, f"expected exactly one winner, got {results!r}" + assert len(conflicts) == 1 + + verify_session = session_factory() + try: + open_count = ( + verify_session.query(PresidentTerm) + .filter(PresidentTerm.ended_at.is_(None)) + .count() + ) + assert open_count == 1 + finally: + verify_session.close() + + def test_appoint_rejects_started_at_before_current_term( + self, + client: TestClient, + admin_token: str, + regular_user: User, + active_user: User, + ): + first = client.post( + "/certificates/president-terms", + json={"user_id": regular_user.id, "started_at": "2026-01-01"}, + headers=_auth(admin_token), + ) + assert first.status_code == 200 + + second = client.post( + "/certificates/president-terms", + json={"user_id": active_user.id, "started_at": "2025-01-01"}, + headers=_auth(admin_token), + ) + assert second.status_code == 400 + assert second.json()["error"] == "INVALID_PRESIDENT_TERM" + + def test_appoint_rejects_started_at_in_the_future( + self, client: TestClient, admin_token: str, regular_user: User + ): + """Appointment takes effect immediately -- `require_president` / + `PresidentService.get_current` only check `ended_at IS NULL`, not + `started_at`. A future-dated `started_at` must be rejected, or the + newly-appointed user would get president-only access (and the + outgoing president would lose it) long before the intended date.""" + future = date.today() + timedelta(days=1) + response = client.post( + "/certificates/president-terms", + json={"user_id": regular_user.id, "started_at": str(future)}, + headers=_auth(admin_token), + ) + assert response.status_code == 400 + assert response.json()["error"] == "INVALID_PRESIDENT_TERM" diff --git a/uv.lock b/uv.lock index 5502008..8556496 100644 --- a/uv.lock +++ b/uv.lock @@ -573,6 +573,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" }, { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" }, { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, @@ -583,6 +584,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, @@ -593,6 +595,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, @@ -603,6 +606,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, @@ -891,6 +895,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + [[package]] name = "platformdirs" version = "4.4.0" @@ -1607,6 +1696,7 @@ dev = [ { name = "black" }, { name = "httpx" }, { name = "isort" }, + { name = "pillow" }, { name = "pre-commit" }, { name = "pytest" }, { name = "testcontainers", extra = ["mysql"] }, @@ -1637,6 +1727,7 @@ dev = [ { name = "black", specifier = ">=25.9.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "isort", specifier = ">=6.0.1" }, + { name = "pillow", specifier = ">=11.0.0" }, { name = "pre-commit", specifier = ">=4.3.0" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "testcontainers", extras = ["mysql"], specifier = ">=4.10.0" },