This commit is contained in:
2025-12-14 02:38:35 +07:00
commit 5343a8f2c3
84 changed files with 7406 additions and 0 deletions

View File

@@ -0,0 +1 @@
# API module

50
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,50 @@
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models import User
security = HTTPBearer()
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
return user
# Type aliases for cleaner dependency injection
CurrentUser = Annotated[User, Depends(get_current_user)]
DbSession = Annotated[AsyncSession, Depends(get_db)]

View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed
router = APIRouter(prefix="/api/v1")
router.include_router(auth.router)
router.include_router(users.router)
router.include_router(marathons.router)
router.include_router(games.router)
router.include_router(challenges.router)
router.include_router(wheel.router)
router.include_router(feed.router)

View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from app.api.deps import DbSession, CurrentUser
from app.core.security import verify_password, get_password_hash, create_access_token
from app.models import User
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPublic
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse)
async def register(data: UserRegister, db: DbSession):
# Check if login already exists
result = await db.execute(select(User).where(User.login == data.login.lower()))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Login already registered",
)
# Create user
user = User(
login=data.login.lower(),
password_hash=get_password_hash(data.password),
nickname=data.nickname,
)
db.add(user)
await db.commit()
await db.refresh(user)
# Generate token
access_token = create_access_token(subject=user.id)
return TokenResponse(
access_token=access_token,
user=UserPublic.model_validate(user),
)
@router.post("/login", response_model=TokenResponse)
async def login(data: UserLogin, db: DbSession):
# Find user
result = await db.execute(select(User).where(User.login == data.login.lower()))
user = result.scalar_one_or_none()
if not user or not verify_password(data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect login or password",
)
# Generate token
access_token = create_access_token(subject=user.id)
return TokenResponse(
access_token=access_token,
user=UserPublic.model_validate(user),
)
@router.get("/me", response_model=UserPublic)
async def get_me(current_user: CurrentUser):
return UserPublic.model_validate(current_user)

View File

@@ -0,0 +1,268 @@
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import Marathon, MarathonStatus, Game, Challenge, Participant
from app.schemas import (
ChallengeCreate,
ChallengeUpdate,
ChallengeResponse,
MessageResponse,
GameShort,
)
from app.services.gpt import GPTService
router = APIRouter(tags=["challenges"])
gpt_service = GPTService()
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
result = await db.execute(
select(Challenge)
.options(selectinload(Challenge.game))
.where(Challenge.id == challenge_id)
)
challenge = result.scalar_one_or_none()
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
return challenge
async def check_participant(db, user_id: int, marathon_id: int) -> Participant:
result = await db.execute(
select(Participant).where(
Participant.user_id == user_id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
return participant
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
# Get game and check access
result = await db.execute(
select(Game).where(Game.id == game_id)
)
game = result.scalar_one_or_none()
if not game:
raise HTTPException(status_code=404, detail="Game not found")
await check_participant(db, current_user.id, game.marathon_id)
result = await db.execute(
select(Challenge)
.where(Challenge.game_id == game_id)
.order_by(Challenge.difficulty, Challenge.created_at)
)
challenges = result.scalars().all()
return [
ChallengeResponse(
id=c.id,
title=c.title,
description=c.description,
type=c.type,
difficulty=c.difficulty,
points=c.points,
estimated_time=c.estimated_time,
proof_type=c.proof_type,
proof_hint=c.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=c.is_generated,
created_at=c.created_at,
)
for c in challenges
]
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
async def create_challenge(
game_id: int,
data: ChallengeCreate,
current_user: CurrentUser,
db: DbSession,
):
# Get game and check access
result = await db.execute(
select(Game).where(Game.id == game_id)
)
game = result.scalar_one_or_none()
if not game:
raise HTTPException(status_code=404, detail="Game not found")
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon")
await check_participant(db, current_user.id, game.marathon_id)
challenge = Challenge(
game_id=game_id,
title=data.title,
description=data.description,
type=data.type.value,
difficulty=data.difficulty.value,
points=data.points,
estimated_time=data.estimated_time,
proof_type=data.proof_type.value,
proof_hint=data.proof_hint,
is_generated=False,
)
db.add(challenge)
await db.commit()
await db.refresh(challenge)
return ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
)
@router.post("/marathons/{marathon_id}/generate-challenges", response_model=MessageResponse)
async def generate_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Generate challenges for all games in marathon using GPT"""
# Check marathon
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot generate challenges for active or finished marathon")
await check_participant(db, current_user.id, marathon_id)
# Get all games
result = await db.execute(
select(Game).where(Game.marathon_id == marathon_id)
)
games = result.scalars().all()
if not games:
raise HTTPException(status_code=400, detail="No games in marathon")
generated_count = 0
for game in games:
# Check if game already has challenges
existing = await db.scalar(
select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
)
if existing:
continue # Skip if already has challenges
try:
challenges_data = await gpt_service.generate_challenges(game.title, game.genre)
for ch_data in challenges_data:
challenge = Challenge(
game_id=game.id,
title=ch_data.title,
description=ch_data.description,
type=ch_data.type,
difficulty=ch_data.difficulty,
points=ch_data.points,
estimated_time=ch_data.estimated_time,
proof_type=ch_data.proof_type,
proof_hint=ch_data.proof_hint,
is_generated=True,
)
db.add(challenge)
generated_count += 1
except Exception as e:
# Log error but continue with other games
print(f"Error generating challenges for {game.title}: {e}")
await db.commit()
return MessageResponse(message=f"Generated {generated_count} challenges")
@router.patch("/challenges/{challenge_id}", response_model=ChallengeResponse)
async def update_challenge(
challenge_id: int,
data: ChallengeUpdate,
current_user: CurrentUser,
db: DbSession,
):
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update challenges in active or finished marathon")
await check_participant(db, current_user.id, challenge.game.marathon_id)
if data.title is not None:
challenge.title = data.title
if data.description is not None:
challenge.description = data.description
if data.type is not None:
challenge.type = data.type.value
if data.difficulty is not None:
challenge.difficulty = data.difficulty.value
if data.points is not None:
challenge.points = data.points
if data.estimated_time is not None:
challenge.estimated_time = data.estimated_time
if data.proof_type is not None:
challenge.proof_type = data.proof_type.value
if data.proof_hint is not None:
challenge.proof_hint = data.proof_hint
await db.commit()
await db.refresh(challenge)
game = challenge.game
return ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
)
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
await check_participant(db, current_user.id, challenge.game.marathon_id)
await db.delete(challenge)
await db.commit()
return MessageResponse(message="Challenge deleted")

View File

@@ -0,0 +1,62 @@
from fastapi import APIRouter
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import Activity, Participant
from app.schemas import FeedResponse, ActivityResponse, UserPublic
router = APIRouter(tags=["feed"])
@router.get("/marathons/{marathon_id}/feed", response_model=FeedResponse)
async def get_feed(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
limit: int = 20,
offset: int = 0,
):
"""Get activity feed for marathon"""
# Check user is participant
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
if not result.scalar_one_or_none():
return FeedResponse(items=[], total=0, has_more=False)
# Get total count
total = await db.scalar(
select(func.count()).select_from(Activity).where(Activity.marathon_id == marathon_id)
)
# Get activities
result = await db.execute(
select(Activity)
.options(selectinload(Activity.user))
.where(Activity.marathon_id == marathon_id)
.order_by(Activity.created_at.desc())
.limit(limit)
.offset(offset)
)
activities = result.scalars().all()
items = [
ActivityResponse(
id=a.id,
type=a.type,
user=UserPublic.model_validate(a.user),
data=a.data,
created_at=a.created_at,
)
for a in activities
]
return FeedResponse(
items=items,
total=total,
has_more=(offset + limit) < total,
)

222
backend/app/api/v1/games.py Normal file
View File

@@ -0,0 +1,222 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
import uuid
from pathlib import Path
from app.api.deps import DbSession, CurrentUser
from app.core.config import settings
from app.models import Marathon, MarathonStatus, Game, Challenge, Participant
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
router = APIRouter(tags=["games"])
async def get_game_or_404(db, game_id: int) -> Game:
result = await db.execute(
select(Game)
.options(selectinload(Game.added_by_user))
.where(Game.id == game_id)
)
game = result.scalar_one_or_none()
if not game:
raise HTTPException(status_code=404, detail="Game not found")
return game
async def check_participant(db, user_id: int, marathon_id: int) -> Participant:
result = await db.execute(
select(Participant).where(
Participant.user_id == user_id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
return participant
@router.get("/marathons/{marathon_id}/games", response_model=list[GameResponse])
async def list_games(marathon_id: int, current_user: CurrentUser, db: DbSession):
await check_participant(db, current_user.id, marathon_id)
result = await db.execute(
select(Game, func.count(Challenge.id).label("challenges_count"))
.outerjoin(Challenge)
.options(selectinload(Game.added_by_user))
.where(Game.marathon_id == marathon_id)
.group_by(Game.id)
.order_by(Game.created_at.desc())
)
games = []
for row in result.all():
game = row[0]
games.append(GameResponse(
id=game.id,
title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
download_url=game.download_url,
genre=game.genre,
added_by=UserPublic.model_validate(game.added_by_user) if game.added_by_user else None,
challenges_count=row[1],
created_at=game.created_at,
))
return games
@router.post("/marathons/{marathon_id}/games", response_model=GameResponse)
async def add_game(
marathon_id: int,
data: GameCreate,
current_user: CurrentUser,
db: DbSession,
):
# Check marathon exists and is preparing
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot add games to active or finished marathon")
await check_participant(db, current_user.id, marathon_id)
game = Game(
marathon_id=marathon_id,
title=data.title,
download_url=data.download_url,
genre=data.genre,
added_by_id=current_user.id,
)
db.add(game)
await db.commit()
await db.refresh(game)
return GameResponse(
id=game.id,
title=game.title,
cover_url=None,
download_url=game.download_url,
genre=game.genre,
added_by=UserPublic.model_validate(current_user),
challenges_count=0,
created_at=game.created_at,
)
@router.get("/games/{game_id}", response_model=GameResponse)
async def get_game(game_id: int, current_user: CurrentUser, db: DbSession):
game = await get_game_or_404(db, game_id)
await check_participant(db, current_user.id, game.marathon_id)
challenges_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id)
)
return GameResponse(
id=game.id,
title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
download_url=game.download_url,
genre=game.genre,
added_by=UserPublic.model_validate(game.added_by_user) if game.added_by_user else None,
challenges_count=challenges_count,
created_at=game.created_at,
)
@router.patch("/games/{game_id}", response_model=GameResponse)
async def update_game(
game_id: int,
data: GameUpdate,
current_user: CurrentUser,
db: DbSession,
):
game = await get_game_or_404(db, game_id)
# Check if marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update games in active or finished marathon")
# Only the one who added or organizer can update
if game.added_by_id != current_user.id and marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only the one who added the game or organizer can update it")
if data.title is not None:
game.title = data.title
if data.download_url is not None:
game.download_url = data.download_url
if data.genre is not None:
game.genre = data.genre
await db.commit()
return await get_game(game_id, current_user, db)
@router.delete("/games/{game_id}", response_model=MessageResponse)
async def delete_game(game_id: int, current_user: CurrentUser, db: DbSession):
game = await get_game_or_404(db, game_id)
# Check if marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot delete games from active or finished marathon")
# Only the one who added or organizer can delete
if game.added_by_id != current_user.id and marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only the one who added the game or organizer can delete it")
await db.delete(game)
await db.commit()
return MessageResponse(message="Game deleted")
@router.post("/games/{game_id}/cover", response_model=GameResponse)
async def upload_cover(
game_id: int,
current_user: CurrentUser,
db: DbSession,
file: UploadFile = File(...),
):
game = await get_game_or_404(db, game_id)
await check_participant(db, current_user.id, game.marathon_id)
# Validate file
if not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
if ext not in settings.ALLOWED_IMAGE_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
)
# Save file
filename = f"{game_id}_{uuid.uuid4().hex}.{ext}"
filepath = Path(settings.UPLOAD_DIR) / "covers" / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "wb") as f:
f.write(contents)
game.cover_path = str(filepath)
await db.commit()
return await get_game(game_id, current_user, db)

View File

@@ -0,0 +1,358 @@
from datetime import timedelta
import secrets
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import Marathon, Participant, MarathonStatus, Game, Assignment, AssignmentStatus, Activity, ActivityType
from app.schemas import (
MarathonCreate,
MarathonUpdate,
MarathonResponse,
MarathonListItem,
JoinMarathon,
ParticipantInfo,
ParticipantWithUser,
LeaderboardEntry,
MessageResponse,
UserPublic,
)
router = APIRouter(prefix="/marathons", tags=["marathons"])
def generate_invite_code() -> str:
return secrets.token_urlsafe(8)
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
result = await db.execute(
select(Marathon)
.options(selectinload(Marathon.organizer))
.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 or organizer"""
result = await db.execute(
select(Marathon, func.count(Participant.id).label("participants_count"))
.outerjoin(Participant)
.where(
(Marathon.organizer_id == current_user.id) |
(Participant.user_id == current_user.id)
)
.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,
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,
organizer_id=current_user.id,
invite_code=generate_invite_code(),
start_date=start_date,
end_date=end_date,
)
db.add(marathon)
await db.flush()
# Auto-add organizer as participant
participant = Participant(
user_id=current_user.id,
marathon_id=marathon.id,
)
db.add(participant)
await db.commit()
await db.refresh(marathon)
return MarathonResponse(
id=marathon.id,
title=marathon.title,
description=marathon.description,
organizer=UserPublic.model_validate(current_user),
status=marathon.status,
invite_code=marathon.invite_code,
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)
# Count participants and 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)
)
# 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,
organizer=UserPublic.model_validate(marathon.organizer),
status=marathon.status,
invite_code=marathon.invite_code,
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,
):
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only organizer can update marathon")
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
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):
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only organizer can delete marathon")
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):
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only organizer can start marathon")
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
# Check if there are games with challenges
games_count = await db.scalar(
select(func.count()).select_from(Game).where(Game.marathon_id == marathon_id)
)
if games_count == 0:
raise HTTPException(status_code=400, detail="Add at least one game before starting")
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()
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):
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only organizer can finish marathon")
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()
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(Marathon.invite_code == data.invite_code)
)
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,
)
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):
await get_marathon_or_404(db, marathon_id)
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,
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.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
async def get_leaderboard(marathon_id: int, db: DbSession):
await get_marathon_or_404(db, marathon_id)
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

104
backend/app/api/v1/users.py Normal file
View File

@@ -0,0 +1,104 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File
from sqlalchemy import select
import uuid
from pathlib import Path
from app.api.deps import DbSession, CurrentUser
from app.core.config import settings
from app.models import User
from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/{user_id}", response_model=UserPublic)
async def get_user(user_id: int, db: DbSession):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return UserPublic.model_validate(user)
@router.patch("/me", response_model=UserPublic)
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
if data.nickname is not None:
current_user.nickname = data.nickname
await db.commit()
await db.refresh(current_user)
return UserPublic.model_validate(current_user)
@router.post("/me/avatar", response_model=UserPublic)
async def upload_avatar(
current_user: CurrentUser,
db: DbSession,
file: UploadFile = File(...),
):
# Validate file
if 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}",
)
# Save file
filename = f"{current_user.id}_{uuid.uuid4().hex}.{ext}"
filepath = Path(settings.UPLOAD_DIR) / "avatars" / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "wb") as f:
f.write(contents)
# Update user
current_user.avatar_path = str(filepath)
await db.commit()
await db.refresh(current_user)
return UserPublic.model_validate(current_user)
@router.post("/me/telegram", response_model=MessageResponse)
async def link_telegram(
data: TelegramLink,
current_user: CurrentUser,
db: DbSession,
):
# Check if telegram_id already linked to another user
result = await db.execute(
select(User).where(User.telegram_id == data.telegram_id, User.id != current_user.id)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This Telegram account is already linked to another user",
)
current_user.telegram_id = data.telegram_id
current_user.telegram_username = data.telegram_username
await db.commit()
return MessageResponse(message="Telegram account linked successfully")

404
backend/app/api/v1/wheel.py Normal file
View File

@@ -0,0 +1,404 @@
import random
from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
import uuid
from pathlib import Path
from app.api.deps import DbSession, CurrentUser
from app.core.config import settings
from app.models import (
Marathon, MarathonStatus, Game, Challenge, Participant,
Assignment, AssignmentStatus, Activity, ActivityType
)
from app.schemas import (
SpinResult, AssignmentResponse, CompleteResult, DropResult,
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
)
from app.services.points import PointsService
router = APIRouter(tags=["wheel"])
points_service = PointsService()
async def get_participant_or_403(db, user_id: int, marathon_id: int) -> Participant:
result = await db.execute(
select(Participant).where(
Participant.user_id == user_id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
return participant
async def get_active_assignment(db, participant_id: int) -> Assignment | None:
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant_id,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
)
return result.scalar_one_or_none()
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Spin the wheel to get a random game and challenge"""
# Check marathon is active
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.status != MarathonStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Marathon is not active")
participant = await get_participant_or_403(db, current_user.id, marathon_id)
# Check no active assignment
active = await get_active_assignment(db, participant.id)
if active:
raise HTTPException(status_code=400, detail="You already have an active assignment")
# Get all games with challenges
result = await db.execute(
select(Game)
.options(selectinload(Game.challenges))
.where(Game.marathon_id == marathon_id)
)
games = [g for g in result.scalars().all() if g.challenges]
if not games:
raise HTTPException(status_code=400, detail="No games with challenges available")
# Random selection
game = random.choice(games)
challenge = random.choice(game.challenges)
# Create assignment
assignment = Assignment(
participant_id=participant.id,
challenge_id=challenge.id,
status=AssignmentStatus.ACTIVE.value,
)
db.add(assignment)
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.SPIN.value,
data={
"game": game.title,
"challenge": challenge.title,
},
)
db.add(activity)
await db.commit()
await db.refresh(assignment)
# Calculate drop penalty
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count)
return SpinResult(
assignment_id=assignment.id,
game=GameResponse(
id=game.id,
title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
download_url=game.download_url,
genre=game.genre,
added_by=None,
challenges_count=len(game.challenges),
created_at=game.created_at,
),
challenge=ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
),
can_drop=True,
drop_penalty=drop_penalty,
)
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Get current active assignment"""
participant = await get_participant_or_403(db, current_user.id, marathon_id)
assignment = await get_active_assignment(db, participant.id)
if not assignment:
return None
challenge = assignment.challenge
game = challenge.game
return AssignmentResponse(
id=assignment.id,
challenge=ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
),
status=assignment.status,
proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" if assignment.proof_path else assignment.proof_url,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
)
@router.post("/assignments/{assignment_id}/complete", response_model=CompleteResult)
async def complete_assignment(
assignment_id: int,
current_user: CurrentUser,
db: DbSession,
proof_url: str | None = Form(None),
comment: str | None = Form(None),
proof_file: UploadFile | None = File(None),
):
"""Complete an assignment with proof"""
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if assignment.participant.user_id != current_user.id:
raise HTTPException(status_code=403, detail="This is not your assignment")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Assignment is not active")
# Need either file or URL
if not proof_file and not proof_url:
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
# Handle file upload
if proof_file:
contents = await proof_file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
)
filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}"
filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "wb") as f:
f.write(contents)
assignment.proof_path = str(filepath)
else:
assignment.proof_url = proof_url
assignment.proof_comment = comment
# Calculate points
participant = assignment.participant
challenge = assignment.challenge
total_points, streak_bonus = points_service.calculate_completion_points(
challenge.points, participant.current_streak
)
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
assignment.points_earned = total_points
assignment.streak_at_completion = participant.current_streak + 1
assignment.completed_at = datetime.utcnow()
# Update participant
participant.total_points += total_points
participant.current_streak += 1
participant.drop_count = 0 # Reset drop counter on success
# Get marathon_id for activity
result = await db.execute(
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
)
full_challenge = result.scalar_one()
# Log activity
activity = Activity(
marathon_id=full_challenge.game.marathon_id,
user_id=current_user.id,
type=ActivityType.COMPLETE.value,
data={
"challenge": challenge.title,
"points": total_points,
"streak": participant.current_streak,
},
)
db.add(activity)
await db.commit()
return CompleteResult(
points_earned=total_points,
streak_bonus=streak_bonus,
total_points=participant.total_points,
new_streak=participant.current_streak,
)
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
"""Drop current assignment"""
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if assignment.participant.user_id != current_user.id:
raise HTTPException(status_code=403, detail="This is not your assignment")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Assignment is not active")
participant = assignment.participant
# Calculate penalty
penalty = points_service.calculate_drop_penalty(participant.drop_count)
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Update participant
participant.total_points = max(0, participant.total_points - penalty)
participant.current_streak = 0
participant.drop_count += 1
# Log activity
activity = Activity(
marathon_id=assignment.challenge.game.marathon_id,
user_id=current_user.id,
type=ActivityType.DROP.value,
data={
"challenge": assignment.challenge.title,
"penalty": penalty,
},
)
db.add(activity)
await db.commit()
return DropResult(
penalty=penalty,
total_points=participant.total_points,
new_drop_count=participant.drop_count,
)
@router.get("/marathons/{marathon_id}/my-history", response_model=list[AssignmentResponse])
async def get_my_history(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
limit: int = 20,
offset: int = 0,
):
"""Get history of user's assignments in marathon"""
participant = await get_participant_or_403(db, current_user.id, marathon_id)
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(Assignment.participant_id == participant.id)
.order_by(Assignment.started_at.desc())
.limit(limit)
.offset(offset)
)
assignments = result.scalars().all()
return [
AssignmentResponse(
id=a.id,
challenge=ChallengeResponse(
id=a.challenge.id,
title=a.challenge.title,
description=a.challenge.description,
type=a.challenge.type,
difficulty=a.challenge.difficulty,
points=a.challenge.points,
estimated_time=a.challenge.estimated_time,
proof_type=a.challenge.proof_type,
proof_hint=a.challenge.proof_hint,
game=GameShort(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=None
),
is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at,
),
status=a.status,
proof_url=f"/uploads/proofs/{a.proof_path.split('/')[-1]}" if a.proof_path else a.proof_url,
proof_comment=a.proof_comment,
points_earned=a.points_earned,
streak_at_completion=a.streak_at_completion,
started_at=a.started_at,
completed_at=a.completed_at,
)
for a in assignments
]