Add covers
This commit is contained in:
36
backend/alembic/versions/019_add_marathon_cover.py
Normal file
36
backend/alembic/versions/019_add_marathon_cover.py
Normal 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')
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user