From d0b8eca600885e6e2c3d1bf81db8aa530a5ac25c Mon Sep 17 00:00:00 2001 From: Oronemu Date: Sun, 14 Dec 2025 20:21:56 +0700 Subject: [PATCH] Add 3 roles, settings for marathons --- Makefile | 22 +- .../alembic/versions/001_add_roles_system.py | 72 +++ .../alembic/versions/002_marathon_settings.py | 32 ++ .../alembic/versions/003_create_admin_user.py | 38 ++ backend/app/api/deps.py | 99 +++- backend/app/api/v1/__init__.py | 3 +- backend/app/api/v1/admin.py | 260 +++++++++++ backend/app/api/v1/challenges.py | 73 +-- backend/app/api/v1/games.py | 252 ++++++++--- backend/app/api/v1/marathons.py | 184 ++++++-- backend/app/models/__init__.py | 12 +- backend/app/models/activity.py | 3 + backend/app/models/game.py | 30 +- backend/app/models/marathon.py | 17 +- backend/app/models/participant.py | 13 +- backend/app/models/user.py | 33 +- backend/app/schemas/__init__.py | 2 + backend/app/schemas/game.py | 4 +- backend/app/schemas/marathon.py | 14 +- backend/app/schemas/user.py | 1 + frontend/src/api/admin.ts | 44 ++ frontend/src/api/games.ts | 22 +- frontend/src/api/index.ts | 1 + frontend/src/api/marathons.ts | 21 +- frontend/src/pages/CreateMarathonPage.tsx | 111 ++++- frontend/src/pages/LobbyPage.tsx | 428 ++++++++++++------ frontend/src/pages/MarathonPage.tsx | 105 ++++- frontend/src/types/index.ts | 73 ++- 28 files changed, 1679 insertions(+), 290 deletions(-) create mode 100644 backend/alembic/versions/001_add_roles_system.py create mode 100644 backend/alembic/versions/002_marathon_settings.py create mode 100644 backend/alembic/versions/003_create_admin_user.py create mode 100644 backend/app/api/v1/admin.py create mode 100644 frontend/src/api/admin.ts diff --git a/Makefile b/Makefile index 4666290..12ef5e7 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/backend/alembic/versions/001_add_roles_system.py b/backend/alembic/versions/001_add_roles_system.py new file mode 100644 index 0000000..421d299 --- /dev/null +++ b/backend/alembic/versions/001_add_roles_system.py @@ -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') diff --git a/backend/alembic/versions/002_marathon_settings.py b/backend/alembic/versions/002_marathon_settings.py new file mode 100644 index 0000000..8b4fddd --- /dev/null +++ b/backend/alembic/versions/002_marathon_settings.py @@ -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') diff --git a/backend/alembic/versions/003_create_admin_user.py b/backend/alembic/versions/003_create_admin_user.py new file mode 100644 index 0000000..dabe4c9 --- /dev/null +++ b/backend/alembic/versions/003_create_admin_user.py @@ -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'") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 452db51..7a330a2 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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)] diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 93d445f..56f7beb 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -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) diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py new file mode 100644 index 0000000..2592f2b --- /dev/null +++ b/backend/app/api/v1/admin.py @@ -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, + } diff --git a/backend/app/api/v1/challenges.py b/backend/app/api/v1/challenges.py index 032712c..9e98cba 100644 --- a/backend/app/api/v1/challenges.py +++ b/backend/app/api/v1/challenges.py @@ -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() diff --git a/backend/app/api/v1/games.py b/backend/app/api/v1/games.py index 6bfb69b..96461fb 100644 --- a/backend/app/api/v1/games.py +++ b/backend/app/api/v1/games.py @@ -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/"): diff --git a/backend/app/api/v1/marathons.py b/backend/app/api/v1/marathons.py index b5a7472..985799a 100644 --- a/backend/app/api/v1/marathons.py +++ b/backend/app/api/v1/marathons.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4d05a74..192c3b8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/activity.py b/backend/app/models/activity.py index 652be70..e20d844 100644 --- a/backend/app/models/activity.py +++ b/backend/app/models/activity.py @@ -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): diff --git a/backend/app/models/game.py b/backend/app/models/game.py index a0e4320..9f06c4d 100644 --- a/backend/app/models/game.py +++ b/backend/app/models/game.py @@ -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 diff --git a/backend/app/models/marathon.py b/backend/app/models/marathon.py index ad1ff4b..3f277bf 100644 --- a/backend/app/models/marathon.py +++ b/backend/app/models/marathon.py @@ -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", diff --git a/backend/app/models/participant.py b/backend/app/models/participant.py index bb166f6..49b1737 100644 --- a/backend/app/models/participant.py +++ b/backend/app/models/participant.py @@ -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 diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8d177e5..73aeb52 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index b26f96d..a8acd8e 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -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", diff --git a/backend/app/schemas/game.py b/backend/app/schemas/game.py index a101224..5c24dc7 100644 --- a/backend/app/schemas/game.py +++ b/backend/app/schemas/game.py @@ -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 diff --git a/backend/app/schemas/marathon.py b/backend/app/schemas/marathon.py index 65602b6..a81797e 100644 --- a/backend/app/schemas/marathon.py +++ b/backend/app/schemas/marathon.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 567029b..2395583 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -32,6 +32,7 @@ class UserPublic(UserBase): id: int login: str avatar_url: str | None = None + role: str = "user" created_at: datetime class Config: diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..526006f --- /dev/null +++ b/frontend/src/api/admin.ts @@ -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 => { + const params: Record = { skip, limit } + if (search) params.search = search + const response = await client.get('/admin/users', { params }) + return response.data + }, + + getUser: async (id: number): Promise => { + const response = await client.get(`/admin/users/${id}`) + return response.data + }, + + setUserRole: async (id: number, role: UserRole): Promise => { + const response = await client.patch(`/admin/users/${id}/role`, { role }) + return response.data + }, + + deleteUser: async (id: number): Promise => { + await client.delete(`/admin/users/${id}`) + }, + + // Marathons + listMarathons: async (skip = 0, limit = 50, search?: string): Promise => { + const params: Record = { skip, limit } + if (search) params.search = search + const response = await client.get('/admin/marathons', { params }) + return response.data + }, + + deleteMarathon: async (id: number): Promise => { + await client.delete(`/admin/marathons/${id}`) + }, + + // Stats + getStats: async (): Promise => { + const response = await client.get('/admin/stats') + return response.data + }, +} diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts index 67ba2b8..ed68189 100644 --- a/frontend/src/api/games.ts +++ b/frontend/src/api/games.ts @@ -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 => { - const response = await client.get(`/marathons/${marathonId}/games`) + list: async (marathonId: number, status?: GameStatus): Promise => { + const params = status ? { status } : {} + const response = await client.get(`/marathons/${marathonId}/games`, { params }) + return response.data + }, + + listPending: async (marathonId: number): Promise => { + const response = await client.get(`/marathons/${marathonId}/games/pending`) return response.data }, @@ -39,6 +45,16 @@ export const gamesApi = { await client.delete(`/games/${id}`) }, + approve: async (id: number): Promise => { + const response = await client.post(`/games/${id}/approve`) + return response.data + }, + + reject: async (id: number): Promise => { + const response = await client.post(`/games/${id}/reject`) + return response.data + }, + uploadCover: async (id: number, file: File): Promise => { const formData = new FormData() formData.append('file', file) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e9c854d..e60d193 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -3,3 +3,4 @@ export { marathonsApi } from './marathons' export { gamesApi } from './games' export { wheelApi } from './wheel' export { feedApi } from './feed' +export { adminApi } from './admin' diff --git a/frontend/src/api/marathons.ts b/frontend/src/api/marathons.ts index 7ae8877..6ce269a 100644 --- a/frontend/src/api/marathons.ts +++ b/frontend/src/api/marathons.ts @@ -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 => { + const response = await client.post(`/marathons/${id}/join`) + return response.data + }, + getParticipants: async (id: number): Promise => { const response = await client.get(`/marathons/${id}/participants`) return response.data }, + setParticipantRole: async (marathonId: number, userId: number, role: ParticipantRole): Promise => { + const response = await client.patch( + `/marathons/${marathonId}/participants/${userId}/role`, + { role } + ) + return response.data + }, + getLeaderboard: async (id: number): Promise => { const response = await client.get(`/marathons/${id}/leaderboard`) return response.data diff --git a/frontend/src/pages/CreateMarathonPage.tsx b/frontend/src/pages/CreateMarathonPage.tsx index 334c3b1..b5ef3c4 100644 --- a/frontend/src/pages/CreateMarathonPage.tsx +++ b/frontend/src/pages/CreateMarathonPage.tsx @@ -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 @@ -23,21 +27,32 @@ export function CreateMarathonPage() { const { register, handleSubmit, + watch, + setValue, formState: { errors }, } = useForm({ 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 (
+ {/* Back button */} + + + К списку марафонов + + Создать марафон @@ -94,6 +115,92 @@ export function CreateMarathonPage() { {...register('duration_days', { valueAsNumber: true })} /> + {/* Тип марафона */} +
+ +
+ + +
+
+ + {/* Кто может предлагать игры */} +
+ +
+ + +
+
+
+ + + )} + {(isOrganizer || game.proposed_by?.id === user?.id) && ( + + )} +
+
+ + {/* Expanded challenges list */} + {expandedGameId === game.id && ( +
+ {loadingChallenges === game.id ? ( +
+ +
+ ) : gameChallenges[game.id]?.length > 0 ? ( + gameChallenges[game.id].map((challenge) => ( +
+
+
+ + {challenge.difficulty === 'easy' ? 'Легко' : + challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'} + + + +{challenge.points} + + {challenge.is_generated && ( + + ИИ + + )} +
+
{challenge.title}
+

{challenge.description}

+
+ {isOrganizer && ( + + )} +
+ )) + ) : ( +

+ Нет заданий +

+ )} +
+ )} + + ) return (
+ {/* Back button */} + + + К марафону + +

{marathon.title}

-

Настройка - Добавьте игры и сгенерируйте задания

+

+ {isOrganizer + ? 'Настройка - Добавьте игры и сгенерируйте задания' + : 'Предложите игры для марафона'} +

{isOrganizer && ( - )}
- {/* Stats */} -
- - -
{games.length}
-
- - Игр -
-
-
+ {/* Stats - только для организаторов */} + {isOrganizer && ( +
+ + +
{approvedGames.length}
+
+ + Игр одобрено +
+
+
- - -
{totalChallenges}
-
- - Заданий + + +
{totalChallenges}
+
+ + Заданий +
+
+
+
+ )} + + {/* Pending games for moderation (organizers only) */} + {isOrganizer && pendingGames.length > 0 && ( + + + + + На модерации ({pendingGames.length}) + + + +
+ {pendingGames.map((game) => renderGameCard(game, true))}
-
+ )} {/* Generate challenges button */} - {games.length > 0 && !previewChallenges && ( + {isOrganizer && approvedGames.length > 0 && !previewChallenges && (

Генерация заданий

- Используйте ИИ для генерации заданий для всех игр без заданий + Используйте ИИ для генерации заданий для одобренных игр без заданий

+ {/* Показываем кнопку если: all_participants ИЛИ (organizer_only И isOrganizer) */} + {(marathon.game_proposal_mode === 'all_participants' || isOrganizer) && ( + + )} {/* Add game form */} @@ -451,116 +689,38 @@ export function LobbyPage() { />
+ {!isOrganizer && ( +

+ Ваша игра будет отправлена на модерацию организаторам +

+ )}
)} {/* Games */} - {games.length === 0 ? ( -

- Пока нет игр. Добавьте игры, чтобы начать! -

- ) : ( -
- {games.map((game) => ( -
- {/* Game header */} -
game.challenges_count > 0 && handleToggleGameChallenges(game.id)} - > -
- {game.challenges_count > 0 && ( - - {expandedGameId === game.id ? ( - - ) : ( - - )} - - )} -
-

{game.title}

-
- {game.genre && {game.genre}} - {game.challenges_count} заданий -
-
-
- -
+ {(() => { + // Организаторы: показываем только одобренные (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 && ( -
- {loadingChallenges === game.id ? ( -
- -
- ) : gameChallenges[game.id]?.length > 0 ? ( - gameChallenges[game.id].map((challenge) => ( -
-
-
- - {challenge.difficulty === 'easy' ? 'Легко' : - challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'} - - - +{challenge.points} - - {challenge.is_generated && ( - - ИИ - - )} -
-
{challenge.title}
-

{challenge.description}

-
- -
- )) - ) : ( -

- Нет заданий -

- )} -
- )} -
- ))} -
- )} + return visibleGames.length === 0 ? ( +

+ {isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'} +

+ ) : ( +
+ {visibleGames.map((game) => renderGameCard(game, false))} +
+ ) + })()}
diff --git a/frontend/src/pages/MarathonPage.tsx b/frontend/src/pages/MarathonPage.tsx index e8e23ed..3e5f883 100644 --- a/frontend/src/pages/MarathonPage.tsx +++ b/frontend/src/pages/MarathonPage.tsx @@ -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(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 (
@@ -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 (
+ {/* Back button */} + + + К списку марафонов + + {/* Header */}
-

{marathon.title}

+
+

{marathon.title}

+ + {marathon.is_public ? ( + <> Открытый + ) : ( + <> Закрытый + )} + +
{marathon.description && (

{marathon.description}

)}
+ {/* Кнопка присоединиться для открытых марафонов */} + {marathon.is_public && !isParticipant && marathon.status !== 'finished' && ( + + )} + + {/* Настройка для организаторов */} {marathon.status === 'preparing' && isOrganizer && ( + + )} + {marathon.status === 'active' && isParticipant && ( + + {canDelete && ( + + )}
{/* Stats */} -
+
{marathon.participants_count}
@@ -116,7 +199,19 @@ export function MarathonPage() {
- Дата начала + Начало +
+ + + + + +
+ {marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'} +
+
+ + Конец
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index dd5022d..a27ab17 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 +}