from datetime import timedelta import secrets import string 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 from app.api.deps import ( DbSession, CurrentUser, 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) from app.models import ( Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge, Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole, ) from app.schemas import ( MarathonCreate, MarathonUpdate, MarathonResponse, MarathonListItem, MarathonPublicInfo, JoinMarathon, ParticipantInfo, ParticipantWithUser, LeaderboardEntry, MessageResponse, UserPublic, SetParticipantRole, ) from app.services.telegram_notifier import telegram_notifier router = APIRouter(prefix="/marathons", tags=["marathons"]) # Public endpoint (no auth required) @router.get("/by-code/{invite_code}", response_model=MarathonPublicInfo) async def get_marathon_by_code(invite_code: str, db: DbSession): """Get public marathon info by invite code. No authentication required.""" result = await db.execute( select(Marathon, func.count(Participant.id).label("participants_count")) .outerjoin(Participant) .options(selectinload(Marathon.creator)) .where(func.upper(Marathon.invite_code) == invite_code.upper()) .group_by(Marathon.id) ) row = result.first() if not row: raise HTTPException(status_code=404, detail="Marathon not found") marathon = row[0] participants_count = row[1] return MarathonPublicInfo( id=marathon.id, title=marathon.title, description=marathon.description, status=marathon.status, cover_url=marathon.cover_url, participants_count=participants_count, creator_nickname=marathon.creator.nickname, ) def generate_invite_code() -> str: """Generate a clean 8-character uppercase alphanumeric code.""" alphabet = string.ascii_uppercase + string.digits return ''.join(secrets.choice(alphabet) for _ in range(8)) async def get_marathon_or_404(db, marathon_id: int) -> Marathon: result = await db.execute( select(Marathon) .options(selectinload(Marathon.creator)) .where(Marathon.id == marathon_id) ) marathon = result.scalar_one_or_none() if not marathon: raise HTTPException(status_code=404, detail="Marathon not found") return marathon async def get_participation(db, user_id: int, marathon_id: int) -> Participant | None: result = await db.execute( select(Participant).where( Participant.user_id == user_id, Participant.marathon_id == marathon_id, ) ) return result.scalar_one_or_none() @router.get("", response_model=list[MarathonListItem]) async def list_marathons(current_user: CurrentUser, db: DbSession): """Get all marathons where user is participant, creator, or public marathons""" # Admin can see all marathons if current_user.is_admin: result = await db.execute( select(Marathon, func.count(Participant.id).label("participants_count")) .outerjoin(Participant) .group_by(Marathon.id) .order_by(Marathon.created_at.desc()) ) else: # User can see: own marathons, participated marathons, and public marathons result = await db.execute( select(Marathon, func.count(Participant.id).label("participants_count")) .outerjoin(Participant) .where( (Marathon.creator_id == current_user.id) | (Participant.user_id == current_user.id) | (Marathon.is_public == True) ) .group_by(Marathon.id) .order_by(Marathon.created_at.desc()) ) marathons = [] for row in result.all(): marathon = row[0] marathons.append(MarathonListItem( id=marathon.id, 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, )) return marathons @router.post("", response_model=MarathonResponse) async def create_marathon( data: MarathonCreate, current_user: CurrentUser, db: DbSession, ): # Strip timezone info for naive datetime columns start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date end_date = start_date + timedelta(days=data.duration_days) marathon = Marathon( title=data.title, description=data.description, creator_id=current_user.id, invite_code=generate_invite_code(), is_public=data.is_public, game_proposal_mode=data.game_proposal_mode, start_date=start_date, end_date=end_date, ) db.add(marathon) await db.flush() # Auto-add creator as organizer participant participant = Participant( user_id=current_user.id, marathon_id=marathon.id, role=ParticipantRole.ORGANIZER.value, # Creator is organizer ) db.add(participant) await db.commit() await db.refresh(marathon) return MarathonResponse( id=marathon.id, title=marathon.title, description=marathon.description, creator=UserPublic.model_validate(current_user), status=marathon.status, invite_code=marathon.invite_code, 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, games_count=0, created_at=marathon.created_at, my_participation=ParticipantInfo.model_validate(participant), ) @router.get("/{marathon_id}", response_model=MarathonResponse) async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): marathon = await get_marathon_or_404(db, marathon_id) # For private marathons, require participation (or admin/creator) if not marathon.is_public and not current_user.is_admin: participation = await get_participation(db, current_user.id, marathon_id) if not participation: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You are not a participant of this private marathon", ) # Count participants and approved games participants_count = await db.scalar( select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id) ) games_count = await db.scalar( select(func.count()).select_from(Game).where( Game.marathon_id == marathon_id, Game.status == GameStatus.APPROVED.value, ) ) # Get user's participation participation = await get_participation(db, current_user.id, marathon_id) return MarathonResponse( id=marathon.id, title=marathon.title, description=marathon.description, creator=UserPublic.model_validate(marathon.creator), status=marathon.status, invite_code=marathon.invite_code, 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, games_count=games_count, created_at=marathon.created_at, my_participation=ParticipantInfo.model_validate(participation) if participation else None, ) @router.patch("/{marathon_id}", response_model=MarathonResponse) async def update_marathon( marathon_id: int, data: MarathonUpdate, current_user: CurrentUser, db: DbSession, ): # Require organizer role 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 active or finished marathon") if data.title is not None: marathon.title = data.title if data.description is not None: marathon.description = data.description if data.start_date is not None: # Strip timezone info for naive datetime columns marathon.start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date if data.is_public is not None: marathon.is_public = data.is_public if data.game_proposal_mode is not None: marathon.game_proposal_mode = data.game_proposal_mode if data.auto_events_enabled is not None: marathon.auto_events_enabled = data.auto_events_enabled await db.commit() return await get_marathon(marathon_id, current_user, db) @router.delete("/{marathon_id}", response_model=MessageResponse) async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): # Only creator or admin can delete await require_creator(db, current_user, marathon_id) marathon = await get_marathon_or_404(db, marathon_id) await db.delete(marathon) await db.commit() return MessageResponse(message="Marathon deleted") @router.post("/{marathon_id}/start", response_model=MarathonResponse) async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): # Require organizer role 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="Marathon is not in preparing state") # Check if there are approved games games_result = await db.execute( select(Game).where( Game.marathon_id == marathon_id, Game.status == GameStatus.APPROVED.value, ) ) approved_games = games_result.scalars().all() if len(approved_games) == 0: raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру") # Check that all approved games have at least one challenge games_without_challenges = [] for game in approved_games: challenge_count = await db.scalar( select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id) ) if challenge_count == 0: games_without_challenges.append(game.title) if games_without_challenges: games_list = ", ".join(games_without_challenges) raise HTTPException( status_code=400, detail=f"У следующих игр нет челленджей: {games_list}" ) marathon.status = MarathonStatus.ACTIVE.value # Log activity activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.START_MARATHON.value, data={"title": marathon.title}, ) db.add(activity) await db.commit() # Send Telegram notifications await telegram_notifier.notify_marathon_start(db, marathon_id, marathon.title) return await get_marathon(marathon_id, current_user, db) @router.post("/{marathon_id}/finish", response_model=MarathonResponse) async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): # Require organizer role await require_organizer(db, current_user, marathon_id) marathon = await get_marathon_or_404(db, marathon_id) if marathon.status != MarathonStatus.ACTIVE.value: raise HTTPException(status_code=400, detail="Marathon is not active") marathon.status = MarathonStatus.FINISHED.value # Log activity activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.FINISH_MARATHON.value, data={"title": marathon.title}, ) db.add(activity) await db.commit() # Send Telegram notifications await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title) return await get_marathon(marathon_id, current_user, db) @router.post("/join", response_model=MarathonResponse) async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession): result = await db.execute( select(Marathon).where(func.upper(Marathon.invite_code) == data.invite_code.upper()) ) marathon = result.scalar_one_or_none() if not marathon: raise HTTPException(status_code=404, detail="Invalid invite code") if marathon.status == MarathonStatus.FINISHED.value: raise HTTPException(status_code=400, detail="Marathon has already finished") # Check if already participant existing = await get_participation(db, current_user.id, marathon.id) if existing: raise HTTPException(status_code=400, detail="Already joined this marathon") participant = Participant( user_id=current_user.id, marathon_id=marathon.id, role=ParticipantRole.PARTICIPANT.value, # Regular participant ) db.add(participant) # Log activity activity = Activity( marathon_id=marathon.id, user_id=current_user.id, type=ActivityType.JOIN.value, data={"nickname": current_user.nickname}, ) db.add(activity) await db.commit() return await get_marathon(marathon.id, current_user, db) @router.post("/{marathon_id}/join", response_model=MarathonResponse) async def join_public_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): """Join a public marathon without invite code""" marathon = await get_marathon_or_404(db, marathon_id) if not marathon.is_public: raise HTTPException(status_code=403, detail="This marathon is private. Use invite code to join.") if marathon.status == MarathonStatus.FINISHED.value: raise HTTPException(status_code=400, detail="Marathon has already finished") # Check if already participant existing = await get_participation(db, current_user.id, marathon.id) if existing: raise HTTPException(status_code=400, detail="Already joined this marathon") participant = Participant( user_id=current_user.id, marathon_id=marathon.id, role=ParticipantRole.PARTICIPANT.value, ) db.add(participant) # Log activity activity = Activity( marathon_id=marathon.id, user_id=current_user.id, type=ActivityType.JOIN.value, data={"nickname": current_user.nickname}, ) db.add(activity) await db.commit() return await get_marathon(marathon.id, current_user, db) @router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser]) async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession): marathon = await get_marathon_or_404(db, marathon_id) # For private marathons, require participation (or admin) if not marathon.is_public and not current_user.is_admin: participation = await get_participation(db, current_user.id, marathon_id) if not participation: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You are not a participant of this private marathon", ) result = await db.execute( select(Participant) .options(selectinload(Participant.user)) .where(Participant.marathon_id == marathon_id) .order_by(Participant.joined_at) ) participants = result.scalars().all() return [ ParticipantWithUser( id=p.id, role=p.role, total_points=p.total_points, current_streak=p.current_streak, drop_count=p.drop_count, joined_at=p.joined_at, user=UserPublic.model_validate(p.user), ) for p in participants ] @router.patch("/{marathon_id}/participants/{user_id}/role", response_model=ParticipantWithUser) async def set_participant_role( marathon_id: int, user_id: int, data: SetParticipantRole, current_user: CurrentUser, db: DbSession, ): """Set participant's role (only creator can do this)""" # Only creator can change roles marathon = await require_creator(db, current_user, marathon_id) # Cannot change creator's role if user_id == marathon.creator_id: raise HTTPException(status_code=400, detail="Cannot change creator's role") # Get participant result = await db.execute( select(Participant) .options(selectinload(Participant.user)) .where( Participant.marathon_id == marathon_id, Participant.user_id == user_id, ) ) participant = result.scalar_one_or_none() if not participant: raise HTTPException(status_code=404, detail="Participant not found") participant.role = data.role await db.commit() await db.refresh(participant) return ParticipantWithUser( id=participant.id, role=participant.role, total_points=participant.total_points, current_streak=participant.current_streak, drop_count=participant.drop_count, joined_at=participant.joined_at, user=UserPublic.model_validate(participant.user), ) @router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry]) async def get_leaderboard( marathon_id: int, db: DbSession, credentials: HTTPAuthorizationCredentials | None = Depends(optional_auth), ): """ Get marathon leaderboard. Public marathons: no auth required. Private marathons: requires auth + participation check. """ marathon = await get_marathon_or_404(db, marathon_id) # For private marathons, require authentication and participation if not marathon.is_public: if not credentials: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required for private marathon leaderboard", headers={"WWW-Authenticate": "Bearer"}, ) payload = decode_access_token(credentials.credentials) if not payload: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}, ) user_id = int(payload.get("sub")) participant = await get_participant(db, user_id, marathon_id) if not participant: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You are not a participant of this marathon", ) result = await db.execute( select(Participant) .options(selectinload(Participant.user)) .where(Participant.marathon_id == marathon_id) .order_by(Participant.total_points.desc()) ) participants = result.scalars().all() leaderboard = [] for rank, p in enumerate(participants, 1): # Count completed and dropped assignments completed = await db.scalar( select(func.count()).select_from(Assignment).where( Assignment.participant_id == p.id, Assignment.status == AssignmentStatus.COMPLETED.value, ) ) dropped = await db.scalar( select(func.count()).select_from(Assignment).where( Assignment.participant_id == p.id, Assignment.status == AssignmentStatus.DROPPED.value, ) ) leaderboard.append(LeaderboardEntry( rank=rank, user=UserPublic.model_validate(p.user), total_points=p.total_points, current_streak=p.current_streak, completed_count=completed, dropped_count=dropped, )) 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)