initial
This commit is contained in:
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API module
|
||||
50
backend/app/api/deps.py
Normal file
50
backend/app/api/deps.py
Normal 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)]
|
||||
13
backend/app/api/v1/__init__.py
Normal file
13
backend/app/api/v1/__init__.py
Normal 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)
|
||||
64
backend/app/api/v1/auth.py
Normal file
64
backend/app/api/v1/auth.py
Normal 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)
|
||||
268
backend/app/api/v1/challenges.py
Normal file
268
backend/app/api/v1/challenges.py
Normal 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")
|
||||
62
backend/app/api/v1/feed.py
Normal file
62
backend/app/api/v1/feed.py
Normal 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
222
backend/app/api/v1/games.py
Normal 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)
|
||||
358
backend/app/api/v1/marathons.py
Normal file
358
backend/app/api/v1/marathons.py
Normal 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
104
backend/app/api/v1/users.py
Normal 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
404
backend/app/api/v1/wheel.py
Normal 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
|
||||
]
|
||||
Reference in New Issue
Block a user