Add covers

This commit is contained in:
2025-12-21 02:52:48 +07:00
parent 9d2dba87b8
commit 921917a319
12 changed files with 869 additions and 26 deletions

View File

@@ -0,0 +1,36 @@
"""Add marathon cover_url field
Revision ID: 019_add_marathon_cover
Revises: 018_seed_static_content
Create Date: 2024-12-21
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '019_add_marathon_cover'
down_revision: Union[str, None] = '018_seed_static_content'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not column_exists('marathons', 'cover_url'):
op.add_column('marathons', sa.Column('cover_url', sa.String(500), nullable=True))
def downgrade() -> None:
if column_exists('marathons', 'cover_url'):
op.drop_column('marathons', 'cover_url')

View File

@@ -1,7 +1,7 @@
from datetime import timedelta
import secrets
import string
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi import APIRouter, HTTPException, status, Depends, UploadFile, File, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
@@ -11,7 +11,9 @@ from app.api.deps import (
require_participant, require_organizer, require_creator,
get_participant,
)
from app.core.config import settings
from app.core.security import decode_access_token
from app.services.storage import storage_service
# Optional auth for endpoints that need it conditionally
optional_auth = HTTPBearer(auto_error=False)
@@ -62,6 +64,7 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
title=marathon.title,
description=marathon.description,
status=marathon.status,
cover_url=marathon.cover_url,
participants_count=participants_count,
creator_nickname=marathon.creator.nickname,
)
@@ -128,6 +131,7 @@ async def list_marathons(current_user: CurrentUser, db: DbSession):
title=marathon.title,
status=marathon.status,
is_public=marathon.is_public,
cover_url=marathon.cover_url,
participants_count=row[1],
start_date=marathon.start_date,
end_date=marathon.end_date,
@@ -180,6 +184,7 @@ async def create_marathon(
is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled,
cover_url=marathon.cover_url,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=1,
@@ -226,6 +231,7 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio
is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled,
cover_url=marathon.cover_url,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=participants_count,
@@ -591,3 +597,109 @@ async def get_leaderboard(
))
return leaderboard
@router.get("/{marathon_id}/cover")
async def get_marathon_cover(marathon_id: int, db: DbSession):
"""Get marathon cover image"""
marathon = await get_marathon_or_404(db, marathon_id)
if not marathon.cover_path:
raise HTTPException(status_code=404, detail="Marathon has no cover")
file_data = await storage_service.get_file(marathon.cover_path, "covers")
if not file_data:
raise HTTPException(status_code=404, detail="Cover not found in storage")
content, content_type = file_data
return Response(
content=content,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=3600",
}
)
@router.post("/{marathon_id}/cover", response_model=MarathonResponse)
async def upload_marathon_cover(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
file: UploadFile = File(...),
):
"""Upload marathon cover image (organizers only, preparing status)"""
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon")
# Validate file
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image",
)
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
# Get file extension
ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
if ext not in settings.ALLOWED_IMAGE_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
)
# Delete old cover if exists
if marathon.cover_path:
await storage_service.delete_file(marathon.cover_path)
# Upload file
filename = storage_service.generate_filename(marathon_id, file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="covers",
filename=filename,
content_type=file.content_type or "image/jpeg",
)
# Update marathon with cover path and URL
marathon.cover_path = file_path
marathon.cover_url = f"/api/v1/marathons/{marathon_id}/cover"
await db.commit()
return await get_marathon(marathon_id, current_user, db)
@router.delete("/{marathon_id}/cover", response_model=MarathonResponse)
async def delete_marathon_cover(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Delete marathon cover image (organizers only, preparing status)"""
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon")
if not marathon.cover_path:
raise HTTPException(status_code=400, detail="Marathon has no cover")
# Delete file from storage
await storage_service.delete_file(marathon.cover_path)
marathon.cover_path = None
marathon.cover_url = None
await db.commit()
return await get_marathon(marathon_id, current_user, db)

View File

@@ -31,6 +31,8 @@ class Marathon(Base):
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
auto_events_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
cover_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships

View File

@@ -49,6 +49,7 @@ class MarathonResponse(MarathonBase):
is_public: bool
game_proposal_mode: str
auto_events_enabled: bool
cover_url: str | None
start_date: datetime | None
end_date: datetime | None
participants_count: int
@@ -69,6 +70,7 @@ class MarathonListItem(BaseModel):
title: str
status: str
is_public: bool
cover_url: str | None
participants_count: int
start_date: datetime | None
end_date: datetime | None
@@ -87,6 +89,7 @@ class MarathonPublicInfo(BaseModel):
title: str
description: str | None
status: str
cover_url: str | None
participants_count: int
creator_nickname: str