Add 3 roles, settings for marathons
This commit is contained in:
22
Makefile
22
Makefile
@@ -17,7 +17,11 @@ help:
|
|||||||
@echo ""
|
@echo ""
|
||||||
@echo " Build:"
|
@echo " Build:"
|
||||||
@echo " make build - Build all containers (with cache)"
|
@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 ""
|
||||||
@echo " Database:"
|
@echo " Database:"
|
||||||
@echo " make migrate - Run database migrations"
|
@echo " make migrate - Run database migrations"
|
||||||
@@ -60,12 +64,28 @@ build:
|
|||||||
build-no-cache:
|
build-no-cache:
|
||||||
$(DC) 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:
|
rebuild-frontend:
|
||||||
$(DC) down
|
$(DC) down
|
||||||
sudo docker rmi marathon-frontend || true
|
sudo docker rmi marathon-frontend || true
|
||||||
$(DC) build --no-cache frontend
|
$(DC) build --no-cache frontend
|
||||||
$(DC) up -d
|
$(DC) up -d
|
||||||
|
|
||||||
|
rebuild-backend:
|
||||||
|
$(DC) down
|
||||||
|
sudo docker rmi marathon-backend || true
|
||||||
|
$(DC) build --no-cache backend
|
||||||
|
$(DC) up -d
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
migrate:
|
migrate:
|
||||||
$(DC) exec backend alembic upgrade head
|
$(DC) exec backend alembic upgrade head
|
||||||
|
|||||||
72
backend/alembic/versions/001_add_roles_system.py
Normal file
72
backend/alembic/versions/001_add_roles_system.py
Normal 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')
|
||||||
32
backend/alembic/versions/002_marathon_settings.py
Normal file
32
backend/alembic/versions/002_marathon_settings.py
Normal 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')
|
||||||
38
backend/alembic/versions/003_create_admin_user.py
Normal file
38
backend/alembic/versions/003_create_admin_user.py
Normal 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'")
|
||||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import decode_access_token
|
from app.core.security import decode_access_token
|
||||||
from app.models import User
|
from app.models import User, Participant, Marathon, UserRole, ParticipantRole
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
@@ -45,6 +45,103 @@ async def get_current_user(
|
|||||||
return 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
|
# Type aliases for cleaner dependency injection
|
||||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
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")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -11,3 +11,4 @@ router.include_router(games.router)
|
|||||||
router.include_router(challenges.router)
|
router.include_router(challenges.router)
|
||||||
router.include_router(wheel.router)
|
router.include_router(wheel.router)
|
||||||
router.include_router(feed.router)
|
router.include_router(feed.router)
|
||||||
|
router.include_router(admin.router)
|
||||||
|
|||||||
260
backend/app/api/v1/admin.py
Normal file
260
backend/app/api/v1/admin.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant
|
||||||
from app.models import Marathon, MarathonStatus, Game, Challenge, Participant
|
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
ChallengeCreate,
|
ChallengeCreate,
|
||||||
ChallengeUpdate,
|
ChallengeUpdate,
|
||||||
@@ -33,21 +33,9 @@ async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
|||||||
return 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])
|
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
|
||||||
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
|
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
|
# Get game and check access
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Game).where(Game.id == game_id)
|
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:
|
if not game:
|
||||||
raise HTTPException(status_code=404, detail="Game not found")
|
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(
|
result = await db.execute(
|
||||||
select(Challenge)
|
select(Challenge)
|
||||||
@@ -91,6 +88,7 @@ async def create_challenge(
|
|||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
):
|
):
|
||||||
|
"""Create a challenge for a game. Organizers only."""
|
||||||
# Get game and check access
|
# Get game and check access
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Game).where(Game.id == game_id)
|
select(Game).where(Game.id == game_id)
|
||||||
@@ -105,7 +103,12 @@ async def create_challenge(
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon")
|
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(
|
challenge = Challenge(
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
@@ -141,7 +144,7 @@ async def create_challenge(
|
|||||||
|
|
||||||
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
|
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
|
||||||
async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
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
|
# Check marathon
|
||||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
marathon = result.scalar_one_or_none()
|
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:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot generate challenges for active or finished marathon")
|
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(
|
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()
|
games = result.scalars().all()
|
||||||
|
|
||||||
if not games:
|
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 = []
|
preview_challenges = []
|
||||||
for game in games:
|
for game in games:
|
||||||
@@ -202,7 +209,7 @@ async def save_challenges(
|
|||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
):
|
):
|
||||||
"""Save previewed challenges to database"""
|
"""Save previewed challenges to database. Organizers only."""
|
||||||
# Check marathon
|
# Check marathon
|
||||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
marathon = result.scalar_one_or_none()
|
marathon = result.scalar_one_or_none()
|
||||||
@@ -212,18 +219,22 @@ async def save_challenges(
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon")
|
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(
|
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())
|
valid_game_ids = set(row[0] for row in result.fetchall())
|
||||||
|
|
||||||
saved_count = 0
|
saved_count = 0
|
||||||
for ch_data in data.challenges:
|
for ch_data in data.challenges:
|
||||||
if ch_data.game_id not in valid_game_ids:
|
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
|
# Validate type
|
||||||
ch_type = ch_data.type
|
ch_type = ch_data.type
|
||||||
@@ -267,6 +278,7 @@ async def update_challenge(
|
|||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
):
|
):
|
||||||
|
"""Update a challenge. Organizers only."""
|
||||||
challenge = await get_challenge_or_404(db, challenge_id)
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
# Check marathon is in preparing state
|
# Check marathon is in preparing state
|
||||||
@@ -275,7 +287,8 @@ async def update_challenge(
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot update challenges in active or finished marathon")
|
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:
|
if data.title is not None:
|
||||||
challenge.title = data.title
|
challenge.title = data.title
|
||||||
@@ -316,6 +329,7 @@ async def update_challenge(
|
|||||||
|
|
||||||
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
|
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
|
||||||
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
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)
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
# Check marathon is in preparing state
|
# 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:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
|
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.delete(challenge)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -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 import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
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.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
|
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
||||||
|
|
||||||
router = APIRouter(tags=["games"])
|
router = APIRouter(tags=["games"])
|
||||||
@@ -15,7 +18,10 @@ router = APIRouter(tags=["games"])
|
|||||||
async def get_game_or_404(db, game_id: int) -> Game:
|
async def get_game_or_404(db, game_id: int) -> Game:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Game)
|
select(Game)
|
||||||
.options(selectinload(Game.added_by_user))
|
.options(
|
||||||
|
selectinload(Game.proposed_by),
|
||||||
|
selectinload(Game.approved_by),
|
||||||
|
)
|
||||||
.where(Game.id == game_id)
|
.where(Game.id == game_id)
|
||||||
)
|
)
|
||||||
game = result.scalar_one_or_none()
|
game = result.scalar_one_or_none()
|
||||||
@@ -24,47 +30,84 @@ async def get_game_or_404(db, game_id: int) -> Game:
|
|||||||
return game
|
return game
|
||||||
|
|
||||||
|
|
||||||
async def check_participant(db, user_id: int, marathon_id: int) -> Participant:
|
def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
|
||||||
result = await db.execute(
|
"""Convert Game model to GameResponse schema"""
|
||||||
select(Participant).where(
|
return GameResponse(
|
||||||
Participant.user_id == user_id,
|
|
||||||
Participant.marathon_id == marathon_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
participant = result.scalar_one_or_none()
|
|
||||||
if not participant:
|
|
||||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
|
||||||
return participant
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/marathons/{marathon_id}/games", response_model=list[GameResponse])
|
|
||||||
async def list_games(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
|
||||||
await check_participant(db, current_user.id, marathon_id)
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(Game, func.count(Challenge.id).label("challenges_count"))
|
|
||||||
.outerjoin(Challenge)
|
|
||||||
.options(selectinload(Game.added_by_user))
|
|
||||||
.where(Game.marathon_id == marathon_id)
|
|
||||||
.group_by(Game.id)
|
|
||||||
.order_by(Game.created_at.desc())
|
|
||||||
)
|
|
||||||
|
|
||||||
games = []
|
|
||||||
for row in result.all():
|
|
||||||
game = row[0]
|
|
||||||
games.append(GameResponse(
|
|
||||||
id=game.id,
|
id=game.id,
|
||||||
title=game.title,
|
title=game.title,
|
||||||
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
|
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
|
||||||
download_url=game.download_url,
|
download_url=game.download_url,
|
||||||
genre=game.genre,
|
genre=game.genre,
|
||||||
added_by=UserPublic.model_validate(game.added_by_user) if game.added_by_user else None,
|
status=game.status,
|
||||||
challenges_count=row[1],
|
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,
|
created_at=game.created_at,
|
||||||
))
|
)
|
||||||
|
|
||||||
return games
|
|
||||||
|
@router.get("/marathons/{marathon_id}/games", response_model=list[GameResponse])
|
||||||
|
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")
|
||||||
|
|
||||||
|
query = (
|
||||||
|
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)
|
||||||
|
.group_by(Game.id)
|
||||||
|
.order_by(Game.created_at.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
@router.post("/marathons/{marathon_id}/games", response_model=GameResponse)
|
||||||
@@ -74,6 +117,7 @@ async def add_game(
|
|||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
):
|
):
|
||||||
|
"""Propose a new game. Organizers can auto-approve."""
|
||||||
# Check marathon exists and is preparing
|
# Check marathon exists and is preparing
|
||||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
marathon = result.scalar_one_or_none()
|
marathon = result.scalar_one_or_none()
|
||||||
@@ -83,16 +127,36 @@ async def add_game(
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot add games to active or finished marathon")
|
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(
|
game = Game(
|
||||||
marathon_id=marathon_id,
|
marathon_id=marathon_id,
|
||||||
title=data.title,
|
title=data.title,
|
||||||
download_url=data.download_url,
|
download_url=data.download_url,
|
||||||
genre=data.genre,
|
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)
|
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.commit()
|
||||||
await db.refresh(game)
|
await db.refresh(game)
|
||||||
|
|
||||||
@@ -102,7 +166,9 @@ async def add_game(
|
|||||||
cover_url=None,
|
cover_url=None,
|
||||||
download_url=game.download_url,
|
download_url=game.download_url,
|
||||||
genre=game.genre,
|
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,
|
challenges_count=0,
|
||||||
created_at=game.created_at,
|
created_at=game.created_at,
|
||||||
)
|
)
|
||||||
@@ -111,22 +177,21 @@ async def add_game(
|
|||||||
@router.get("/games/{game_id}", response_model=GameResponse)
|
@router.get("/games/{game_id}", response_model=GameResponse)
|
||||||
async def get_game(game_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_game(game_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
game = await get_game_or_404(db, game_id)
|
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(
|
challenges_count = await db.scalar(
|
||||||
select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id)
|
select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
return GameResponse(
|
return game_to_response(game, challenges_count)
|
||||||
id=game.id,
|
|
||||||
title=game.title,
|
|
||||||
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
|
|
||||||
download_url=game.download_url,
|
|
||||||
genre=game.genre,
|
|
||||||
added_by=UserPublic.model_validate(game.added_by_user) if game.added_by_user else None,
|
|
||||||
challenges_count=challenges_count,
|
|
||||||
created_at=game.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/games/{game_id}", response_model=GameResponse)
|
@router.patch("/games/{game_id}", response_model=GameResponse)
|
||||||
@@ -144,9 +209,16 @@ async def update_game(
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot update games in active or finished marathon")
|
raise HTTPException(status_code=400, detail="Cannot update games in active or finished marathon")
|
||||||
|
|
||||||
# Only the one who added or organizer can update
|
participant = await get_participant(db, current_user.id, game.marathon_id)
|
||||||
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")
|
# 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:
|
if data.title is not None:
|
||||||
game.title = data.title
|
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:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete games from active or finished marathon")
|
raise HTTPException(status_code=400, detail="Cannot delete games from active or finished marathon")
|
||||||
|
|
||||||
# Only the one who added or organizer can delete
|
participant = await get_participant(db, current_user.id, game.marathon_id)
|
||||||
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")
|
# 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.delete(game)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -180,6 +259,73 @@ async def delete_game(game_id: int, current_user: CurrentUser, db: DbSession):
|
|||||||
return MessageResponse(message="Game deleted")
|
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)
|
@router.post("/games/{game_id}/cover", response_model=GameResponse)
|
||||||
async def upload_cover(
|
async def upload_cover(
|
||||||
game_id: int,
|
game_id: int,
|
||||||
@@ -188,7 +334,7 @@ async def upload_cover(
|
|||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
):
|
):
|
||||||
game = await get_game_or_404(db, game_id)
|
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
|
# Validate file
|
||||||
if not file.content_type.startswith("image/"):
|
if not file.content_type.startswith("image/"):
|
||||||
|
|||||||
@@ -4,8 +4,15 @@ from fastapi import APIRouter, HTTPException, status
|
|||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import (
|
||||||
from app.models import Marathon, Participant, MarathonStatus, Game, Assignment, AssignmentStatus, Activity, ActivityType
|
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 (
|
from app.schemas import (
|
||||||
MarathonCreate,
|
MarathonCreate,
|
||||||
MarathonUpdate,
|
MarathonUpdate,
|
||||||
@@ -17,6 +24,7 @@ from app.schemas import (
|
|||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
UserPublic,
|
UserPublic,
|
||||||
|
SetParticipantRole,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/marathons", tags=["marathons"])
|
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:
|
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Marathon)
|
select(Marathon)
|
||||||
.options(selectinload(Marathon.organizer))
|
.options(selectinload(Marathon.creator))
|
||||||
.where(Marathon.id == marathon_id)
|
.where(Marathon.id == marathon_id)
|
||||||
)
|
)
|
||||||
marathon = result.scalar_one_or_none()
|
marathon = result.scalar_one_or_none()
|
||||||
@@ -50,13 +58,24 @@ async def get_participation(db, user_id: int, marathon_id: int) -> Participant |
|
|||||||
|
|
||||||
@router.get("", response_model=list[MarathonListItem])
|
@router.get("", response_model=list[MarathonListItem])
|
||||||
async def list_marathons(current_user: CurrentUser, db: DbSession):
|
async def list_marathons(current_user: CurrentUser, db: DbSession):
|
||||||
"""Get all marathons where user is participant or organizer"""
|
"""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(
|
result = await db.execute(
|
||||||
select(Marathon, func.count(Participant.id).label("participants_count"))
|
select(Marathon, func.count(Participant.id).label("participants_count"))
|
||||||
.outerjoin(Participant)
|
.outerjoin(Participant)
|
||||||
.where(
|
.where(
|
||||||
(Marathon.organizer_id == current_user.id) |
|
(Marathon.creator_id == current_user.id) |
|
||||||
(Participant.user_id == current_user.id)
|
(Participant.user_id == current_user.id) |
|
||||||
|
(Marathon.is_public == True)
|
||||||
)
|
)
|
||||||
.group_by(Marathon.id)
|
.group_by(Marathon.id)
|
||||||
.order_by(Marathon.created_at.desc())
|
.order_by(Marathon.created_at.desc())
|
||||||
@@ -69,6 +88,7 @@ async def list_marathons(current_user: CurrentUser, db: DbSession):
|
|||||||
id=marathon.id,
|
id=marathon.id,
|
||||||
title=marathon.title,
|
title=marathon.title,
|
||||||
status=marathon.status,
|
status=marathon.status,
|
||||||
|
is_public=marathon.is_public,
|
||||||
participants_count=row[1],
|
participants_count=row[1],
|
||||||
start_date=marathon.start_date,
|
start_date=marathon.start_date,
|
||||||
end_date=marathon.end_date,
|
end_date=marathon.end_date,
|
||||||
@@ -90,18 +110,21 @@ async def create_marathon(
|
|||||||
marathon = Marathon(
|
marathon = Marathon(
|
||||||
title=data.title,
|
title=data.title,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
organizer_id=current_user.id,
|
creator_id=current_user.id,
|
||||||
invite_code=generate_invite_code(),
|
invite_code=generate_invite_code(),
|
||||||
|
is_public=data.is_public,
|
||||||
|
game_proposal_mode=data.game_proposal_mode,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
)
|
)
|
||||||
db.add(marathon)
|
db.add(marathon)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# Auto-add organizer as participant
|
# Auto-add creator as organizer participant
|
||||||
participant = Participant(
|
participant = Participant(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
marathon_id=marathon.id,
|
marathon_id=marathon.id,
|
||||||
|
role=ParticipantRole.ORGANIZER.value, # Creator is organizer
|
||||||
)
|
)
|
||||||
db.add(participant)
|
db.add(participant)
|
||||||
|
|
||||||
@@ -112,9 +135,11 @@ async def create_marathon(
|
|||||||
id=marathon.id,
|
id=marathon.id,
|
||||||
title=marathon.title,
|
title=marathon.title,
|
||||||
description=marathon.description,
|
description=marathon.description,
|
||||||
organizer=UserPublic.model_validate(current_user),
|
creator=UserPublic.model_validate(current_user),
|
||||||
status=marathon.status,
|
status=marathon.status,
|
||||||
invite_code=marathon.invite_code,
|
invite_code=marathon.invite_code,
|
||||||
|
is_public=marathon.is_public,
|
||||||
|
game_proposal_mode=marathon.game_proposal_mode,
|
||||||
start_date=marathon.start_date,
|
start_date=marathon.start_date,
|
||||||
end_date=marathon.end_date,
|
end_date=marathon.end_date,
|
||||||
participants_count=1,
|
participants_count=1,
|
||||||
@@ -128,12 +153,15 @@ async def create_marathon(
|
|||||||
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
marathon = await get_marathon_or_404(db, marathon_id)
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
# Count participants and games
|
# Count participants and approved games
|
||||||
participants_count = await db.scalar(
|
participants_count = await db.scalar(
|
||||||
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
|
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
|
||||||
)
|
)
|
||||||
games_count = await db.scalar(
|
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
|
# Get user's participation
|
||||||
@@ -143,9 +171,11 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio
|
|||||||
id=marathon.id,
|
id=marathon.id,
|
||||||
title=marathon.title,
|
title=marathon.title,
|
||||||
description=marathon.description,
|
description=marathon.description,
|
||||||
organizer=UserPublic.model_validate(marathon.organizer),
|
creator=UserPublic.model_validate(marathon.creator),
|
||||||
status=marathon.status,
|
status=marathon.status,
|
||||||
invite_code=marathon.invite_code,
|
invite_code=marathon.invite_code,
|
||||||
|
is_public=marathon.is_public,
|
||||||
|
game_proposal_mode=marathon.game_proposal_mode,
|
||||||
start_date=marathon.start_date,
|
start_date=marathon.start_date,
|
||||||
end_date=marathon.end_date,
|
end_date=marathon.end_date,
|
||||||
participants_count=participants_count,
|
participants_count=participants_count,
|
||||||
@@ -162,11 +192,10 @@ async def update_marathon(
|
|||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
):
|
):
|
||||||
|
# Require organizer role
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
marathon = await get_marathon_or_404(db, 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:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot update active or finished marathon")
|
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:
|
if data.start_date is not None:
|
||||||
# Strip timezone info for naive datetime columns
|
# 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
|
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()
|
await db.commit()
|
||||||
|
|
||||||
@@ -185,11 +218,10 @@ async def update_marathon(
|
|||||||
|
|
||||||
@router.delete("/{marathon_id}", response_model=MessageResponse)
|
@router.delete("/{marathon_id}", response_model=MessageResponse)
|
||||||
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
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)
|
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.delete(marathon)
|
||||||
await db.commit()
|
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)
|
@router.post("/{marathon_id}/start", response_model=MarathonResponse)
|
||||||
async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
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)
|
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:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
|
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(
|
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:
|
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
|
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)
|
@router.post("/{marathon_id}/finish", response_model=MarathonResponse)
|
||||||
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
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)
|
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:
|
if marathon.status != MarathonStatus.ACTIVE.value:
|
||||||
raise HTTPException(status_code=400, detail="Marathon is not active")
|
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(
|
participant = Participant(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
marathon_id=marathon.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)
|
db.add(participant)
|
||||||
|
|
||||||
@@ -308,6 +379,7 @@ async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSe
|
|||||||
return [
|
return [
|
||||||
ParticipantWithUser(
|
ParticipantWithUser(
|
||||||
id=p.id,
|
id=p.id,
|
||||||
|
role=p.role,
|
||||||
total_points=p.total_points,
|
total_points=p.total_points,
|
||||||
current_streak=p.current_streak,
|
current_streak=p.current_streak,
|
||||||
drop_count=p.drop_count,
|
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])
|
@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
|
||||||
async def get_leaderboard(marathon_id: int, db: DbSession):
|
async def get_leaderboard(marathon_id: int, db: DbSession):
|
||||||
await get_marathon_or_404(db, marathon_id)
|
await get_marathon_or_404(db, marathon_id)
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
from app.models.user import User
|
from app.models.user import User, UserRole
|
||||||
from app.models.marathon import Marathon, MarathonStatus
|
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode
|
||||||
from app.models.participant import Participant
|
from app.models.participant import Participant, ParticipantRole
|
||||||
from app.models.game import Game
|
from app.models.game import Game, GameStatus
|
||||||
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
|
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
|
||||||
from app.models.assignment import Assignment, AssignmentStatus
|
from app.models.assignment import Assignment, AssignmentStatus
|
||||||
from app.models.activity import Activity, ActivityType
|
from app.models.activity import Activity, ActivityType
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
|
"UserRole",
|
||||||
"Marathon",
|
"Marathon",
|
||||||
"MarathonStatus",
|
"MarathonStatus",
|
||||||
|
"GameProposalMode",
|
||||||
"Participant",
|
"Participant",
|
||||||
|
"ParticipantRole",
|
||||||
"Game",
|
"Game",
|
||||||
|
"GameStatus",
|
||||||
"Challenge",
|
"Challenge",
|
||||||
"ChallengeType",
|
"ChallengeType",
|
||||||
"Difficulty",
|
"Difficulty",
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class ActivityType(str, Enum):
|
|||||||
DROP = "drop"
|
DROP = "drop"
|
||||||
START_MARATHON = "start_marathon"
|
START_MARATHON = "start_marathon"
|
||||||
FINISH_MARATHON = "finish_marathon"
|
FINISH_MARATHON = "finish_marathon"
|
||||||
|
ADD_GAME = "add_game"
|
||||||
|
APPROVE_GAME = "approve_game"
|
||||||
|
REJECT_GAME = "reject_game"
|
||||||
|
|
||||||
|
|
||||||
class Activity(Base):
|
class Activity(Base):
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
from sqlalchemy import String, DateTime, ForeignKey, Text
|
from sqlalchemy import String, DateTime, ForeignKey, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class GameStatus(str, Enum):
|
||||||
|
PENDING = "pending" # Предложена участником, ждёт модерации
|
||||||
|
APPROVED = "approved" # Одобрена организатором
|
||||||
|
REJECTED = "rejected" # Отклонена
|
||||||
|
|
||||||
|
|
||||||
class Game(Base):
|
class Game(Base):
|
||||||
__tablename__ = "games"
|
__tablename__ = "games"
|
||||||
|
|
||||||
@@ -14,14 +21,33 @@ class Game(Base):
|
|||||||
cover_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
cover_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
download_url: Mapped[str] = mapped_column(Text, nullable=False)
|
download_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
genre: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games")
|
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(
|
challenges: Mapped[list["Challenge"]] = relationship(
|
||||||
"Challenge",
|
"Challenge",
|
||||||
back_populates="game",
|
back_populates="game",
|
||||||
cascade="all, delete-orphan"
|
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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -12,24 +12,31 @@ class MarathonStatus(str, Enum):
|
|||||||
FINISHED = "finished"
|
FINISHED = "finished"
|
||||||
|
|
||||||
|
|
||||||
|
class GameProposalMode(str, Enum):
|
||||||
|
ALL_PARTICIPANTS = "all_participants"
|
||||||
|
ORGANIZER_ONLY = "organizer_only"
|
||||||
|
|
||||||
|
|
||||||
class Marathon(Base):
|
class Marathon(Base):
|
||||||
__tablename__ = "marathons"
|
__tablename__ = "marathons"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(100), nullable=False)
|
title: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
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)
|
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)
|
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)
|
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
end_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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
organizer: Mapped["User"] = relationship(
|
creator: Mapped["User"] = relationship(
|
||||||
"User",
|
"User",
|
||||||
back_populates="organized_marathons",
|
back_populates="created_marathons",
|
||||||
foreign_keys=[organizer_id]
|
foreign_keys=[creator_id]
|
||||||
)
|
)
|
||||||
participants: Mapped[list["Participant"]] = relationship(
|
participants: Mapped[list["Participant"]] = relationship(
|
||||||
"Participant",
|
"Participant",
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
from datetime import datetime
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantRole(str, Enum):
|
||||||
|
PARTICIPANT = "participant"
|
||||||
|
ORGANIZER = "organizer"
|
||||||
|
|
||||||
|
|
||||||
class Participant(Base):
|
class Participant(Base):
|
||||||
__tablename__ = "participants"
|
__tablename__ = "participants"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -14,6 +20,7 @@ class Participant(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=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)
|
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)
|
total_points: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
current_streak: 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
|
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
|
||||||
@@ -27,3 +34,7 @@ class Participant(Base):
|
|||||||
back_populates="participant",
|
back_populates="participant",
|
||||||
cascade="all, delete-orphan"
|
cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_organizer(self) -> bool:
|
||||||
|
return self.role == ParticipantRole.ORGANIZER.value
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
from sqlalchemy import String, BigInteger, DateTime
|
from sqlalchemy import String, BigInteger, DateTime
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, Enum):
|
||||||
|
USER = "user"
|
||||||
|
ADMIN = "admin"
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@@ -15,19 +21,36 @@ class User(Base):
|
|||||||
avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, 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)
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
organized_marathons: Mapped[list["Marathon"]] = relationship(
|
created_marathons: Mapped[list["Marathon"]] = relationship(
|
||||||
"Marathon",
|
"Marathon",
|
||||||
back_populates="organizer",
|
back_populates="creator",
|
||||||
foreign_keys="Marathon.organizer_id"
|
foreign_keys="Marathon.creator_id"
|
||||||
)
|
)
|
||||||
participations: Mapped[list["Participant"]] = relationship(
|
participations: Mapped[list["Participant"]] = relationship(
|
||||||
"Participant",
|
"Participant",
|
||||||
back_populates="user"
|
back_populates="user"
|
||||||
)
|
)
|
||||||
added_games: Mapped[list["Game"]] = relationship(
|
proposed_games: Mapped[list["Game"]] = relationship(
|
||||||
"Game",
|
"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
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.schemas.marathon import (
|
|||||||
ParticipantWithUser,
|
ParticipantWithUser,
|
||||||
JoinMarathon,
|
JoinMarathon,
|
||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
|
SetParticipantRole,
|
||||||
)
|
)
|
||||||
from app.schemas.game import (
|
from app.schemas.game import (
|
||||||
GameCreate,
|
GameCreate,
|
||||||
@@ -68,6 +69,7 @@ __all__ = [
|
|||||||
"ParticipantWithUser",
|
"ParticipantWithUser",
|
||||||
"JoinMarathon",
|
"JoinMarathon",
|
||||||
"LeaderboardEntry",
|
"LeaderboardEntry",
|
||||||
|
"SetParticipantRole",
|
||||||
# Game
|
# Game
|
||||||
"GameCreate",
|
"GameCreate",
|
||||||
"GameUpdate",
|
"GameUpdate",
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ class GameShort(BaseModel):
|
|||||||
class GameResponse(GameBase):
|
class GameResponse(GameBase):
|
||||||
id: int
|
id: int
|
||||||
cover_url: str | None = None
|
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
|
challenges_count: int = 0
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,21 @@ class MarathonBase(BaseModel):
|
|||||||
class MarathonCreate(MarathonBase):
|
class MarathonCreate(MarathonBase):
|
||||||
start_date: datetime
|
start_date: datetime
|
||||||
duration_days: int = Field(default=30, ge=1, le=365)
|
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):
|
class MarathonUpdate(BaseModel):
|
||||||
title: str | None = Field(None, min_length=1, max_length=100)
|
title: str | None = Field(None, min_length=1, max_length=100)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
start_date: datetime | 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):
|
class ParticipantInfo(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
role: str = "participant"
|
||||||
total_points: int
|
total_points: int
|
||||||
current_streak: int
|
current_streak: int
|
||||||
drop_count: int
|
drop_count: int
|
||||||
@@ -37,9 +42,11 @@ class ParticipantWithUser(ParticipantInfo):
|
|||||||
|
|
||||||
class MarathonResponse(MarathonBase):
|
class MarathonResponse(MarathonBase):
|
||||||
id: int
|
id: int
|
||||||
organizer: UserPublic
|
creator: UserPublic
|
||||||
status: str
|
status: str
|
||||||
invite_code: str
|
invite_code: str
|
||||||
|
is_public: bool
|
||||||
|
game_proposal_mode: str
|
||||||
start_date: datetime | None
|
start_date: datetime | None
|
||||||
end_date: datetime | None
|
end_date: datetime | None
|
||||||
participants_count: int
|
participants_count: int
|
||||||
@@ -51,10 +58,15 @@ class MarathonResponse(MarathonBase):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SetParticipantRole(BaseModel):
|
||||||
|
role: str = Field(..., pattern="^(participant|organizer)$")
|
||||||
|
|
||||||
|
|
||||||
class MarathonListItem(BaseModel):
|
class MarathonListItem(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
status: str
|
status: str
|
||||||
|
is_public: bool
|
||||||
participants_count: int
|
participants_count: int
|
||||||
start_date: datetime | None
|
start_date: datetime | None
|
||||||
end_date: datetime | None
|
end_date: datetime | None
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class UserPublic(UserBase):
|
|||||||
id: int
|
id: int
|
||||||
login: str
|
login: str
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
|
role: str = "user"
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
44
frontend/src/api/admin.ts
Normal file
44
frontend/src/api/admin.ts
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { Game, Challenge, ChallengePreview, ChallengesPreviewResponse } from '@/types'
|
import type { Game, GameStatus, Challenge, ChallengePreview, ChallengesPreviewResponse } from '@/types'
|
||||||
|
|
||||||
export interface CreateGameData {
|
export interface CreateGameData {
|
||||||
title: string
|
title: string
|
||||||
@@ -20,8 +20,14 @@ export interface CreateChallengeData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const gamesApi = {
|
export const gamesApi = {
|
||||||
list: async (marathonId: number): Promise<Game[]> => {
|
list: async (marathonId: number, status?: GameStatus): Promise<Game[]> => {
|
||||||
const response = await client.get<Game[]>(`/marathons/${marathonId}/games`)
|
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
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -39,6 +45,16 @@ export const gamesApi = {
|
|||||||
await client.delete(`/games/${id}`)
|
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> => {
|
uploadCover: async (id: number, file: File): Promise<Game> => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export { marathonsApi } from './marathons'
|
|||||||
export { gamesApi } from './games'
|
export { gamesApi } from './games'
|
||||||
export { wheelApi } from './wheel'
|
export { wheelApi } from './wheel'
|
||||||
export { feedApi } from './feed'
|
export { feedApi } from './feed'
|
||||||
|
export { adminApi } from './admin'
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import client from './client'
|
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 {
|
export interface CreateMarathonData {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
start_date: string
|
start_date: string
|
||||||
duration_days?: number
|
duration_days?: number
|
||||||
}
|
is_public?: boolean
|
||||||
|
game_proposal_mode?: GameProposalMode
|
||||||
export interface ParticipantWithUser extends ParticipantInfo {
|
|
||||||
user: User
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const marathonsApi = {
|
export const marathonsApi = {
|
||||||
@@ -52,11 +50,24 @@ export const marathonsApi = {
|
|||||||
return response.data
|
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[]> => {
|
getParticipants: async (id: number): Promise<ParticipantWithUser[]> => {
|
||||||
const response = await client.get<ParticipantWithUser[]>(`/marathons/${id}/participants`)
|
const response = await client.get<ParticipantWithUser[]>(`/marathons/${id}/participants`)
|
||||||
return response.data
|
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[]> => {
|
getLeaderboard: async (id: number): Promise<LeaderboardEntry[]> => {
|
||||||
const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`)
|
const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`)
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { marathonsApi } from '@/api'
|
import { marathonsApi } from '@/api'
|
||||||
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
|
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({
|
const createSchema = z.object({
|
||||||
title: z.string().min(1, 'Название обязательно').max(100),
|
title: z.string().min(1, 'Название обязательно').max(100),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
start_date: z.string().min(1, 'Дата начала обязательна'),
|
start_date: z.string().min(1, 'Дата начала обязательна'),
|
||||||
duration_days: z.number().min(1).max(365).default(30),
|
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>
|
type CreateForm = z.infer<typeof createSchema>
|
||||||
@@ -23,21 +27,32 @@ export function CreateMarathonPage() {
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<CreateForm>({
|
} = useForm<CreateForm>({
|
||||||
resolver: zodResolver(createSchema),
|
resolver: zodResolver(createSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
duration_days: 30,
|
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) => {
|
const onSubmit = async (data: CreateForm) => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const marathon = await marathonsApi.create({
|
const marathon = await marathonsApi.create({
|
||||||
...data,
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
start_date: new Date(data.start_date).toISOString(),
|
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`)
|
navigate(`/marathons/${marathon.id}/lobby`)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -50,6 +65,12 @@ export function CreateMarathonPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg mx-auto">
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Создать марафон</CardTitle>
|
<CardTitle>Создать марафон</CardTitle>
|
||||||
@@ -94,6 +115,92 @@ export function CreateMarathonPage() {
|
|||||||
{...register('duration_days', { valueAsNumber: true })}
|
{...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">
|
<div className="flex gap-3 pt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { marathonsApi, gamesApi } from '@/api'
|
||||||
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
|
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
|
||||||
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
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() {
|
export function LobbyPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -13,6 +16,7 @@ export function LobbyPage() {
|
|||||||
|
|
||||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||||
const [games, setGames] = useState<Game[]>([])
|
const [games, setGames] = useState<Game[]>([])
|
||||||
|
const [pendingGames, setPendingGames] = useState<Game[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
// Add game form
|
// Add game form
|
||||||
@@ -22,6 +26,9 @@ export function LobbyPage() {
|
|||||||
const [gameGenre, setGameGenre] = useState('')
|
const [gameGenre, setGameGenre] = useState('')
|
||||||
const [isAddingGame, setIsAddingGame] = useState(false)
|
const [isAddingGame, setIsAddingGame] = useState(false)
|
||||||
|
|
||||||
|
// Moderation
|
||||||
|
const [moderatingGameId, setModeratingGameId] = useState<number | null>(null)
|
||||||
|
|
||||||
// Generate challenges
|
// Generate challenges
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
const [generateMessage, setGenerateMessage] = useState<string | null>(null)
|
const [generateMessage, setGenerateMessage] = useState<string | null>(null)
|
||||||
@@ -44,12 +51,23 @@ export function LobbyPage() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
const [marathonData, gamesData] = await Promise.all([
|
const marathonData = await marathonsApi.get(parseInt(id))
|
||||||
marathonsApi.get(parseInt(id)),
|
|
||||||
gamesApi.list(parseInt(id)),
|
|
||||||
])
|
|
||||||
setMarathon(marathonData)
|
setMarathon(marathonData)
|
||||||
|
|
||||||
|
// Load games - organizers see all, participants see approved + own
|
||||||
|
const gamesData = await gamesApi.list(parseInt(id))
|
||||||
setGames(gamesData)
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load data:', error)
|
console.error('Failed to load data:', error)
|
||||||
navigate('/marathons')
|
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) => {
|
const handleToggleGameChallenges = async (gameId: number) => {
|
||||||
if (expandedGameId === gameId) {
|
if (expandedGameId === gameId) {
|
||||||
setExpandedGameId(null)
|
setExpandedGameId(null)
|
||||||
@@ -205,33 +249,206 @@ export function LobbyPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOrganizer = user?.id === marathon.organizer.id
|
const isOrganizer = marathon.my_participation?.role === 'organizer' || user?.role === 'admin'
|
||||||
const totalChallenges = games.reduce((sum, g) => sum + g.challenges_count, 0)
|
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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<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 className="flex justify-between items-center mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">{marathon.title}</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
{isOrganizer && (
|
{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" />
|
<Play className="w-4 h-4 mr-2" />
|
||||||
Запустить марафон
|
Запустить марафон
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats - только для организаторов */}
|
||||||
|
{isOrganizer && (
|
||||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="text-center py-4">
|
<CardContent className="text-center py-4">
|
||||||
<div className="text-2xl font-bold text-white">{games.length}</div>
|
<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">
|
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||||
<Gamepad2 className="w-4 h-4" />
|
<Gamepad2 className="w-4 h-4" />
|
||||||
Игр
|
Игр одобрено
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -246,16 +463,34 @@ export function LobbyPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Generate challenges button */}
|
{/* Generate challenges button */}
|
||||||
{games.length > 0 && !previewChallenges && (
|
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
|
||||||
<Card className="mb-8">
|
<Card className="mb-8">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-white">Генерация заданий</h3>
|
<h3 className="font-medium text-white">Генерация заданий</h3>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
Используйте ИИ для генерации заданий для всех игр без заданий
|
Используйте ИИ для генерации заданий для одобренных игр без заданий
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleGenerateChallenges} isLoading={isGenerating} variant="secondary">
|
<Button onClick={handleGenerateChallenges} isLoading={isGenerating} variant="secondary">
|
||||||
@@ -425,10 +660,13 @@ export function LobbyPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Игры</CardTitle>
|
<CardTitle>Игры</CardTitle>
|
||||||
|
{/* Показываем кнопку если: all_participants ИЛИ (organizer_only И isOrganizer) */}
|
||||||
|
{(marathon.game_proposal_mode === 'all_participants' || isOrganizer) && (
|
||||||
<Button size="sm" onClick={() => setShowAddGame(!showAddGame)}>
|
<Button size="sm" onClick={() => setShowAddGame(!showAddGame)}>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
Добавить игру
|
{isOrganizer ? 'Добавить игру' : 'Предложить игру'}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Add game form */}
|
{/* Add game form */}
|
||||||
@@ -451,116 +689,38 @@ export function LobbyPage() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={handleAddGame} isLoading={isAddingGame} disabled={!gameTitle || !gameUrl}>
|
<Button onClick={handleAddGame} isLoading={isAddingGame} disabled={!gameTitle || !gameUrl}>
|
||||||
Добавить
|
{isOrganizer ? 'Добавить' : 'Предложить'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={() => setShowAddGame(false)}>
|
<Button variant="ghost" onClick={() => setShowAddGame(false)}>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{!isOrganizer && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Ваша игра будет отправлена на модерацию организаторам
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Games */}
|
{/* Games */}
|
||||||
{games.length === 0 ? (
|
{(() => {
|
||||||
|
// Организаторы: показываем только одобренные (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))
|
||||||
|
|
||||||
|
return visibleGames.length === 0 ? (
|
||||||
<p className="text-center text-gray-400 py-8">
|
<p className="text-center text-gray-400 py-8">
|
||||||
Пока нет игр. Добавьте игры, чтобы начать!
|
{isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{games.map((game) => (
|
{visibleGames.map((game) => renderGameCard(game, false))}
|
||||||
<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>
|
)
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { marathonsApi } from '@/api'
|
|||||||
import type { Marathon } from '@/types'
|
import type { Marathon } from '@/types'
|
||||||
import { Button, Card, CardContent } from '@/components/ui'
|
import { Button, Card, CardContent } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
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'
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
export function MarathonPage() {
|
export function MarathonPage() {
|
||||||
@@ -14,6 +14,8 @@ export function MarathonPage() {
|
|||||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [isJoining, setIsJoining] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMarathon()
|
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) {
|
if (isLoading || !marathon) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-12">
|
<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 isParticipant = !!marathon.my_participation
|
||||||
|
const isCreator = marathon.creator.id === user?.id
|
||||||
|
const canDelete = isCreator || user?.role === 'admin'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex justify-between items-start mb-8">
|
<div className="flex justify-between items-start mb-8">
|
||||||
<div>
|
<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 && (
|
{marathon.description && (
|
||||||
<p className="text-gray-400">{marathon.description}</p>
|
<p className="text-gray-400">{marathon.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<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 && (
|
{marathon.status === 'preparing' && isOrganizer && (
|
||||||
<Link to={`/marathons/${id}/lobby`}>
|
<Link to={`/marathons/${id}/lobby`}>
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
@@ -72,6 +134,16 @@ export function MarathonPage() {
|
|||||||
</Link>
|
</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 && (
|
{marathon.status === 'active' && isParticipant && (
|
||||||
<Link to={`/marathons/${id}/play`}>
|
<Link to={`/marathons/${id}/play`}>
|
||||||
<Button>
|
<Button>
|
||||||
@@ -87,11 +159,22 @@ export function MarathonPage() {
|
|||||||
Рейтинг
|
Рейтинг
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid md:grid-cols-4 gap-4 mb-8">
|
<div className="grid md:grid-cols-5 gap-4 mb-8">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="text-center py-4">
|
<CardContent className="text-center py-4">
|
||||||
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
|
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
|
||||||
@@ -116,7 +199,19 @@ export function MarathonPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||||
<Calendar className="w-4 h-4" />
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
// User types
|
// User types
|
||||||
|
export type UserRole = 'user' | 'admin'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number
|
id: number
|
||||||
login: string
|
login: string
|
||||||
nickname: string
|
nickname: string
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
|
role: UserRole
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,22 +18,31 @@ export interface TokenResponse {
|
|||||||
|
|
||||||
// Marathon types
|
// Marathon types
|
||||||
export type MarathonStatus = 'preparing' | 'active' | 'finished'
|
export type MarathonStatus = 'preparing' | 'active' | 'finished'
|
||||||
|
export type ParticipantRole = 'participant' | 'organizer'
|
||||||
|
export type GameProposalMode = 'all_participants' | 'organizer_only'
|
||||||
|
|
||||||
export interface ParticipantInfo {
|
export interface ParticipantInfo {
|
||||||
id: number
|
id: number
|
||||||
|
role: ParticipantRole
|
||||||
total_points: number
|
total_points: number
|
||||||
current_streak: number
|
current_streak: number
|
||||||
drop_count: number
|
drop_count: number
|
||||||
joined_at: string
|
joined_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParticipantWithUser extends ParticipantInfo {
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
export interface Marathon {
|
export interface Marathon {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
description: string | null
|
description: string | null
|
||||||
organizer: User
|
creator: User
|
||||||
status: MarathonStatus
|
status: MarathonStatus
|
||||||
invite_code: string
|
invite_code: string
|
||||||
|
is_public: boolean
|
||||||
|
game_proposal_mode: GameProposalMode
|
||||||
start_date: string | null
|
start_date: string | null
|
||||||
end_date: string | null
|
end_date: string | null
|
||||||
participants_count: number
|
participants_count: number
|
||||||
@@ -43,11 +55,21 @@ export interface MarathonListItem {
|
|||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
status: MarathonStatus
|
status: MarathonStatus
|
||||||
|
is_public: boolean
|
||||||
participants_count: number
|
participants_count: number
|
||||||
start_date: string | null
|
start_date: string | null
|
||||||
end_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 {
|
export interface LeaderboardEntry {
|
||||||
rank: number
|
rank: number
|
||||||
user: User
|
user: User
|
||||||
@@ -58,13 +80,17 @@ export interface LeaderboardEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Game types
|
// Game types
|
||||||
|
export type GameStatus = 'pending' | 'approved' | 'rejected'
|
||||||
|
|
||||||
export interface Game {
|
export interface Game {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
cover_url: string | null
|
cover_url: string | null
|
||||||
download_url: string
|
download_url: string
|
||||||
genre: string | null
|
genre: string | null
|
||||||
added_by: User | null
|
status: GameStatus
|
||||||
|
proposed_by: User | null
|
||||||
|
approved_by: User | null
|
||||||
challenges_count: number
|
challenges_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -158,7 +184,16 @@ export interface DropResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Activity types
|
// 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 {
|
export interface Activity {
|
||||||
id: number
|
id: number
|
||||||
@@ -173,3 +208,35 @@ export interface FeedResponse {
|
|||||||
total: number
|
total: number
|
||||||
has_more: boolean
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user