Skip to content
Merged
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
1 change: 1 addition & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import app.models.application # noqa: F401
import app.models.email_verification # noqa: F401
import app.models.event # noqa: F401
import app.models.review_image # noqa: F401
import app.models.review_submission # noqa: F401
import app.models.store # noqa: F401
import app.models.user # noqa: F401
Expand Down
39 changes: 39 additions & 0 deletions alembic/versions/23d53081763c_add_review_images_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""add review_images table

Revision ID: 23d53081763c
Revises: dd5d7eeaabd1
Create Date: 2026-06-15 13:32:08.943085

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '23d53081763c'
down_revision: Union[str, Sequence[str], None] = 'dd5d7eeaabd1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('review_images',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('submission_id', sa.Uuid(), nullable=False),
sa.Column('image_key', sa.String(), nullable=False),
sa.Column('order', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['submission_id'], ['review_submissions.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('review_images')
# ### end Alembic commands ###
69 changes: 69 additions & 0 deletions app/api/v1/endpoints/application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.v1.deps import require_login
from app.db.session import get_db
from app.schemas.application import (
ApplicationCreateReq,
ApplicationListResp,
ReviewSubmissionReq,
)
from app.schemas.common import SuccessResponse
from app.services import application as application_service

router = APIRouter(tags=["Application"])


@router.post("/applications", response_model=SuccessResponse[None])
async def create_application(
body: ApplicationCreateReq,
db: AsyncSession = Depends(get_db),
reviewer_id: str = Depends(require_login),
) -> SuccessResponse[None]:
await application_service.create_application(db, reviewer_id, body)
return SuccessResponse(data=None)


@router.get("/application", response_model=SuccessResponse[ApplicationListResp])
async def get_my_applications(
page: int = Query(default=1, ge=1),
size: int = Query(default=20, ge=1),
db: AsyncSession = Depends(get_db),
reviewer_id: str = Depends(require_login),
) -> SuccessResponse[ApplicationListResp]:
applications, total = await application_service.list_my_applications(
db, reviewer_id, page, size
)
total_pages = max(1, (total + size - 1) // size)
return SuccessResponse(
data=ApplicationListResp(
applications=applications,
totalCount=total,
currentPage=page,
totalPages=total_pages,
hasNext=page < total_pages,
)
)


@router.delete("/application/{applicationId}", response_model=SuccessResponse[None])
async def cancel_application(
applicationId: str,
db: AsyncSession = Depends(get_db),
user_id: str = Depends(require_login),
) -> SuccessResponse[None]:
await application_service.cancel_application(db, applicationId, user_id)
return SuccessResponse(data=None)


@router.post(
"/application/{applicationId}/submission", response_model=SuccessResponse[None]
)
async def submit_review(
applicationId: str,
body: ReviewSubmissionReq,
db: AsyncSession = Depends(get_db),
user_id: str = Depends(require_login),
) -> SuccessResponse[None]:
await application_service.submit_review(db, applicationId, user_id, body)
return SuccessResponse(data=None)
2 changes: 2 additions & 0 deletions app/api/v1/router.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from fastapi import APIRouter

from app.api.v1.endpoints.application import router as application_router
from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.event import router as event_router
from app.api.v1.endpoints.store import router as store_router
Expand All @@ -8,3 +9,4 @@
router.include_router(auth_router)
router.include_router(store_router)
router.include_router(event_router)
router.include_router(application_router)
15 changes: 15 additions & 0 deletions app/models/review_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from uuid import UUID, uuid4

from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column

from app.db.base import Base


class ReviewImage(Base):
__tablename__ = "review_images"

id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
submission_id: Mapped[UUID] = mapped_column(ForeignKey("review_submissions.id"))
image_key: Mapped[str] = mapped_column(String)
order: Mapped[int] = mapped_column(Integer)
34 changes: 34 additions & 0 deletions app/schemas/application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from pydantic import BaseModel


class ApplicationCreateReq(BaseModel):
eventId: str
walletAddress: str
imageKey: str


class ReviewSubmissionReq(BaseModel):
imageList: list[str]
comment: str


class ReviewSubmissionDetail(BaseModel):
id: str
message: str
reviewImages: list[str]


class ApplicationItem(BaseModel):
id: str
eventId: str
status: str
reviewSubmission: ReviewSubmissionDetail | None = None
appliedAt: str


class ApplicationListResp(BaseModel):
applications: list[ApplicationItem]
totalCount: int
currentPage: int
totalPages: int
hasNext: bool
161 changes: 161 additions & 0 deletions app/services/application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
from uuid import UUID

from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from app.models.application import Application
from app.models.event import Event
from app.models.review_image import ReviewImage
from app.models.review_submission import ReviewSubmission
from app.schemas.application import (
ApplicationCreateReq,
ApplicationItem,
ReviewSubmissionDetail,
ReviewSubmissionReq,
)


async def create_application(
db: AsyncSession, reviewer_id: str, data: ApplicationCreateReq
) -> None:
event = await db.scalar(select(Event).where(Event.id == UUID(data.eventId)))
if not event:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="이벤트를 찾을 수 없습니다.",
)

existing = await db.scalar(
select(Application).where(
Application.event_id == UUID(data.eventId),
Application.reviewer_id == UUID(reviewer_id),
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="이미 신청한 이벤트입니다.",
)

application = Application(
event_id=UUID(data.eventId),
reviewer_id=UUID(reviewer_id),
wallet_address=data.walletAddress,
image_key=data.imageKey,
)
db.add(application)
try:
await db.commit()
except IntegrityError as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="이미 신청한 이벤트입니다.",
) from e


async def list_my_applications(
db: AsyncSession, reviewer_id: str, page: int, size: int
) -> tuple[list[ApplicationItem], int]:
base_query = select(Application).where(Application.reviewer_id == UUID(reviewer_id))
_total = await db.scalar(select(func.count()).select_from(base_query.subquery()))
total = _total if _total is not None else 0

apps = (await db.scalars(base_query.offset((page - 1) * size).limit(size))).all()

result: list[ApplicationItem] = []
for app in apps:
submission = await db.scalar(
select(ReviewSubmission).where(ReviewSubmission.application_id == app.id)
)
submission_detail = None
if submission:
images = (
await db.scalars(
select(ReviewImage)
.where(ReviewImage.submission_id == submission.id)
.order_by(ReviewImage.order)
)
).all()
submission_detail = ReviewSubmissionDetail(
id=str(submission.id),
message=submission.message,
reviewImages=[img.image_key for img in images],
)
result.append(
ApplicationItem(
id=str(app.id),
eventId=str(app.event_id),
status=app.status,
reviewSubmission=submission_detail,
appliedAt=app.applied_at.isoformat(),
)
)
return result, int(total)


async def cancel_application(
db: AsyncSession, application_id: str, user_id: str
) -> None:
application = await db.scalar(
select(Application).where(Application.id == UUID(application_id))
)
if not application:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="신청을 찾을 수 없습니다.",
)
if str(application.reviewer_id) != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="본인의 신청이 아닙니다.",
)
if application.status != "PENDING":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="PENDING 상태의 신청만 취소할 수 있습니다.",
)
await db.delete(application)
await db.commit()


async def submit_review(
db: AsyncSession, application_id: str, user_id: str, data: ReviewSubmissionReq
) -> None:
application = await db.scalar(
select(Application).where(Application.id == UUID(application_id))
)
if not application:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="신청을 찾을 수 없습니다.",
)
if str(application.reviewer_id) != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="본인의 신청이 아닙니다.",
)
existing = await db.scalar(
select(ReviewSubmission).where(
ReviewSubmission.application_id == UUID(application_id)
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="이미 제출된 리뷰가 있습니다.",
)

submission = ReviewSubmission(
application_id=UUID(application_id),
message=data.comment,
)
db.add(submission)
await db.flush()

for i, image_key in enumerate(data.imageList):
db.add(ReviewImage(submission_id=submission.id, image_key=image_key, order=i))

await db.commit()
Loading
Loading