initial
This commit is contained in:
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create upload directories
|
||||
RUN mkdir -p /app/uploads/avatars /app/uploads/covers /app/uploads/proofs
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
40
backend/alembic.ini
Normal file
40
backend/alembic.ini
Normal file
@@ -0,0 +1,40 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
63
backend/alembic/env.py
Normal file
63
backend/alembic/env.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base
|
||||
from app.models import * # noqa: F401, F403
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Game Marathon Backend
|
||||
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
|
||||
]
|
||||
19
backend/app/core/__init__.py
Normal file
19
backend/app/core/__init__.py
Normal 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",
|
||||
]
|
||||
44
backend/app/core/config.py
Normal file
44
backend/app/core/config.py
Normal 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()
|
||||
29
backend/app/core/database.py
Normal file
29
backend/app/core/database.py
Normal 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()
|
||||
37
backend/app/core/security.py
Normal file
37
backend/app/core/security.py
Normal 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
57
backend/app/main.py
Normal 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"}
|
||||
23
backend/app/models/__init__.py
Normal file
23
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
30
backend/app/models/activity.py
Normal file
30
backend/app/models/activity.py
Normal 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")
|
||||
32
backend/app/models/assignment.py
Normal file
32
backend/app/models/assignment.py
Normal 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")
|
||||
53
backend/app/models/challenge.py
Normal file
53
backend/app/models/challenge.py
Normal 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"
|
||||
)
|
||||
27
backend/app/models/game.py
Normal file
27
backend/app/models/game.py
Normal 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"
|
||||
)
|
||||
48
backend/app/models/marathon.py
Normal file
48
backend/app/models/marathon.py
Normal 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"
|
||||
)
|
||||
29
backend/app/models/participant.py
Normal file
29
backend/app/models/participant.py
Normal 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"
|
||||
)
|
||||
33
backend/app/models/user.py
Normal file
33
backend/app/models/user.py
Normal 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"
|
||||
)
|
||||
90
backend/app/schemas/__init__.py
Normal file
90
backend/app/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
21
backend/app/schemas/activity.py
Normal file
21
backend/app/schemas/activity.py
Normal 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
|
||||
50
backend/app/schemas/assignment.py
Normal file
50
backend/app/schemas/assignment.py
Normal 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
|
||||
53
backend/app/schemas/challenge.py
Normal file
53
backend/app/schemas/challenge.py
Normal 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
|
||||
14
backend/app/schemas/common.py
Normal file
14
backend/app/schemas/common.py
Normal 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
|
||||
40
backend/app/schemas/game.py
Normal file
40
backend/app/schemas/game.py
Normal 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
|
||||
76
backend/app/schemas/marathon.py
Normal file
76
backend/app/schemas/marathon.py
Normal 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
|
||||
54
backend/app/schemas/user.py
Normal file
54
backend/app/schemas/user.py
Normal 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
|
||||
4
backend/app/services/__init__.py
Normal file
4
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.services.points import PointsService
|
||||
from app.services.gpt import GPTService
|
||||
|
||||
__all__ = ["PointsService", "GPTService"]
|
||||
96
backend/app/services/gpt.py
Normal file
96
backend/app/services/gpt.py
Normal 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
|
||||
55
backend/app/services/points.py
Normal file
55
backend/app/services/points.py
Normal 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
|
||||
)
|
||||
32
backend/requirements.txt
Normal file
32
backend/requirements.txt
Normal file
@@ -0,0 +1,32 @@
|
||||
# FastAPI
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# Database
|
||||
sqlalchemy[asyncio]==2.0.25
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
|
||||
# Auth
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
|
||||
# Validation
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
email-validator==2.1.0
|
||||
|
||||
# OpenAI
|
||||
openai==1.12.0
|
||||
|
||||
# Telegram notifications
|
||||
httpx==0.26.0
|
||||
|
||||
# File handling
|
||||
aiofiles==23.2.1
|
||||
python-magic==0.4.27
|
||||
|
||||
# Utils
|
||||
python-dotenv==1.0.0
|
||||
Reference in New Issue
Block a user