Add 3 roles, settings for marathons

This commit is contained in:
2025-12-14 20:21:56 +07:00
parent bb9e9a6e1d
commit d0b8eca600
28 changed files with 1679 additions and 290 deletions

View File

@@ -17,7 +17,11 @@ help:
@echo ""
@echo " Build:"
@echo " make build - Build all containers (with cache)"
@echo " make build-no-cache - Rebuild all containers (no cache)"
@echo " make build-no-cache - Build all containers (no cache)"
@echo " make reup - Rebuild with cache: down + build + up"
@echo " make rebuild - Full rebuild: down + build --no-cache + up"
@echo " make rebuild-frontend - Rebuild only frontend"
@echo " make rebuild-backend - Rebuild only backend"
@echo ""
@echo " Database:"
@echo " make migrate - Run database migrations"
@@ -60,12 +64,28 @@ build:
build-no-cache:
$(DC) build --no-cache
reup:
$(DC) down
$(DC) build
$(DC) up -d
rebuild:
$(DC) down
$(DC) build --no-cache
$(DC) up -d
rebuild-frontend:
$(DC) down
sudo docker rmi marathon-frontend || true
$(DC) build --no-cache frontend
$(DC) up -d
rebuild-backend:
$(DC) down
sudo docker rmi marathon-backend || true
$(DC) build --no-cache backend
$(DC) up -d
# Database
migrate:
$(DC) exec backend alembic upgrade head

View File

@@ -0,0 +1,72 @@
"""Add roles system
Revision ID: 001_add_roles
Revises:
Create Date: 2024-12-14
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '001_add_roles'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add role column to users table
op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user'))
# Add role column to participants table
op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant'))
# Rename organizer_id to creator_id in marathons table
op.alter_column('marathons', 'organizer_id', new_column_name='creator_id')
# Update existing participants: set role='organizer' for marathon creators
op.execute("""
UPDATE participants p
SET role = 'organizer'
FROM marathons m
WHERE p.marathon_id = m.id AND p.user_id = m.creator_id
""")
# Add status column to games table
op.add_column('games', sa.Column('status', sa.String(20), nullable=False, server_default='approved'))
# Rename added_by_id to proposed_by_id in games table
op.alter_column('games', 'added_by_id', new_column_name='proposed_by_id')
# Add approved_by_id column to games table
op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True))
op.create_foreign_key(
'fk_games_approved_by_id',
'games', 'users',
['approved_by_id'], ['id'],
ondelete='SET NULL'
)
def downgrade() -> None:
# Remove approved_by_id from games
op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey')
op.drop_column('games', 'approved_by_id')
# Rename proposed_by_id back to added_by_id
op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id')
# Remove status from games
op.drop_column('games', 'status')
# Rename creator_id back to organizer_id
op.alter_column('marathons', 'creator_id', new_column_name='organizer_id')
# Remove role from participants
op.drop_column('participants', 'role')
# Remove role from users
op.drop_column('users', 'role')

View File

@@ -0,0 +1,32 @@
"""Add marathon settings (is_public, game_proposal_mode)
Revision ID: 002_marathon_settings
Revises: 001_add_roles
Create Date: 2024-12-14
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '002_marathon_settings'
down_revision: Union[str, None] = '001_add_roles'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add is_public column to marathons table (default False = private)
op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false'))
# Add game_proposal_mode column to marathons table
# 'all_participants' - anyone can propose games (with moderation)
# 'organizer_only' - only organizers can add games
op.add_column('marathons', sa.Column('game_proposal_mode', sa.String(20), nullable=False, server_default='all_participants'))
def downgrade() -> None:
op.drop_column('marathons', 'game_proposal_mode')
op.drop_column('marathons', 'is_public')

View File

@@ -0,0 +1,38 @@
"""Create admin user
Revision ID: 003_create_admin
Revises: 002_marathon_settings
Create Date: 2024-12-14
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from passlib.context import CryptContext
# revision identifiers, used by Alembic.
revision: str = '003_create_admin'
down_revision: Union[str, None] = '002_marathon_settings'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def upgrade() -> None:
# Hash the password
password_hash = pwd_context.hash("RPQ586qq")
# Insert admin user (ignore if already exists)
op.execute(f"""
INSERT INTO users (login, password_hash, nickname, role, created_at)
VALUES ('admin', '{password_hash}', 'Admin', 'admin', NOW())
ON CONFLICT (login) DO UPDATE SET
password_hash = '{password_hash}',
role = 'admin'
""")
def downgrade() -> None:
op.execute("DELETE FROM users WHERE login = 'admin'")

View File

@@ -7,7 +7,7 @@ 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
from app.models import User, Participant, Marathon, UserRole, ParticipantRole
security = HTTPBearer()
@@ -45,6 +45,103 @@ async def get_current_user(
return user
def require_admin(user: User) -> User:
"""Check if user is admin"""
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user
async def get_participant(
db: AsyncSession,
user_id: int,
marathon_id: int,
) -> Participant | None:
"""Get participant record for user in marathon"""
result = await db.execute(
select(Participant).where(
Participant.user_id == user_id,
Participant.marathon_id == marathon_id,
)
)
return result.scalar_one_or_none()
async def require_participant(
db: AsyncSession,
user_id: int,
marathon_id: int,
) -> Participant:
"""Require user to be participant of marathon"""
participant = await get_participant(db, user_id, marathon_id)
if not participant:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant of this marathon",
)
return participant
async def require_organizer(
db: AsyncSession,
user: User,
marathon_id: int,
) -> Participant:
"""Require user to be organizer of marathon (or admin)"""
if user.is_admin:
# Admins can act as organizers
participant = await get_participant(db, user.id, marathon_id)
if participant:
return participant
# Create virtual participant for admin
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")
# Return a temporary object for admin
return Participant(
user_id=user.id,
marathon_id=marathon_id,
role=ParticipantRole.ORGANIZER.value
)
participant = await get_participant(db, user.id, marathon_id)
if not participant:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant of this marathon",
)
if not participant.is_organizer:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only organizers can perform this action",
)
return participant
async def require_creator(
db: AsyncSession,
user: User,
marathon_id: int,
) -> Marathon:
"""Require user to be creator of marathon (or admin)"""
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 not user.is_admin and marathon.creator_id != user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the creator can perform this action",
)
return marathon
# Type aliases for cleaner dependency injection
CurrentUser = Annotated[User, Depends(get_current_user)]
DbSession = Annotated[AsyncSession, Depends(get_db)]

View File

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

260
backend/app/api/v1/admin.py Normal file
View File

@@ -0,0 +1,260 @@
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, Field
from app.api.deps import DbSession, CurrentUser, require_admin
from app.models import User, UserRole, Marathon, Participant, Game
from app.schemas import UserPublic, MarathonListItem, MessageResponse
router = APIRouter(prefix="/admin", tags=["admin"])
class SetUserRole(BaseModel):
role: str = Field(..., pattern="^(user|admin)$")
class AdminUserResponse(BaseModel):
id: int
login: str
nickname: str
role: str
avatar_url: str | None = None
telegram_id: int | None = None
telegram_username: str | None = None
marathons_count: int = 0
created_at: str
class Config:
from_attributes = True
class AdminMarathonResponse(BaseModel):
id: int
title: str
status: str
creator: UserPublic
participants_count: int
games_count: int
start_date: str | None
end_date: str | None
created_at: str
class Config:
from_attributes = True
@router.get("/users", response_model=list[AdminUserResponse])
async def list_users(
current_user: CurrentUser,
db: DbSession,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
search: str | None = None,
):
"""List all users. Admin only."""
require_admin(current_user)
query = select(User).order_by(User.created_at.desc())
if search:
query = query.where(
(User.login.ilike(f"%{search}%")) |
(User.nickname.ilike(f"%{search}%"))
)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
users = result.scalars().all()
response = []
for user in users:
# Count marathons user participates in
marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
response.append(AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
))
return response
@router.get("/users/{user_id}", response_model=AdminUserResponse)
async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
"""Get user details. Admin only."""
require_admin(current_user)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
return AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
)
@router.patch("/users/{user_id}/role", response_model=AdminUserResponse)
async def set_user_role(
user_id: int,
data: SetUserRole,
current_user: CurrentUser,
db: DbSession,
):
"""Set user's global role. Admin only."""
require_admin(current_user)
# Cannot change own role
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot change your own role")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.role = data.role
await db.commit()
await db.refresh(user)
marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
return AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
)
@router.delete("/users/{user_id}", response_model=MessageResponse)
async def delete_user(user_id: int, current_user: CurrentUser, db: DbSession):
"""Delete a user. Admin only."""
require_admin(current_user)
# Cannot delete yourself
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Cannot delete another admin
if user.role == UserRole.ADMIN.value:
raise HTTPException(status_code=400, detail="Cannot delete another admin")
await db.delete(user)
await db.commit()
return MessageResponse(message="User deleted")
@router.get("/marathons", response_model=list[AdminMarathonResponse])
async def list_marathons(
current_user: CurrentUser,
db: DbSession,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
search: str | None = None,
):
"""List all marathons. Admin only."""
require_admin(current_user)
query = (
select(Marathon)
.options(selectinload(Marathon.creator))
.order_by(Marathon.created_at.desc())
)
if search:
query = query.where(Marathon.title.ilike(f"%{search}%"))
query = query.offset(skip).limit(limit)
result = await db.execute(query)
marathons = result.scalars().all()
response = []
for marathon in marathons:
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)
)
response.append(AdminMarathonResponse(
id=marathon.id,
title=marathon.title,
status=marathon.status,
creator=UserPublic.model_validate(marathon.creator),
participants_count=participants_count,
games_count=games_count,
start_date=marathon.start_date.isoformat() if marathon.start_date else None,
end_date=marathon.end_date.isoformat() if marathon.end_date else None,
created_at=marathon.created_at.isoformat(),
))
return response
@router.delete("/marathons/{marathon_id}", response_model=MessageResponse)
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Delete a marathon. Admin only."""
require_admin(current_user)
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")
await db.delete(marathon)
await db.commit()
return MessageResponse(message="Marathon deleted")
@router.get("/stats")
async def get_stats(current_user: CurrentUser, db: DbSession):
"""Get platform statistics. Admin only."""
require_admin(current_user)
users_count = await db.scalar(select(func.count()).select_from(User))
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
games_count = await db.scalar(select(func.count()).select_from(Game))
participants_count = await db.scalar(select(func.count()).select_from(Participant))
return {
"users_count": users_count,
"marathons_count": marathons_count,
"games_count": games_count,
"total_participations": participants_count,
}

View File

@@ -2,8 +2,8 @@ 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.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge
from app.schemas import (
ChallengeCreate,
ChallengeUpdate,
@@ -33,21 +33,9 @@ async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
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):
"""List challenges for a game. Participants can view challenges for approved games only."""
# Get game and check access
result = await db.execute(
select(Game).where(Game.id == game_id)
@@ -56,7 +44,16 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession
if not game:
raise HTTPException(status_code=404, detail="Game not found")
await check_participant(db, current_user.id, game.marathon_id)
participant = await get_participant(db, current_user.id, game.marathon_id)
# Check access
if not current_user.is_admin:
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Regular participants can only see challenges for approved games or their own games
if not participant.is_organizer:
if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id:
raise HTTPException(status_code=403, detail="Game not accessible")
result = await db.execute(
select(Challenge)
@@ -91,6 +88,7 @@ async def create_challenge(
current_user: CurrentUser,
db: DbSession,
):
"""Create a challenge for a game. Organizers only."""
# Get game and check access
result = await db.execute(
select(Game).where(Game.id == game_id)
@@ -105,7 +103,12 @@ async def create_challenge(
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)
# Only organizers can add challenges
await require_organizer(db, current_user, game.marathon_id)
# Can only add challenges to approved games
if game.status != GameStatus.APPROVED.value:
raise HTTPException(status_code=400, detail="Can only add challenges to approved games")
challenge = Challenge(
game_id=game_id,
@@ -141,7 +144,7 @@ async def create_challenge(
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Generate challenges preview for all games in marathon using GPT (without saving)"""
"""Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only."""
# Check marathon
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
@@ -151,16 +154,20 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db
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)
# Only organizers can generate challenges
await require_organizer(db, current_user, marathon_id)
# Get all games
# Get only APPROVED games
result = await db.execute(
select(Game).where(Game.marathon_id == marathon_id)
select(Game).where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
)
)
games = result.scalars().all()
if not games:
raise HTTPException(status_code=400, detail="No games in marathon")
raise HTTPException(status_code=400, detail="No approved games in marathon")
preview_challenges = []
for game in games:
@@ -202,7 +209,7 @@ async def save_challenges(
current_user: CurrentUser,
db: DbSession,
):
"""Save previewed challenges to database"""
"""Save previewed challenges to database. Organizers only."""
# Check marathon
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
@@ -212,18 +219,22 @@ async def save_challenges(
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, marathon_id)
# Only organizers can save challenges
await require_organizer(db, current_user, marathon_id)
# Verify all games belong to this marathon
# Verify all games belong to this marathon AND are approved
result = await db.execute(
select(Game.id).where(Game.marathon_id == marathon_id)
select(Game.id).where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
)
)
valid_game_ids = set(row[0] for row in result.fetchall())
saved_count = 0
for ch_data in data.challenges:
if ch_data.game_id not in valid_game_ids:
continue # Skip challenges for invalid games
continue # Skip challenges for invalid/unapproved games
# Validate type
ch_type = ch_data.type
@@ -267,6 +278,7 @@ async def update_challenge(
current_user: CurrentUser,
db: DbSession,
):
"""Update a challenge. Organizers only."""
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
@@ -275,7 +287,8 @@ async def update_challenge(
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)
# Only organizers can update challenges
await require_organizer(db, current_user, challenge.game.marathon_id)
if data.title is not None:
challenge.title = data.title
@@ -316,6 +329,7 @@ async def update_challenge(
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
"""Delete a challenge. Organizers only."""
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
@@ -324,7 +338,8 @@ async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbS
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)
# Only organizers can delete challenges
await require_organizer(db, current_user, challenge.game.marathon_id)
await db.delete(challenge)
await db.commit()

View File

@@ -1,12 +1,15 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query
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.api.deps import (
DbSession, CurrentUser,
require_participant, require_organizer, get_participant,
)
from app.core.config import settings
from app.models import Marathon, MarathonStatus, Game, Challenge, Participant
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
router = APIRouter(tags=["games"])
@@ -15,7 +18,10 @@ 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))
.options(
selectinload(Game.proposed_by),
selectinload(Game.approved_by),
)
.where(Game.id == game_id)
)
game = result.scalar_one_or_none()
@@ -24,47 +30,84 @@ async def get_game_or_404(db, game_id: int) -> Game:
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,
)
def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
"""Convert Game model to GameResponse schema"""
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,
status=game.status,
proposed_by=UserPublic.model_validate(game.proposed_by) if game.proposed_by else None,
approved_by=UserPublic.model_validate(game.approved_by) if game.approved_by else None,
challenges_count=challenges_count,
created_at=game.created_at,
)
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)
async def list_games(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
status_filter: str | None = Query(None, alias="status"),
):
"""List games in marathon. Organizers/admins see all, participants see only approved."""
# Admins can view without being participant
participant = await get_participant(db, current_user.id, marathon_id)
if not participant and not current_user.is_admin:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
result = await db.execute(
query = (
select(Game, func.count(Challenge.id).label("challenges_count"))
.outerjoin(Challenge)
.options(selectinload(Game.added_by_user))
.options(
selectinload(Game.proposed_by),
selectinload(Game.approved_by),
)
.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,
))
# Filter by status if provided
is_organizer = current_user.is_admin or (participant and participant.is_organizer)
if status_filter:
query = query.where(Game.status == status_filter)
elif not is_organizer:
# Regular participants only see approved games + their own pending games
query = query.where(
(Game.status == GameStatus.APPROVED.value) |
(Game.proposed_by_id == current_user.id)
)
return games
result = await db.execute(query)
return [game_to_response(row[0], row[1]) for row in result.all()]
@router.get("/marathons/{marathon_id}/games/pending", response_model=list[GameResponse])
async def list_pending_games(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""List pending games for moderation. Organizers only."""
await require_organizer(db, current_user, marathon_id)
result = await db.execute(
select(Game, func.count(Challenge.id).label("challenges_count"))
.outerjoin(Challenge)
.options(
selectinload(Game.proposed_by),
selectinload(Game.approved_by),
)
.where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.PENDING.value,
)
.group_by(Game.id)
.order_by(Game.created_at.desc())
)
return [game_to_response(row[0], row[1]) for row in result.all()]
@router.post("/marathons/{marathon_id}/games", response_model=GameResponse)
@@ -74,6 +117,7 @@ async def add_game(
current_user: CurrentUser,
db: DbSession,
):
"""Propose a new game. Organizers can auto-approve."""
# Check marathon exists and is preparing
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
@@ -83,16 +127,36 @@ async def add_game(
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)
participant = await require_participant(db, current_user.id, marathon_id)
# Check if user can propose games based on marathon settings
is_organizer = participant.is_organizer or current_user.is_admin
if marathon.game_proposal_mode == GameProposalMode.ORGANIZER_ONLY.value and not is_organizer:
raise HTTPException(status_code=403, detail="Only organizers can add games to this marathon")
# Organizers can auto-approve their games
game_status = GameStatus.APPROVED.value if is_organizer else GameStatus.PENDING.value
game = Game(
marathon_id=marathon_id,
title=data.title,
download_url=data.download_url,
genre=data.genre,
added_by_id=current_user.id,
proposed_by_id=current_user.id,
status=game_status,
approved_by_id=current_user.id if is_organizer else None,
)
db.add(game)
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.ADD_GAME.value,
data={"title": game.title, "status": game_status},
)
db.add(activity)
await db.commit()
await db.refresh(game)
@@ -102,7 +166,9 @@ async def add_game(
cover_url=None,
download_url=game.download_url,
genre=game.genre,
added_by=UserPublic.model_validate(current_user),
status=game.status,
proposed_by=UserPublic.model_validate(current_user),
approved_by=UserPublic.model_validate(current_user) if is_organizer else None,
challenges_count=0,
created_at=game.created_at,
)
@@ -111,22 +177,21 @@ async def add_game(
@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)
participant = await get_participant(db, current_user.id, game.marathon_id)
# Check access: organizers see all, participants see approved + own
if not current_user.is_admin:
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
if not participant.is_organizer:
if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id:
raise HTTPException(status_code=403, detail="Game not found")
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,
)
return game_to_response(game, challenges_count)
@router.patch("/games/{game_id}", response_model=GameResponse)
@@ -144,9 +209,16 @@ async def update_game(
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")
participant = await get_participant(db, current_user.id, game.marathon_id)
# Only the one who proposed, organizers, or admin can update
can_update = (
current_user.is_admin or
(participant and participant.is_organizer) or
game.proposed_by_id == current_user.id
)
if not can_update:
raise HTTPException(status_code=403, detail="Only the one who proposed the game or organizer can update it")
if data.title is not None:
game.title = data.title
@@ -170,9 +242,16 @@ async def delete_game(game_id: int, current_user: CurrentUser, db: DbSession):
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")
participant = await get_participant(db, current_user.id, game.marathon_id)
# Only the one who proposed, organizers, or admin can delete
can_delete = (
current_user.is_admin or
(participant and participant.is_organizer) or
game.proposed_by_id == current_user.id
)
if not can_delete:
raise HTTPException(status_code=403, detail="Only the one who proposed the game or organizer can delete it")
await db.delete(game)
await db.commit()
@@ -180,6 +259,73 @@ async def delete_game(game_id: int, current_user: CurrentUser, db: DbSession):
return MessageResponse(message="Game deleted")
@router.post("/games/{game_id}/approve", response_model=GameResponse)
async def approve_game(game_id: int, current_user: CurrentUser, db: DbSession):
"""Approve a pending game. Organizers only."""
game = await get_game_or_404(db, game_id)
await require_organizer(db, current_user, game.marathon_id)
if game.status != GameStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Game is not pending")
game.status = GameStatus.APPROVED.value
game.approved_by_id = current_user.id
# Log activity
activity = Activity(
marathon_id=game.marathon_id,
user_id=current_user.id,
type=ActivityType.APPROVE_GAME.value,
data={"title": game.title},
)
db.add(activity)
await db.commit()
await db.refresh(game)
# Need to reload relationships
game = await get_game_or_404(db, game_id)
challenges_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id)
)
return game_to_response(game, challenges_count)
@router.post("/games/{game_id}/reject", response_model=GameResponse)
async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession):
"""Reject a pending game. Organizers only."""
game = await get_game_or_404(db, game_id)
await require_organizer(db, current_user, game.marathon_id)
if game.status != GameStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Game is not pending")
game.status = GameStatus.REJECTED.value
# Log activity
activity = Activity(
marathon_id=game.marathon_id,
user_id=current_user.id,
type=ActivityType.REJECT_GAME.value,
data={"title": game.title},
)
db.add(activity)
await db.commit()
await db.refresh(game)
# Need to reload relationships
game = await get_game_or_404(db, game_id)
challenges_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id)
)
return game_to_response(game, challenges_count)
@router.post("/games/{game_id}/cover", response_model=GameResponse)
async def upload_cover(
game_id: int,
@@ -188,7 +334,7 @@ async def upload_cover(
file: UploadFile = File(...),
):
game = await get_game_or_404(db, game_id)
await check_participant(db, current_user.id, game.marathon_id)
await require_participant(db, current_user.id, game.marathon_id)
# Validate file
if not file.content_type.startswith("image/"):

View File

@@ -4,8 +4,15 @@ 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.api.deps import (
DbSession, CurrentUser,
require_participant, require_organizer, require_creator,
get_participant,
)
from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
)
from app.schemas import (
MarathonCreate,
MarathonUpdate,
@@ -17,6 +24,7 @@ from app.schemas import (
LeaderboardEntry,
MessageResponse,
UserPublic,
SetParticipantRole,
)
router = APIRouter(prefix="/marathons", tags=["marathons"])
@@ -29,7 +37,7 @@ def generate_invite_code() -> str:
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
result = await db.execute(
select(Marathon)
.options(selectinload(Marathon.organizer))
.options(selectinload(Marathon.creator))
.where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
@@ -50,17 +58,28 @@ async def get_participation(db, user_id: int, marathon_id: int) -> Participant |
@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)
"""Get all marathons where user is participant, creator, or public marathons"""
# Admin can see all marathons
if current_user.is_admin:
result = await db.execute(
select(Marathon, func.count(Participant.id).label("participants_count"))
.outerjoin(Participant)
.group_by(Marathon.id)
.order_by(Marathon.created_at.desc())
)
else:
# User can see: own marathons, participated marathons, and public marathons
result = await db.execute(
select(Marathon, func.count(Participant.id).label("participants_count"))
.outerjoin(Participant)
.where(
(Marathon.creator_id == current_user.id) |
(Participant.user_id == current_user.id) |
(Marathon.is_public == True)
)
.group_by(Marathon.id)
.order_by(Marathon.created_at.desc())
)
.group_by(Marathon.id)
.order_by(Marathon.created_at.desc())
)
marathons = []
for row in result.all():
@@ -69,6 +88,7 @@ async def list_marathons(current_user: CurrentUser, db: DbSession):
id=marathon.id,
title=marathon.title,
status=marathon.status,
is_public=marathon.is_public,
participants_count=row[1],
start_date=marathon.start_date,
end_date=marathon.end_date,
@@ -90,18 +110,21 @@ async def create_marathon(
marathon = Marathon(
title=data.title,
description=data.description,
organizer_id=current_user.id,
creator_id=current_user.id,
invite_code=generate_invite_code(),
is_public=data.is_public,
game_proposal_mode=data.game_proposal_mode,
start_date=start_date,
end_date=end_date,
)
db.add(marathon)
await db.flush()
# Auto-add organizer as participant
# Auto-add creator as organizer participant
participant = Participant(
user_id=current_user.id,
marathon_id=marathon.id,
role=ParticipantRole.ORGANIZER.value, # Creator is organizer
)
db.add(participant)
@@ -112,9 +135,11 @@ async def create_marathon(
id=marathon.id,
title=marathon.title,
description=marathon.description,
organizer=UserPublic.model_validate(current_user),
creator=UserPublic.model_validate(current_user),
status=marathon.status,
invite_code=marathon.invite_code,
is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=1,
@@ -128,12 +153,15 @@ async def create_marathon(
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
# Count participants and approved 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)
select(func.count()).select_from(Game).where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
)
)
# Get user's participation
@@ -143,9 +171,11 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio
id=marathon.id,
title=marathon.title,
description=marathon.description,
organizer=UserPublic.model_validate(marathon.organizer),
creator=UserPublic.model_validate(marathon.creator),
status=marathon.status,
invite_code=marathon.invite_code,
is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=participants_count,
@@ -162,11 +192,10 @@ async def update_marathon(
current_user: CurrentUser,
db: DbSession,
):
# Require organizer role
await require_organizer(db, current_user, marathon_id)
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")
@@ -177,6 +206,10 @@ async def update_marathon(
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
if data.is_public is not None:
marathon.is_public = data.is_public
if data.game_proposal_mode is not None:
marathon.game_proposal_mode = data.game_proposal_mode
await db.commit()
@@ -185,11 +218,10 @@ async def update_marathon(
@router.delete("/{marathon_id}", response_model=MessageResponse)
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
# Only creator or admin can delete
await require_creator(db, current_user, marathon_id)
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()
@@ -198,20 +230,22 @@ async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
@router.post("/{marathon_id}/start", response_model=MarathonResponse)
async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
# Require organizer role
await require_organizer(db, current_user, marathon_id)
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
# Check if there are approved games with challenges
games_count = await db.scalar(
select(func.count()).select_from(Game).where(Game.marathon_id == marathon_id)
select(func.count()).select_from(Game).where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
)
)
if games_count == 0:
raise HTTPException(status_code=400, detail="Add at least one game before starting")
raise HTTPException(status_code=400, detail="Add and approve at least one game before starting")
marathon.status = MarathonStatus.ACTIVE.value
@@ -231,11 +265,10 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
@router.post("/{marathon_id}/finish", response_model=MarathonResponse)
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
# Require organizer role
await require_organizer(db, current_user, marathon_id)
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")
@@ -276,6 +309,44 @@ async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSes
participant = Participant(
user_id=current_user.id,
marathon_id=marathon.id,
role=ParticipantRole.PARTICIPANT.value, # Regular participant
)
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.post("/{marathon_id}/join", response_model=MarathonResponse)
async def join_public_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Join a public marathon without invite code"""
marathon = await get_marathon_or_404(db, marathon_id)
if not marathon.is_public:
raise HTTPException(status_code=403, detail="This marathon is private. Use invite code to join.")
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,
role=ParticipantRole.PARTICIPANT.value,
)
db.add(participant)
@@ -308,6 +379,7 @@ async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSe
return [
ParticipantWithUser(
id=p.id,
role=p.role,
total_points=p.total_points,
current_streak=p.current_streak,
drop_count=p.drop_count,
@@ -318,6 +390,50 @@ async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSe
]
@router.patch("/{marathon_id}/participants/{user_id}/role", response_model=ParticipantWithUser)
async def set_participant_role(
marathon_id: int,
user_id: int,
data: SetParticipantRole,
current_user: CurrentUser,
db: DbSession,
):
"""Set participant's role (only creator can do this)"""
# Only creator can change roles
marathon = await require_creator(db, current_user, marathon_id)
# Cannot change creator's role
if user_id == marathon.creator_id:
raise HTTPException(status_code=400, detail="Cannot change creator's role")
# Get participant
result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(
Participant.marathon_id == marathon_id,
Participant.user_id == user_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
participant.role = data.role
await db.commit()
await db.refresh(participant)
return ParticipantWithUser(
id=participant.id,
role=participant.role,
total_points=participant.total_points,
current_streak=participant.current_streak,
drop_count=participant.drop_count,
joined_at=participant.joined_at,
user=UserPublic.model_validate(participant.user),
)
@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)

View File

@@ -1,17 +1,21 @@
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.user import User, UserRole
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode
from app.models.participant import Participant, ParticipantRole
from app.models.game import Game, GameStatus
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",
"UserRole",
"Marathon",
"MarathonStatus",
"GameProposalMode",
"Participant",
"ParticipantRole",
"Game",
"GameStatus",
"Challenge",
"ChallengeType",
"Difficulty",

View File

@@ -13,6 +13,9 @@ class ActivityType(str, Enum):
DROP = "drop"
START_MARATHON = "start_marathon"
FINISH_MARATHON = "finish_marathon"
ADD_GAME = "add_game"
APPROVE_GAME = "approve_game"
REJECT_GAME = "reject_game"
class Activity(Base):

View File

@@ -1,10 +1,17 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, DateTime, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class GameStatus(str, Enum):
PENDING = "pending" # Предложена участником, ждёт модерации
APPROVED = "approved" # Одобрена организатором
REJECTED = "rejected" # Отклонена
class Game(Base):
__tablename__ = "games"
@@ -14,14 +21,33 @@ class Game(Base):
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)
status: Mapped[str] = mapped_column(String(20), default=GameStatus.PENDING.value)
proposed_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
approved_by_id: Mapped[int | None] = 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")
proposed_by: Mapped["User"] = relationship(
"User",
back_populates="proposed_games",
foreign_keys=[proposed_by_id]
)
approved_by: Mapped["User | None"] = relationship(
"User",
back_populates="approved_games",
foreign_keys=[approved_by_id]
)
challenges: Mapped[list["Challenge"]] = relationship(
"Challenge",
back_populates="game",
cascade="all, delete-orphan"
)
@property
def is_approved(self) -> bool:
return self.status == GameStatus.APPROVED.value
@property
def is_pending(self) -> bool:
return self.status == GameStatus.PENDING.value

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -12,24 +12,31 @@ class MarathonStatus(str, Enum):
FINISHED = "finished"
class GameProposalMode(str, Enum):
ALL_PARTICIPANTS = "all_participants"
ORGANIZER_ONLY = "organizer_only"
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"))
creator_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)
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
game_proposal_mode: Mapped[str] = mapped_column(String(20), default=GameProposalMode.ALL_PARTICIPANTS.value)
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(
creator: Mapped["User"] = relationship(
"User",
back_populates="organized_marathons",
foreign_keys=[organizer_id]
back_populates="created_marathons",
foreign_keys=[creator_id]
)
participants: Mapped[list["Participant"]] = relationship(
"Participant",

View File

@@ -1,10 +1,16 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint
from enum import Enum
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class ParticipantRole(str, Enum):
PARTICIPANT = "participant"
ORGANIZER = "organizer"
class Participant(Base):
__tablename__ = "participants"
__table_args__ = (
@@ -14,6 +20,7 @@ class Participant(Base):
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)
role: Mapped[str] = mapped_column(String(20), default=ParticipantRole.PARTICIPANT.value)
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
@@ -27,3 +34,7 @@ class Participant(Base):
back_populates="participant",
cascade="all, delete-orphan"
)
@property
def is_organizer(self) -> bool:
return self.role == ParticipantRole.ORGANIZER.value

View File

@@ -1,10 +1,16 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, BigInteger, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class UserRole(str, Enum):
USER = "user"
ADMIN = "admin"
class User(Base):
__tablename__ = "users"
@@ -15,19 +21,36 @@ class User(Base):
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)
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
organized_marathons: Mapped[list["Marathon"]] = relationship(
created_marathons: Mapped[list["Marathon"]] = relationship(
"Marathon",
back_populates="organizer",
foreign_keys="Marathon.organizer_id"
back_populates="creator",
foreign_keys="Marathon.creator_id"
)
participations: Mapped[list["Participant"]] = relationship(
"Participant",
back_populates="user"
)
added_games: Mapped[list["Game"]] = relationship(
proposed_games: Mapped[list["Game"]] = relationship(
"Game",
back_populates="added_by_user"
back_populates="proposed_by",
foreign_keys="Game.proposed_by_id"
)
approved_games: Mapped[list["Game"]] = relationship(
"Game",
back_populates="approved_by",
foreign_keys="Game.approved_by_id"
)
@property
def is_admin(self) -> bool:
return self.role == UserRole.ADMIN.value
@property
def avatar_url(self) -> str | None:
if self.avatar_path:
return f"/uploads/avatars/{self.avatar_path.split('/')[-1]}"
return None

View File

@@ -16,6 +16,7 @@ from app.schemas.marathon import (
ParticipantWithUser,
JoinMarathon,
LeaderboardEntry,
SetParticipantRole,
)
from app.schemas.game import (
GameCreate,
@@ -68,6 +69,7 @@ __all__ = [
"ParticipantWithUser",
"JoinMarathon",
"LeaderboardEntry",
"SetParticipantRole",
# Game
"GameCreate",
"GameUpdate",

View File

@@ -32,7 +32,9 @@ class GameShort(BaseModel):
class GameResponse(GameBase):
id: int
cover_url: str | None = None
added_by: UserPublic | None = None
status: str = "pending"
proposed_by: UserPublic | None = None
approved_by: UserPublic | None = None
challenges_count: int = 0
created_at: datetime

View File

@@ -12,16 +12,21 @@ class MarathonBase(BaseModel):
class MarathonCreate(MarathonBase):
start_date: datetime
duration_days: int = Field(default=30, ge=1, le=365)
is_public: bool = False
game_proposal_mode: str = Field(default="all_participants", pattern="^(all_participants|organizer_only)$")
class MarathonUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None
start_date: datetime | None = None
is_public: bool | None = None
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
class ParticipantInfo(BaseModel):
id: int
role: str = "participant"
total_points: int
current_streak: int
drop_count: int
@@ -37,9 +42,11 @@ class ParticipantWithUser(ParticipantInfo):
class MarathonResponse(MarathonBase):
id: int
organizer: UserPublic
creator: UserPublic
status: str
invite_code: str
is_public: bool
game_proposal_mode: str
start_date: datetime | None
end_date: datetime | None
participants_count: int
@@ -51,10 +58,15 @@ class MarathonResponse(MarathonBase):
from_attributes = True
class SetParticipantRole(BaseModel):
role: str = Field(..., pattern="^(participant|organizer)$")
class MarathonListItem(BaseModel):
id: int
title: str
status: str
is_public: bool
participants_count: int
start_date: datetime | None
end_date: datetime | None

View File

@@ -32,6 +32,7 @@ class UserPublic(UserBase):
id: int
login: str
avatar_url: str | None = None
role: str = "user"
created_at: datetime
class Config:

44
frontend/src/api/admin.ts Normal file
View File

@@ -0,0 +1,44 @@
import client from './client'
import type { AdminUser, AdminMarathon, UserRole, PlatformStats } from '@/types'
export const adminApi = {
// Users
listUsers: async (skip = 0, limit = 50, search?: string): Promise<AdminUser[]> => {
const params: Record<string, unknown> = { skip, limit }
if (search) params.search = search
const response = await client.get<AdminUser[]>('/admin/users', { params })
return response.data
},
getUser: async (id: number): Promise<AdminUser> => {
const response = await client.get<AdminUser>(`/admin/users/${id}`)
return response.data
},
setUserRole: async (id: number, role: UserRole): Promise<AdminUser> => {
const response = await client.patch<AdminUser>(`/admin/users/${id}/role`, { role })
return response.data
},
deleteUser: async (id: number): Promise<void> => {
await client.delete(`/admin/users/${id}`)
},
// Marathons
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
const params: Record<string, unknown> = { skip, limit }
if (search) params.search = search
const response = await client.get<AdminMarathon[]>('/admin/marathons', { params })
return response.data
},
deleteMarathon: async (id: number): Promise<void> => {
await client.delete(`/admin/marathons/${id}`)
},
// Stats
getStats: async (): Promise<PlatformStats> => {
const response = await client.get<PlatformStats>('/admin/stats')
return response.data
},
}

View File

@@ -1,5 +1,5 @@
import client from './client'
import type { Game, Challenge, ChallengePreview, ChallengesPreviewResponse } from '@/types'
import type { Game, GameStatus, Challenge, ChallengePreview, ChallengesPreviewResponse } from '@/types'
export interface CreateGameData {
title: string
@@ -20,8 +20,14 @@ export interface CreateChallengeData {
}
export const gamesApi = {
list: async (marathonId: number): Promise<Game[]> => {
const response = await client.get<Game[]>(`/marathons/${marathonId}/games`)
list: async (marathonId: number, status?: GameStatus): Promise<Game[]> => {
const params = status ? { status } : {}
const response = await client.get<Game[]>(`/marathons/${marathonId}/games`, { params })
return response.data
},
listPending: async (marathonId: number): Promise<Game[]> => {
const response = await client.get<Game[]>(`/marathons/${marathonId}/games/pending`)
return response.data
},
@@ -39,6 +45,16 @@ export const gamesApi = {
await client.delete(`/games/${id}`)
},
approve: async (id: number): Promise<Game> => {
const response = await client.post<Game>(`/games/${id}/approve`)
return response.data
},
reject: async (id: number): Promise<Game> => {
const response = await client.post<Game>(`/games/${id}/reject`)
return response.data
},
uploadCover: async (id: number, file: File): Promise<Game> => {
const formData = new FormData()
formData.append('file', file)

View File

@@ -3,3 +3,4 @@ export { marathonsApi } from './marathons'
export { gamesApi } from './games'
export { wheelApi } from './wheel'
export { feedApi } from './feed'
export { adminApi } from './admin'

View File

@@ -1,15 +1,13 @@
import client from './client'
import type { Marathon, MarathonListItem, LeaderboardEntry, ParticipantInfo, User } from '@/types'
import type { Marathon, MarathonListItem, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode } from '@/types'
export interface CreateMarathonData {
title: string
description?: string
start_date: string
duration_days?: number
}
export interface ParticipantWithUser extends ParticipantInfo {
user: User
is_public?: boolean
game_proposal_mode?: GameProposalMode
}
export const marathonsApi = {
@@ -52,11 +50,24 @@ export const marathonsApi = {
return response.data
},
joinPublic: async (id: number): Promise<Marathon> => {
const response = await client.post<Marathon>(`/marathons/${id}/join`)
return response.data
},
getParticipants: async (id: number): Promise<ParticipantWithUser[]> => {
const response = await client.get<ParticipantWithUser[]>(`/marathons/${id}/participants`)
return response.data
},
setParticipantRole: async (marathonId: number, userId: number, role: ParticipantRole): Promise<ParticipantWithUser> => {
const response = await client.patch<ParticipantWithUser>(
`/marathons/${marathonId}/participants/${userId}/role`,
{ role }
)
return response.data
},
getLeaderboard: async (id: number): Promise<LeaderboardEntry[]> => {
const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`)
return response.data

View File

@@ -1,16 +1,20 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, Link } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { marathonsApi } from '@/api'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
import { Globe, Lock, Users, UserCog, ArrowLeft } from 'lucide-react'
import type { GameProposalMode } from '@/types'
const createSchema = z.object({
title: z.string().min(1, 'Название обязательно').max(100),
description: z.string().optional(),
start_date: z.string().min(1, 'Дата начала обязательна'),
duration_days: z.number().min(1).max(365).default(30),
is_public: z.boolean().default(false),
game_proposal_mode: z.enum(['all_participants', 'organizer_only']).default('all_participants'),
})
type CreateForm = z.infer<typeof createSchema>
@@ -23,21 +27,32 @@ export function CreateMarathonPage() {
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<CreateForm>({
resolver: zodResolver(createSchema),
defaultValues: {
duration_days: 30,
is_public: false,
game_proposal_mode: 'all_participants',
},
})
const isPublic = watch('is_public')
const gameProposalMode = watch('game_proposal_mode')
const onSubmit = async (data: CreateForm) => {
setIsLoading(true)
setError(null)
try {
const marathon = await marathonsApi.create({
...data,
title: data.title,
description: data.description,
start_date: new Date(data.start_date).toISOString(),
duration_days: data.duration_days,
is_public: data.is_public,
game_proposal_mode: data.game_proposal_mode as GameProposalMode,
})
navigate(`/marathons/${marathon.id}/lobby`)
} catch (err: unknown) {
@@ -50,6 +65,12 @@ export function CreateMarathonPage() {
return (
<div className="max-w-lg mx-auto">
{/* Back button */}
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
<ArrowLeft className="w-4 h-4" />
К списку марафонов
</Link>
<Card>
<CardHeader>
<CardTitle>Создать марафон</CardTitle>
@@ -94,6 +115,92 @@ export function CreateMarathonPage() {
{...register('duration_days', { valueAsNumber: true })}
/>
{/* Тип марафона */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Тип марафона
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setValue('is_public', false)}
className={`p-3 rounded-lg border-2 transition-all ${
!isPublic
? 'border-primary-500 bg-primary-500/10'
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
}`}
>
<Lock className={`w-5 h-5 mx-auto mb-1 ${!isPublic ? 'text-primary-400' : 'text-gray-400'}`} />
<div className={`text-sm font-medium ${!isPublic ? 'text-white' : 'text-gray-300'}`}>
Закрытый
</div>
<div className="text-xs text-gray-500 mt-1">
Вход по коду
</div>
</button>
<button
type="button"
onClick={() => setValue('is_public', true)}
className={`p-3 rounded-lg border-2 transition-all ${
isPublic
? 'border-primary-500 bg-primary-500/10'
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
}`}
>
<Globe className={`w-5 h-5 mx-auto mb-1 ${isPublic ? 'text-primary-400' : 'text-gray-400'}`} />
<div className={`text-sm font-medium ${isPublic ? 'text-white' : 'text-gray-300'}`}>
Открытый
</div>
<div className="text-xs text-gray-500 mt-1">
Виден всем
</div>
</button>
</div>
</div>
{/* Кто может предлагать игры */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Кто может предлагать игры
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setValue('game_proposal_mode', 'all_participants')}
className={`p-3 rounded-lg border-2 transition-all ${
gameProposalMode === 'all_participants'
? 'border-primary-500 bg-primary-500/10'
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
}`}
>
<Users className={`w-5 h-5 mx-auto mb-1 ${gameProposalMode === 'all_participants' ? 'text-primary-400' : 'text-gray-400'}`} />
<div className={`text-sm font-medium ${gameProposalMode === 'all_participants' ? 'text-white' : 'text-gray-300'}`}>
Все участники
</div>
<div className="text-xs text-gray-500 mt-1">
С модерацией
</div>
</button>
<button
type="button"
onClick={() => setValue('game_proposal_mode', 'organizer_only')}
className={`p-3 rounded-lg border-2 transition-all ${
gameProposalMode === 'organizer_only'
? 'border-primary-500 bg-primary-500/10'
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
}`}
>
<UserCog className={`w-5 h-5 mx-auto mb-1 ${gameProposalMode === 'organizer_only' ? 'text-primary-400' : 'text-gray-400'}`} />
<div className={`text-sm font-medium ${gameProposalMode === 'organizer_only' ? 'text-white' : 'text-gray-300'}`}>
Только организатор
</div>
<div className="text-xs text-gray-500 mt-1">
Без модерации
</div>
</button>
</div>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"

View File

@@ -1,10 +1,13 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi, gamesApi } from '@/api'
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye, ChevronDown, ChevronUp, Edit2, Check } from 'lucide-react'
import {
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft
} from 'lucide-react'
export function LobbyPage() {
const { id } = useParams<{ id: string }>()
@@ -13,6 +16,7 @@ export function LobbyPage() {
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [games, setGames] = useState<Game[]>([])
const [pendingGames, setPendingGames] = useState<Game[]>([])
const [isLoading, setIsLoading] = useState(true)
// Add game form
@@ -22,6 +26,9 @@ export function LobbyPage() {
const [gameGenre, setGameGenre] = useState('')
const [isAddingGame, setIsAddingGame] = useState(false)
// Moderation
const [moderatingGameId, setModeratingGameId] = useState<number | null>(null)
// Generate challenges
const [isGenerating, setIsGenerating] = useState(false)
const [generateMessage, setGenerateMessage] = useState<string | null>(null)
@@ -44,12 +51,23 @@ export function LobbyPage() {
const loadData = async () => {
if (!id) return
try {
const [marathonData, gamesData] = await Promise.all([
marathonsApi.get(parseInt(id)),
gamesApi.list(parseInt(id)),
])
const marathonData = await marathonsApi.get(parseInt(id))
setMarathon(marathonData)
// Load games - organizers see all, participants see approved + own
const gamesData = await gamesApi.list(parseInt(id))
setGames(gamesData)
// If organizer, load pending games separately
if (marathonData.my_participation?.role === 'organizer' || user?.role === 'admin') {
try {
const pending = await gamesApi.listPending(parseInt(id))
setPendingGames(pending)
} catch {
// If not authorized, just ignore
setPendingGames([])
}
}
} catch (error) {
console.error('Failed to load data:', error)
navigate('/marathons')
@@ -91,6 +109,32 @@ export function LobbyPage() {
}
}
const handleApproveGame = async (gameId: number) => {
setModeratingGameId(gameId)
try {
await gamesApi.approve(gameId)
await loadData()
} catch (error) {
console.error('Failed to approve game:', error)
} finally {
setModeratingGameId(null)
}
}
const handleRejectGame = async (gameId: number) => {
if (!confirm('Отклонить эту игру?')) return
setModeratingGameId(gameId)
try {
await gamesApi.reject(gameId)
await loadData()
} catch (error) {
console.error('Failed to reject game:', error)
} finally {
setModeratingGameId(null)
}
}
const handleToggleGameChallenges = async (gameId: number) => {
if (expandedGameId === gameId) {
setExpandedGameId(null)
@@ -205,57 +249,248 @@ export function LobbyPage() {
)
}
const isOrganizer = user?.id === marathon.organizer.id
const totalChallenges = games.reduce((sum, g) => sum + g.challenges_count, 0)
const isOrganizer = marathon.my_participation?.role === 'organizer' || user?.role === 'admin'
const approvedGames = games.filter(g => g.status === 'approved')
const totalChallenges = approvedGames.reduce((sum, g) => sum + g.challenges_count, 0)
const getStatusBadge = (status: string) => {
switch (status) {
case 'approved':
return (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-green-900/50 text-green-400">
<CheckCircle className="w-3 h-3" />
Одобрено
</span>
)
case 'pending':
return (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-yellow-900/50 text-yellow-400">
<Clock className="w-3 h-3" />
На модерации
</span>
)
case 'rejected':
return (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-red-900/50 text-red-400">
<XCircle className="w-3 h-3" />
Отклонено
</span>
)
default:
return null
}
}
const renderGameCard = (game: Game, showModeration = false) => (
<div key={game.id} className="bg-gray-900 rounded-lg overflow-hidden">
{/* Game header */}
<div
className={`flex items-center justify-between p-4 ${
game.challenges_count > 0 ? 'cursor-pointer hover:bg-gray-800/50' : ''
}`}
onClick={() => game.challenges_count > 0 && handleToggleGameChallenges(game.id)}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
{game.challenges_count > 0 && (
<span className="text-gray-400 shrink-0">
{expandedGameId === game.id ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</span>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-medium text-white">{game.title}</h4>
{getStatusBadge(game.status)}
</div>
<div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap">
{game.genre && <span>{game.genre}</span>}
{game.status === 'approved' && <span>{game.challenges_count} заданий</span>}
{game.proposed_by && (
<span className="flex items-center gap-1 text-gray-500">
<User className="w-3 h-3" />
{game.proposed_by.nickname}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
{showModeration && game.status === 'pending' && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => handleApproveGame(game.id)}
disabled={moderatingGameId === game.id}
className="text-green-400 hover:text-green-300"
>
{moderatingGameId === game.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCircle className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRejectGame(game.id)}
disabled={moderatingGameId === game.id}
className="text-red-400 hover:text-red-300"
>
<XCircle className="w-4 h-4" />
</Button>
</>
)}
{(isOrganizer || game.proposed_by?.id === user?.id) && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteGame(game.id)}
className="text-red-400 hover:text-red-300"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</div>
{/* Expanded challenges list */}
{expandedGameId === game.id && (
<div className="border-t border-gray-800 p-4 space-y-2">
{loadingChallenges === game.id ? (
<div className="flex justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
</div>
) : gameChallenges[game.id]?.length > 0 ? (
gameChallenges[game.id].map((challenge) => (
<div
key={challenge.id}
className="flex items-start justify-between gap-3 p-3 bg-gray-800 rounded-lg"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded ${
challenge.difficulty === 'easy' ? 'bg-green-900/50 text-green-400' :
challenge.difficulty === 'medium' ? 'bg-yellow-900/50 text-yellow-400' :
'bg-red-900/50 text-red-400'
}`}>
{challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span>
<span className="text-xs text-primary-400 font-medium">
+{challenge.points}
</span>
{challenge.is_generated && (
<span className="text-xs text-gray-500">
<Sparkles className="w-3 h-3 inline" /> ИИ
</span>
)}
</div>
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
</div>
{isOrganizer && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
className="text-red-400 hover:text-red-300 shrink-0"
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
))
) : (
<p className="text-center text-gray-500 py-2 text-sm">
Нет заданий
</p>
)}
</div>
)}
</div>
)
return (
<div className="max-w-4xl mx-auto">
{/* Back button */}
<Link to={`/marathons/${id}`} className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
<ArrowLeft className="w-4 h-4" />
К марафону
</Link>
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-white">{marathon.title}</h1>
<p className="text-gray-400">Настройка - Добавьте игры и сгенерируйте задания</p>
<p className="text-gray-400">
{isOrganizer
? 'Настройка - Добавьте игры и сгенерируйте задания'
: 'Предложите игры для марафона'}
</p>
</div>
{isOrganizer && (
<Button onClick={handleStartMarathon} isLoading={isStarting} disabled={games.length === 0}>
<Button onClick={handleStartMarathon} isLoading={isStarting} disabled={approvedGames.length === 0}>
<Play className="w-4 h-4 mr-2" />
Запустить марафон
</Button>
)}
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-4 mb-8">
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{games.length}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Gamepad2 className="w-4 h-4" />
Игр
</div>
</CardContent>
</Card>
{/* Stats - только для организаторов */}
{isOrganizer && (
<div className="grid grid-cols-2 gap-4 mb-8">
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{approvedGames.length}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Gamepad2 className="w-4 h-4" />
Игр одобрено
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{totalChallenges}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Sparkles className="w-4 h-4" />
Заданий
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{totalChallenges}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Sparkles className="w-4 h-4" />
Заданий
</div>
</CardContent>
</Card>
</div>
)}
{/* Pending games for moderation (organizers only) */}
{isOrganizer && pendingGames.length > 0 && (
<Card className="mb-8 border-yellow-900/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-yellow-400">
<Clock className="w-5 h-5" />
На модерации ({pendingGames.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{pendingGames.map((game) => renderGameCard(game, true))}
</div>
</CardContent>
</Card>
</div>
)}
{/* Generate challenges button */}
{games.length > 0 && !previewChallenges && (
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
<Card className="mb-8">
<CardContent>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-white">Генерация заданий</h3>
<p className="text-sm text-gray-400">
Используйте ИИ для генерации заданий для всех игр без заданий
Используйте ИИ для генерации заданий для одобренных игр без заданий
</p>
</div>
<Button onClick={handleGenerateChallenges} isLoading={isGenerating} variant="secondary">
@@ -425,10 +660,13 @@ export function LobbyPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Игры</CardTitle>
<Button size="sm" onClick={() => setShowAddGame(!showAddGame)}>
<Plus className="w-4 h-4 mr-1" />
Добавить игру
</Button>
{/* Показываем кнопку если: all_participants ИЛИ (organizer_only И isOrganizer) */}
{(marathon.game_proposal_mode === 'all_participants' || isOrganizer) && (
<Button size="sm" onClick={() => setShowAddGame(!showAddGame)}>
<Plus className="w-4 h-4 mr-1" />
{isOrganizer ? 'Добавить игру' : 'Предложить игру'}
</Button>
)}
</CardHeader>
<CardContent>
{/* Add game form */}
@@ -451,116 +689,38 @@ export function LobbyPage() {
/>
<div className="flex gap-2">
<Button onClick={handleAddGame} isLoading={isAddingGame} disabled={!gameTitle || !gameUrl}>
Добавить
{isOrganizer ? 'Добавить' : 'Предложить'}
</Button>
<Button variant="ghost" onClick={() => setShowAddGame(false)}>
Отмена
</Button>
</div>
{!isOrganizer && (
<p className="text-xs text-gray-500">
Ваша игра будет отправлена на модерацию организаторам
</p>
)}
</div>
)}
{/* Games */}
{games.length === 0 ? (
<p className="text-center text-gray-400 py-8">
Пока нет игр. Добавьте игры, чтобы начать!
</p>
) : (
<div className="space-y-3">
{games.map((game) => (
<div key={game.id} className="bg-gray-900 rounded-lg overflow-hidden">
{/* Game header */}
<div
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-800/50"
onClick={() => game.challenges_count > 0 && handleToggleGameChallenges(game.id)}
>
<div className="flex items-center gap-3">
{game.challenges_count > 0 && (
<span className="text-gray-400">
{expandedGameId === game.id ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</span>
)}
<div>
<h4 className="font-medium text-white">{game.title}</h4>
<div className="text-sm text-gray-400">
{game.genre && <span className="mr-3">{game.genre}</span>}
<span>{game.challenges_count} заданий</span>
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleDeleteGame(game.id)
}}
className="text-red-400 hover:text-red-300"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
{(() => {
// Организаторы: показываем только одобренные (pending в секции модерации)
// Участники: показываем одобренные + свои pending
const visibleGames = isOrganizer
? games.filter(g => g.status !== 'pending')
: games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id))
{/* Expanded challenges list */}
{expandedGameId === game.id && (
<div className="border-t border-gray-800 p-4 space-y-2">
{loadingChallenges === game.id ? (
<div className="flex justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
</div>
) : gameChallenges[game.id]?.length > 0 ? (
gameChallenges[game.id].map((challenge) => (
<div
key={challenge.id}
className="flex items-start justify-between gap-3 p-3 bg-gray-800 rounded-lg"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded ${
challenge.difficulty === 'easy' ? 'bg-green-900/50 text-green-400' :
challenge.difficulty === 'medium' ? 'bg-yellow-900/50 text-yellow-400' :
'bg-red-900/50 text-red-400'
}`}>
{challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span>
<span className="text-xs text-primary-400 font-medium">
+{challenge.points}
</span>
{challenge.is_generated && (
<span className="text-xs text-gray-500">
<Sparkles className="w-3 h-3 inline" /> ИИ
</span>
)}
</div>
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
className="text-red-400 hover:text-red-300 shrink-0"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))
) : (
<p className="text-center text-gray-500 py-2 text-sm">
Нет заданий
</p>
)}
</div>
)}
</div>
))}
</div>
)}
return visibleGames.length === 0 ? (
<p className="text-center text-gray-400 py-8">
{isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'}
</p>
) : (
<div className="space-y-3">
{visibleGames.map((game) => renderGameCard(game, false))}
</div>
)
})()}
</CardContent>
</Card>
</div>

View File

@@ -4,7 +4,7 @@ import { marathonsApi } from '@/api'
import type { Marathon } from '@/types'
import { Button, Card, CardContent } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2 } from 'lucide-react'
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft } from 'lucide-react'
import { format } from 'date-fns'
export function MarathonPage() {
@@ -14,6 +14,8 @@ export function MarathonPage() {
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [copied, setCopied] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isJoining, setIsJoining] = useState(false)
useEffect(() => {
loadMarathon()
@@ -40,6 +42,36 @@ export function MarathonPage() {
}
}
const handleDelete = async () => {
if (!marathon || !confirm('Вы уверены, что хотите удалить этот марафон? Это действие нельзя отменить.')) return
setIsDeleting(true)
try {
await marathonsApi.delete(marathon.id)
navigate('/marathons')
} catch (error) {
console.error('Failed to delete marathon:', error)
alert('Не удалось удалить марафон')
} finally {
setIsDeleting(false)
}
}
const handleJoinPublic = async () => {
if (!marathon) return
setIsJoining(true)
try {
const updated = await marathonsApi.joinPublic(marathon.id)
setMarathon(updated)
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось присоединиться')
} finally {
setIsJoining(false)
}
}
if (isLoading || !marathon) {
return (
<div className="flex justify-center py-12">
@@ -48,21 +80,51 @@ export function MarathonPage() {
)
}
const isOrganizer = user?.id === marathon.organizer.id
const isOrganizer = marathon.my_participation?.role === 'organizer' || user?.role === 'admin'
const isParticipant = !!marathon.my_participation
const isCreator = marathon.creator.id === user?.id
const canDelete = isCreator || user?.role === 'admin'
return (
<div className="max-w-4xl mx-auto">
{/* Back button */}
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
<ArrowLeft className="w-4 h-4" />
К списку марафонов
</Link>
{/* Header */}
<div className="flex justify-between items-start mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">{marathon.title}</h1>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1>
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
marathon.is_public
? 'bg-green-900/50 text-green-400'
: 'bg-gray-700 text-gray-300'
}`}>
{marathon.is_public ? (
<><Globe className="w-3 h-3" /> Открытый</>
) : (
<><Lock className="w-3 h-3" /> Закрытый</>
)}
</span>
</div>
{marathon.description && (
<p className="text-gray-400">{marathon.description}</p>
)}
</div>
<div className="flex gap-2">
{/* Кнопка присоединиться для открытых марафонов */}
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
<Button onClick={handleJoinPublic} isLoading={isJoining}>
<UserPlus className="w-4 h-4 mr-2" />
Присоединиться
</Button>
)}
{/* Настройка для организаторов */}
{marathon.status === 'preparing' && isOrganizer && (
<Link to={`/marathons/${id}/lobby`}>
<Button variant="secondary">
@@ -72,6 +134,16 @@ export function MarathonPage() {
</Link>
)}
{/* Предложить игру для участников (не организаторов) если разрешено */}
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
<Link to={`/marathons/${id}/lobby`}>
<Button variant="secondary">
<Gamepad2 className="w-4 h-4 mr-2" />
Предложить игру
</Button>
</Link>
)}
{marathon.status === 'active' && isParticipant && (
<Link to={`/marathons/${id}/play`}>
<Button>
@@ -87,11 +159,22 @@ export function MarathonPage() {
Рейтинг
</Button>
</Link>
{canDelete && (
<Button
variant="ghost"
onClick={handleDelete}
isLoading={isDeleting}
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<div className="grid md:grid-cols-5 gap-4 mb-8">
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
@@ -116,7 +199,19 @@ export function MarathonPage() {
</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Calendar className="w-4 h-4" />
Дата начала
Начало
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">
{marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'}
</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<CalendarCheck className="w-4 h-4" />
Конец
</div>
</CardContent>
</Card>

View File

@@ -1,9 +1,12 @@
// User types
export type UserRole = 'user' | 'admin'
export interface User {
id: number
login: string
nickname: string
avatar_url: string | null
role: UserRole
created_at: string
}
@@ -15,22 +18,31 @@ export interface TokenResponse {
// Marathon types
export type MarathonStatus = 'preparing' | 'active' | 'finished'
export type ParticipantRole = 'participant' | 'organizer'
export type GameProposalMode = 'all_participants' | 'organizer_only'
export interface ParticipantInfo {
id: number
role: ParticipantRole
total_points: number
current_streak: number
drop_count: number
joined_at: string
}
export interface ParticipantWithUser extends ParticipantInfo {
user: User
}
export interface Marathon {
id: number
title: string
description: string | null
organizer: User
creator: User
status: MarathonStatus
invite_code: string
is_public: boolean
game_proposal_mode: GameProposalMode
start_date: string | null
end_date: string | null
participants_count: number
@@ -43,11 +55,21 @@ export interface MarathonListItem {
id: number
title: string
status: MarathonStatus
is_public: boolean
participants_count: number
start_date: string | null
end_date: string | null
}
export interface MarathonCreate {
title: string
description?: string
start_date: string
duration_days: number
is_public: boolean
game_proposal_mode: GameProposalMode
}
export interface LeaderboardEntry {
rank: number
user: User
@@ -58,13 +80,17 @@ export interface LeaderboardEntry {
}
// Game types
export type GameStatus = 'pending' | 'approved' | 'rejected'
export interface Game {
id: number
title: string
cover_url: string | null
download_url: string
genre: string | null
added_by: User | null
status: GameStatus
proposed_by: User | null
approved_by: User | null
challenges_count: number
created_at: string
}
@@ -158,7 +184,16 @@ export interface DropResult {
}
// Activity types
export type ActivityType = 'join' | 'spin' | 'complete' | 'drop' | 'start_marathon' | 'finish_marathon'
export type ActivityType =
| 'join'
| 'spin'
| 'complete'
| 'drop'
| 'start_marathon'
| 'finish_marathon'
| 'add_game'
| 'approve_game'
| 'reject_game'
export interface Activity {
id: number
@@ -173,3 +208,35 @@ export interface FeedResponse {
total: number
has_more: boolean
}
// Admin types
export interface AdminUser {
id: number
login: string
nickname: string
role: UserRole
avatar_url: string | null
telegram_id: number | null
telegram_username: string | null
marathons_count: number
created_at: string
}
export interface AdminMarathon {
id: number
title: string
status: MarathonStatus
creator: User
participants_count: number
games_count: number
start_date: string | null
end_date: string | null
created_at: string
}
export interface PlatformStats {
users_count: number
marathons_count: number
games_count: number
total_participations: number
}