import secrets from datetime import datetime from fastapi import APIRouter, HTTPException, status, Query from sqlalchemy import select, func from sqlalchemy.orm import selectinload from app.api.deps import DbSession, CurrentUser, require_participant from app.models import ( WidgetToken, Participant, Marathon, Assignment, AssignmentStatus, BonusAssignment, BonusAssignmentStatus, ) from app.schemas.widget import ( WidgetTokenResponse, WidgetTokenListItem, WidgetLeaderboardEntry, WidgetLeaderboardResponse, WidgetCurrentResponse, WidgetProgressResponse, ) from app.schemas.common import MessageResponse from app.core.config import settings router = APIRouter(prefix="/widgets", tags=["widgets"]) def get_avatar_url(user) -> str | None: """Get avatar URL - through backend API if user has avatar, else telegram""" if user.avatar_path: return f"/api/v1/users/{user.id}/avatar" return user.telegram_avatar_url def generate_widget_token() -> str: """Generate a secure widget token""" return f"wgt_{secrets.token_urlsafe(32)}" def build_widget_urls(marathon_id: int, token: str) -> dict[str, str]: """Build widget URLs for the token""" base_url = settings.FRONTEND_URL or "http://localhost:5173" params = f"marathon={marathon_id}&token={token}" return { "leaderboard": f"{base_url}/widget/leaderboard?{params}", "current": f"{base_url}/widget/current?{params}", "progress": f"{base_url}/widget/progress?{params}", } # === Token management (authenticated) === @router.post("/marathons/{marathon_id}/token", response_model=WidgetTokenResponse) async def create_widget_token( marathon_id: int, current_user: CurrentUser, db: DbSession, ): """Create a widget token for the current user in a marathon""" participant = await require_participant(db, current_user.id, marathon_id) # Check if user already has an active token existing = await db.scalar( select(WidgetToken).where( WidgetToken.participant_id == participant.id, WidgetToken.marathon_id == marathon_id, WidgetToken.is_active == True, ) ) if existing: # Return existing token return WidgetTokenResponse( id=existing.id, token=existing.token, created_at=existing.created_at, expires_at=existing.expires_at, is_active=existing.is_active, urls=build_widget_urls(marathon_id, existing.token), ) # Create new token token = generate_widget_token() widget_token = WidgetToken( token=token, participant_id=participant.id, marathon_id=marathon_id, ) db.add(widget_token) await db.commit() await db.refresh(widget_token) return WidgetTokenResponse( id=widget_token.id, token=widget_token.token, created_at=widget_token.created_at, expires_at=widget_token.expires_at, is_active=widget_token.is_active, urls=build_widget_urls(marathon_id, widget_token.token), ) @router.get("/marathons/{marathon_id}/tokens", response_model=list[WidgetTokenListItem]) async def list_widget_tokens( marathon_id: int, current_user: CurrentUser, db: DbSession, ): """List all widget tokens for the current user in a marathon""" participant = await require_participant(db, current_user.id, marathon_id) result = await db.execute( select(WidgetToken) .where( WidgetToken.participant_id == participant.id, WidgetToken.marathon_id == marathon_id, ) .order_by(WidgetToken.created_at.desc()) ) tokens = result.scalars().all() return [ WidgetTokenListItem( id=t.id, token=t.token, created_at=t.created_at, is_active=t.is_active, ) for t in tokens ] @router.delete("/tokens/{token_id}", response_model=MessageResponse) async def revoke_widget_token( token_id: int, current_user: CurrentUser, db: DbSession, ): """Revoke a widget token""" result = await db.execute( select(WidgetToken) .options(selectinload(WidgetToken.participant)) .where(WidgetToken.id == token_id) ) widget_token = result.scalar_one_or_none() if not widget_token: raise HTTPException(status_code=404, detail="Token not found") if widget_token.participant.user_id != current_user.id and not current_user.is_admin: raise HTTPException(status_code=403, detail="Not authorized to revoke this token") widget_token.is_active = False await db.commit() return MessageResponse(message="Token revoked") @router.post("/tokens/{token_id}/regenerate", response_model=WidgetTokenResponse) async def regenerate_widget_token( token_id: int, current_user: CurrentUser, db: DbSession, ): """Regenerate a widget token (deactivates old, creates new)""" result = await db.execute( select(WidgetToken) .options(selectinload(WidgetToken.participant)) .where(WidgetToken.id == token_id) ) old_token = result.scalar_one_or_none() if not old_token: raise HTTPException(status_code=404, detail="Token not found") if old_token.participant.user_id != current_user.id and not current_user.is_admin: raise HTTPException(status_code=403, detail="Not authorized") # Deactivate old token old_token.is_active = False # Create new token new_token = WidgetToken( token=generate_widget_token(), participant_id=old_token.participant_id, marathon_id=old_token.marathon_id, ) db.add(new_token) await db.commit() await db.refresh(new_token) return WidgetTokenResponse( id=new_token.id, token=new_token.token, created_at=new_token.created_at, expires_at=new_token.expires_at, is_active=new_token.is_active, urls=build_widget_urls(new_token.marathon_id, new_token.token), ) # === Public widget endpoints (authenticated via widget token) === async def validate_widget_token(token: str, marathon_id: int, db) -> WidgetToken: """Validate widget token and return it""" result = await db.execute( select(WidgetToken) .options( selectinload(WidgetToken.participant).selectinload(Participant.user), selectinload(WidgetToken.marathon), ) .where( WidgetToken.token == token, WidgetToken.marathon_id == marathon_id, WidgetToken.is_active == True, ) ) widget_token = result.scalar_one_or_none() if not widget_token: raise HTTPException(status_code=401, detail="Invalid widget token") if widget_token.expires_at and widget_token.expires_at < datetime.utcnow(): raise HTTPException(status_code=401, detail="Widget token expired") return widget_token @router.get("/data/leaderboard", response_model=WidgetLeaderboardResponse) async def widget_leaderboard( marathon: int = Query(..., description="Marathon ID"), token: str = Query(..., description="Widget token"), count: int = Query(5, ge=1, le=50, description="Number of participants"), db: DbSession = None, ): """Get leaderboard data for widget""" widget_token = await validate_widget_token(token, marathon, db) current_participant = widget_token.participant # Get all participants ordered by points result = await db.execute( select(Participant) .options(selectinload(Participant.user)) .where(Participant.marathon_id == marathon) .order_by(Participant.total_points.desc()) ) all_participants = result.scalars().all() total_participants = len(all_participants) current_user_rank = None # Find current user rank and build entries entries = [] for rank, p in enumerate(all_participants, 1): if p.id == current_participant.id: current_user_rank = rank if rank <= count: user = p.user entries.append(WidgetLeaderboardEntry( rank=rank, nickname=user.nickname, avatar_url=get_avatar_url(user), total_points=p.total_points, current_streak=p.current_streak, is_current_user=(p.id == current_participant.id), )) return WidgetLeaderboardResponse( entries=entries, current_user_rank=current_user_rank, total_participants=total_participants, marathon_title=widget_token.marathon.title, ) @router.get("/data/current", response_model=WidgetCurrentResponse) async def widget_current_assignment( marathon: int = Query(..., description="Marathon ID"), token: str = Query(..., description="Widget token"), db: DbSession = None, ): """Get current assignment data for widget""" widget_token = await validate_widget_token(token, marathon, db) participant = widget_token.participant # Get active assignment result = await db.execute( select(Assignment) .options( selectinload(Assignment.challenge), selectinload(Assignment.game), ) .where( Assignment.participant_id == participant.id, Assignment.status.in_([ AssignmentStatus.ACTIVE.value, AssignmentStatus.RETURNED.value, ]), ) .order_by(Assignment.started_at.desc()) .limit(1) ) assignment = result.scalar_one_or_none() if not assignment: return WidgetCurrentResponse(has_assignment=False) # Determine assignment type and details if assignment.is_playthrough: game = assignment.game assignment_type = "playthrough" challenge_title = "Прохождение" challenge_description = game.playthrough_description points = game.playthrough_points difficulty = None # Count bonus challenges bonus_result = await db.execute( select(func.count()).select_from(BonusAssignment) .where(BonusAssignment.main_assignment_id == assignment.id) ) bonus_total = bonus_result.scalar() or 0 completed_result = await db.execute( select(func.count()).select_from(BonusAssignment) .where( BonusAssignment.main_assignment_id == assignment.id, BonusAssignment.status == BonusAssignmentStatus.COMPLETED.value, ) ) bonus_completed = completed_result.scalar() or 0 game_title = game.title game_cover_url = f"/api/v1/games/{game.id}/cover" if game.cover_path else None else: challenge = assignment.challenge assignment_type = "challenge" challenge_title = challenge.title challenge_description = challenge.description points = challenge.points difficulty = challenge.difficulty bonus_completed = None bonus_total = None game = challenge.game if hasattr(challenge, 'game') else None if not game: # Load game via challenge from app.models import Game game_result = await db.execute( select(Game).where(Game.id == challenge.game_id) ) game = game_result.scalar_one_or_none() game_title = game.title if game else None game_cover_url = f"/api/v1/games/{game.id}/cover" if game and game.cover_path else None return WidgetCurrentResponse( has_assignment=True, game_title=game_title, game_cover_url=game_cover_url, assignment_type=assignment_type, challenge_title=challenge_title, challenge_description=challenge_description, points=points, difficulty=difficulty, bonus_completed=bonus_completed, bonus_total=bonus_total, ) @router.get("/data/progress", response_model=WidgetProgressResponse) async def widget_progress( marathon: int = Query(..., description="Marathon ID"), token: str = Query(..., description="Widget token"), db: DbSession = None, ): """Get participant progress data for widget""" widget_token = await validate_widget_token(token, marathon, db) participant = widget_token.participant user = participant.user # Calculate rank result = await db.execute( select(func.count()) .select_from(Participant) .where( Participant.marathon_id == marathon, Participant.total_points > participant.total_points, ) ) higher_count = result.scalar() or 0 rank = higher_count + 1 # Count completed and dropped assignments completed_result = await db.execute( select(func.count()) .select_from(Assignment) .where( Assignment.participant_id == participant.id, Assignment.status == AssignmentStatus.COMPLETED.value, ) ) completed_count = completed_result.scalar() or 0 dropped_result = await db.execute( select(func.count()) .select_from(Assignment) .where( Assignment.participant_id == participant.id, Assignment.status == AssignmentStatus.DROPPED.value, ) ) dropped_count = dropped_result.scalar() or 0 return WidgetProgressResponse( nickname=user.nickname, avatar_url=get_avatar_url(user), rank=rank, total_points=participant.total_points, current_streak=participant.current_streak, completed_count=completed_count, dropped_count=dropped_count, marathon_title=widget_token.marathon.title, )