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

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Game Marathon Backend

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
]

View File

@@ -0,0 +1,19 @@
from app.core.config import settings
from app.core.database import Base, get_db, engine
from app.core.security import (
verify_password,
get_password_hash,
create_access_token,
decode_access_token,
)
__all__ = [
"settings",
"Base",
"get_db",
"engine",
"verify_password",
"get_password_hash",
"create_access_token",
"decode_access_token",
]

View File

@@ -0,0 +1,44 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# App
APP_NAME: str = "Game Marathon"
DEBUG: bool = False
# Database
DATABASE_URL: str = "postgresql+asyncpg://marathon:marathon@localhost:5432/marathon"
# Security
SECRET_KEY: str = "your-secret-key-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
# OpenAI
OPENAI_API_KEY: str = ""
# Telegram
TELEGRAM_BOT_TOKEN: str = ""
# Uploads
UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE: int = 15 * 1024 * 1024 # 15 MB
ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"}
ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"}
@property
def ALLOWED_EXTENSIONS(self) -> set:
return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS
class Config:
env_file = ".env"
extra = "ignore"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

View File

@@ -0,0 +1,29 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
future=True,
)
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session_maker() as session:
try:
yield session
finally:
await session.close()

View File

@@ -0,0 +1,37 @@
from datetime import datetime, timedelta
from typing import Any
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(subject: int | Any, expires_delta: timedelta | None = None) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_access_token(token: str) -> dict | None:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except jwt.JWTError:
return None

57
backend/app/main.py Normal file
View File

@@ -0,0 +1,57 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from app.core.config import settings
from app.core.database import engine, Base
from app.api.v1 import router as api_router
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Create upload directories
upload_dir = Path(settings.UPLOAD_DIR)
(upload_dir / "avatars").mkdir(parents=True, exist_ok=True)
(upload_dir / "covers").mkdir(parents=True, exist_ok=True)
(upload_dir / "proofs").mkdir(parents=True, exist_ok=True)
yield
# Shutdown
await engine.dispose()
app = FastAPI(
title=settings.APP_NAME,
version="1.0.0",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Static files for uploads
upload_path = Path(settings.UPLOAD_DIR)
if upload_path.exists():
app.mount("/uploads", StaticFiles(directory=str(upload_path)), name="uploads")
# API routes
app.include_router(api_router)
@app.get("/health")
async def health_check():
return {"status": "ok"}

View File

@@ -0,0 +1,23 @@
from app.models.user import User
from app.models.marathon import Marathon, MarathonStatus
from app.models.participant import Participant
from app.models.game import Game
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
from app.models.assignment import Assignment, AssignmentStatus
from app.models.activity import Activity, ActivityType
__all__ = [
"User",
"Marathon",
"MarathonStatus",
"Participant",
"Game",
"Challenge",
"ChallengeType",
"Difficulty",
"ProofType",
"Assignment",
"AssignmentStatus",
"Activity",
"ActivityType",
]

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, DateTime, ForeignKey, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class ActivityType(str, Enum):
JOIN = "join"
SPIN = "spin"
COMPLETE = "complete"
DROP = "drop"
START_MARATHON = "start_marathon"
FINISH_MARATHON = "finish_marathon"
class Activity(Base):
__tablename__ = "activities"
id: Mapped[int] = mapped_column(primary_key=True)
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
type: Mapped[str] = mapped_column(String(30), nullable=False)
data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
# Relationships
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="activities")
user: Mapped["User"] = relationship("User")

View File

@@ -0,0 +1,32 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class AssignmentStatus(str, Enum):
ACTIVE = "active"
COMPLETED = "completed"
DROPPED = "dropped"
class Assignment(Base):
__tablename__ = "assignments"
id: Mapped[int] = mapped_column(primary_key=True)
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True)
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
points_earned: Mapped[int] = mapped_column(Integer, default=0)
streak_at_completion: Mapped[int | None] = mapped_column(Integer, nullable=True)
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")

View File

@@ -0,0 +1,53 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class ChallengeType(str, Enum):
COMPLETION = "completion"
NO_DEATH = "no_death"
SPEEDRUN = "speedrun"
COLLECTION = "collection"
ACHIEVEMENT = "achievement"
CHALLENGE_RUN = "challenge_run"
SCORE_ATTACK = "score_attack"
TIME_TRIAL = "time_trial"
class Difficulty(str, Enum):
EASY = "easy"
MEDIUM = "medium"
HARD = "hard"
class ProofType(str, Enum):
SCREENSHOT = "screenshot"
VIDEO = "video"
STEAM = "steam"
class Challenge(Base):
__tablename__ = "challenges"
id: Mapped[int] = mapped_column(primary_key=True)
game_id: Mapped[int] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
type: Mapped[str] = mapped_column(String(30), nullable=False)
difficulty: Mapped[str] = mapped_column(String(10), nullable=False)
points: Mapped[int] = mapped_column(Integer, nullable=False)
estimated_time: Mapped[int | None] = mapped_column(Integer, nullable=True) # in minutes
proof_type: Mapped[str] = mapped_column(String(20), nullable=False)
proof_hint: Mapped[str | None] = mapped_column(Text, nullable=True)
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
assignments: Mapped[list["Assignment"]] = relationship(
"Assignment",
back_populates="challenge"
)

View File

@@ -0,0 +1,27 @@
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Game(Base):
__tablename__ = "games"
id: Mapped[int] = mapped_column(primary_key=True)
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(100), nullable=False)
cover_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
download_url: Mapped[str] = mapped_column(Text, nullable=False)
genre: Mapped[str | None] = mapped_column(String(50), nullable=True)
added_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games")
added_by_user: Mapped["User"] = relationship("User", back_populates="added_games")
challenges: Mapped[list["Challenge"]] = relationship(
"Challenge",
back_populates="game",
cascade="all, delete-orphan"
)

View File

@@ -0,0 +1,48 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class MarathonStatus(str, Enum):
PREPARING = "preparing"
ACTIVE = "active"
FINISHED = "finished"
class Marathon(Base):
__tablename__ = "marathons"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
organizer_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(20), default=MarathonStatus.PREPARING.value)
invite_code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True)
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
organizer: Mapped["User"] = relationship(
"User",
back_populates="organized_marathons",
foreign_keys=[organizer_id]
)
participants: Mapped[list["Participant"]] = relationship(
"Participant",
back_populates="marathon",
cascade="all, delete-orphan"
)
games: Mapped[list["Game"]] = relationship(
"Game",
back_populates="marathon",
cascade="all, delete-orphan"
)
activities: Mapped[list["Activity"]] = relationship(
"Activity",
back_populates="marathon",
cascade="all, delete-orphan"
)

View File

@@ -0,0 +1,29 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Participant(Base):
__tablename__ = "participants"
__table_args__ = (
UniqueConstraint("user_id", "marathon_id", name="unique_user_marathon"),
)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), index=True)
total_points: Mapped[int] = mapped_column(Integer, default=0)
current_streak: Mapped[int] = mapped_column(Integer, default=0)
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
user: Mapped["User"] = relationship("User", back_populates="participations")
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants")
assignments: Mapped[list["Assignment"]] = relationship(
"Assignment",
back_populates="participant",
cascade="all, delete-orphan"
)

View File

@@ -0,0 +1,33 @@
from datetime import datetime
from sqlalchemy import String, BigInteger, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
login: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
nickname: Mapped[str] = mapped_column(String(50), nullable=False)
avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
telegram_username: Mapped[str | None] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
organized_marathons: Mapped[list["Marathon"]] = relationship(
"Marathon",
back_populates="organizer",
foreign_keys="Marathon.organizer_id"
)
participations: Mapped[list["Participant"]] = relationship(
"Participant",
back_populates="user"
)
added_games: Mapped[list["Game"]] = relationship(
"Game",
back_populates="added_by_user"
)

View File

@@ -0,0 +1,90 @@
from app.schemas.user import (
UserRegister,
UserLogin,
UserUpdate,
UserPublic,
UserWithTelegram,
TokenResponse,
TelegramLink,
)
from app.schemas.marathon import (
MarathonCreate,
MarathonUpdate,
MarathonResponse,
MarathonListItem,
ParticipantInfo,
ParticipantWithUser,
JoinMarathon,
LeaderboardEntry,
)
from app.schemas.game import (
GameCreate,
GameUpdate,
GameResponse,
GameShort,
)
from app.schemas.challenge import (
ChallengeCreate,
ChallengeUpdate,
ChallengeResponse,
ChallengeGenerated,
)
from app.schemas.assignment import (
CompleteAssignment,
AssignmentResponse,
SpinResult,
CompleteResult,
DropResult,
)
from app.schemas.activity import (
ActivityResponse,
FeedResponse,
)
from app.schemas.common import (
MessageResponse,
ErrorResponse,
PaginationParams,
)
__all__ = [
# User
"UserRegister",
"UserLogin",
"UserUpdate",
"UserPublic",
"UserWithTelegram",
"TokenResponse",
"TelegramLink",
# Marathon
"MarathonCreate",
"MarathonUpdate",
"MarathonResponse",
"MarathonListItem",
"ParticipantInfo",
"ParticipantWithUser",
"JoinMarathon",
"LeaderboardEntry",
# Game
"GameCreate",
"GameUpdate",
"GameResponse",
"GameShort",
# Challenge
"ChallengeCreate",
"ChallengeUpdate",
"ChallengeResponse",
"ChallengeGenerated",
# Assignment
"CompleteAssignment",
"AssignmentResponse",
"SpinResult",
"CompleteResult",
"DropResult",
# Activity
"ActivityResponse",
"FeedResponse",
# Common
"MessageResponse",
"ErrorResponse",
"PaginationParams",
]

View File

@@ -0,0 +1,21 @@
from datetime import datetime
from pydantic import BaseModel
from app.schemas.user import UserPublic
class ActivityResponse(BaseModel):
id: int
type: str
user: UserPublic
data: dict | None = None
created_at: datetime
class Config:
from_attributes = True
class FeedResponse(BaseModel):
items: list[ActivityResponse]
total: int
has_more: bool

View File

@@ -0,0 +1,50 @@
from datetime import datetime
from pydantic import BaseModel
from app.schemas.game import GameResponse
from app.schemas.challenge import ChallengeResponse
class AssignmentBase(BaseModel):
pass
class CompleteAssignment(BaseModel):
proof_url: str | None = None
comment: str | None = None
class AssignmentResponse(BaseModel):
id: int
challenge: ChallengeResponse
status: str
proof_url: str | None = None
proof_comment: str | None = None
points_earned: int
streak_at_completion: int | None = None
started_at: datetime
completed_at: datetime | None = None
class Config:
from_attributes = True
class SpinResult(BaseModel):
assignment_id: int
game: GameResponse
challenge: ChallengeResponse
can_drop: bool
drop_penalty: int
class CompleteResult(BaseModel):
points_earned: int
streak_bonus: int
total_points: int
new_streak: int
class DropResult(BaseModel):
penalty: int
total_points: int
new_drop_count: int

View File

@@ -0,0 +1,53 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.models.challenge import ChallengeType, Difficulty, ProofType
from app.schemas.game import GameShort
class ChallengeBase(BaseModel):
title: str = Field(..., min_length=1, max_length=100)
description: str = Field(..., min_length=1)
type: ChallengeType
difficulty: Difficulty
points: int = Field(..., ge=1, le=500)
estimated_time: int | None = Field(None, ge=1) # minutes
proof_type: ProofType
proof_hint: str | None = None
class ChallengeCreate(ChallengeBase):
pass
class ChallengeUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None
type: ChallengeType | None = None
difficulty: Difficulty | None = None
points: int | None = Field(None, ge=1, le=500)
estimated_time: int | None = None
proof_type: ProofType | None = None
proof_hint: str | None = None
class ChallengeResponse(ChallengeBase):
id: int
game: GameShort
is_generated: bool
created_at: datetime
class Config:
from_attributes = True
class ChallengeGenerated(BaseModel):
"""Schema for GPT-generated challenges"""
title: str
description: str
type: str
difficulty: str
points: int
estimated_time: int | None = None
proof_type: str
proof_hint: str | None = None

View File

@@ -0,0 +1,14 @@
from pydantic import BaseModel
class MessageResponse(BaseModel):
message: str
class ErrorResponse(BaseModel):
detail: str
class PaginationParams(BaseModel):
limit: int = 20
offset: int = 0

View File

@@ -0,0 +1,40 @@
from datetime import datetime
from pydantic import BaseModel, Field, HttpUrl
from app.schemas.user import UserPublic
class GameBase(BaseModel):
title: str = Field(..., min_length=1, max_length=100)
download_url: str = Field(..., min_length=1)
genre: str | None = Field(None, max_length=50)
class GameCreate(GameBase):
cover_url: str | None = None
class GameUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=100)
download_url: str | None = None
genre: str | None = None
class GameShort(BaseModel):
id: int
title: str
cover_url: str | None = None
class Config:
from_attributes = True
class GameResponse(GameBase):
id: int
cover_url: str | None = None
added_by: UserPublic | None = None
challenges_count: int = 0
created_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,76 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.user import UserPublic
class MarathonBase(BaseModel):
title: str = Field(..., min_length=1, max_length=100)
description: str | None = None
class MarathonCreate(MarathonBase):
start_date: datetime
duration_days: int = Field(default=30, ge=1, le=365)
class MarathonUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None
start_date: datetime | None = None
class ParticipantInfo(BaseModel):
id: int
total_points: int
current_streak: int
drop_count: int
joined_at: datetime
class Config:
from_attributes = True
class ParticipantWithUser(ParticipantInfo):
user: UserPublic
class MarathonResponse(MarathonBase):
id: int
organizer: UserPublic
status: str
invite_code: str
start_date: datetime | None
end_date: datetime | None
participants_count: int
games_count: int
created_at: datetime
my_participation: ParticipantInfo | None = None
class Config:
from_attributes = True
class MarathonListItem(BaseModel):
id: int
title: str
status: str
participants_count: int
start_date: datetime | None
end_date: datetime | None
class Config:
from_attributes = True
class JoinMarathon(BaseModel):
invite_code: str
class LeaderboardEntry(BaseModel):
rank: int
user: UserPublic
total_points: int
current_streak: int
completed_count: int
dropped_count: int

View File

@@ -0,0 +1,54 @@
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
import re
class UserBase(BaseModel):
nickname: str = Field(..., min_length=2, max_length=50)
class UserRegister(UserBase):
login: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=6, max_length=100)
@field_validator("login")
@classmethod
def validate_login(cls, v: str) -> str:
if not re.match(r"^[a-zA-Z0-9_]+$", v):
raise ValueError("Login can only contain letters, numbers, and underscores")
return v.lower()
class UserLogin(BaseModel):
login: str
password: str
class UserUpdate(BaseModel):
nickname: str | None = Field(None, min_length=2, max_length=50)
class UserPublic(UserBase):
id: int
login: str
avatar_url: str | None = None
created_at: datetime
class Config:
from_attributes = True
class UserWithTelegram(UserPublic):
telegram_id: int | None = None
telegram_username: str | None = None
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserPublic
class TelegramLink(BaseModel):
telegram_id: int
telegram_username: str | None = None

View File

@@ -0,0 +1,4 @@
from app.services.points import PointsService
from app.services.gpt import GPTService
__all__ = ["PointsService", "GPTService"]

View File

@@ -0,0 +1,96 @@
import json
from openai import AsyncOpenAI
from app.core.config import settings
from app.schemas import ChallengeGenerated
class GPTService:
"""Service for generating challenges using OpenAI GPT"""
def __init__(self):
self.client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
async def generate_challenges(
self,
game_title: str,
game_genre: str | None = None
) -> list[ChallengeGenerated]:
"""
Generate challenges for a game using GPT.
Args:
game_title: Name of the game
game_genre: Optional genre of the game
Returns:
List of generated challenges
"""
genre_text = f" (жанр: {game_genre})" if game_genre else ""
prompt = f"""Для видеоигры "{game_title}"{genre_text} сгенерируй 6 челленджей для игрового марафона.
Требования:
- 2 лёгких челленджа (15-30 минут игры)
- 2 средних челленджа (1-2 часа игры)
- 2 сложных челленджа (3+ часов или высокая сложность)
Для каждого челленджа укажи:
- title: короткое название на русском (до 50 символов)
- description: что нужно сделать на русском (1-2 предложения)
- type: один из [completion, no_death, speedrun, collection, achievement, challenge_run]
- difficulty: easy/medium/hard
- points: очки (easy: 30-50, medium: 60-100, hard: 120-200)
- estimated_time: примерное время в минутах
- proof_type: screenshot/video/steam (что лучше подойдёт для проверки)
- proof_hint: что должно быть на скриншоте/видео для подтверждения на русском
Ответь ТОЛЬКО валидным JSON объектом с ключом "challenges" содержащим массив челленджей.
Пример формата:
{{"challenges": [{{"title": "...", "description": "...", "type": "...", "difficulty": "...", "points": 50, "estimated_time": 30, "proof_type": "...", "proof_hint": "..."}}]}}"""
response = await self.client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0.7,
max_tokens=2000,
)
content = response.choices[0].message.content
data = json.loads(content)
challenges = []
for ch in data.get("challenges", []):
# Validate and normalize type
ch_type = ch.get("type", "completion")
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
ch_type = "completion"
# Validate difficulty
difficulty = ch.get("difficulty", "medium")
if difficulty not in ["easy", "medium", "hard"]:
difficulty = "medium"
# Validate proof_type
proof_type = ch.get("proof_type", "screenshot")
if proof_type not in ["screenshot", "video", "steam"]:
proof_type = "screenshot"
# Validate points
points = ch.get("points", 50)
if not isinstance(points, int) or points < 1:
points = 50
challenges.append(ChallengeGenerated(
title=ch.get("title", "Unnamed Challenge")[:100],
description=ch.get("description", "Complete the challenge"),
type=ch_type,
difficulty=difficulty,
points=points,
estimated_time=ch.get("estimated_time"),
proof_type=proof_type,
proof_hint=ch.get("proof_hint"),
))
return challenges

View File

@@ -0,0 +1,55 @@
class PointsService:
"""Service for calculating points and penalties"""
STREAK_MULTIPLIERS = {
0: 0.0,
1: 0.0,
2: 0.1,
3: 0.2,
4: 0.3,
}
MAX_STREAK_MULTIPLIER = 0.4
DROP_PENALTIES = {
0: 0, # First drop is free
1: 10,
2: 25,
}
MAX_DROP_PENALTY = 50
def calculate_completion_points(
self,
base_points: int,
current_streak: int
) -> tuple[int, int]:
"""
Calculate points earned for completing a challenge.
Args:
base_points: Base points for the challenge
current_streak: Current streak before this completion
Returns:
Tuple of (total_points, streak_bonus)
"""
multiplier = self.STREAK_MULTIPLIERS.get(
current_streak,
self.MAX_STREAK_MULTIPLIER
)
bonus = int(base_points * multiplier)
return base_points + bonus, bonus
def calculate_drop_penalty(self, consecutive_drops: int) -> int:
"""
Calculate penalty for dropping a challenge.
Args:
consecutive_drops: Number of drops since last completion
Returns:
Penalty points to subtract
"""
return self.DROP_PENALTIES.get(
consecutive_drops,
self.MAX_DROP_PENALTY
)