Add 3 roles, settings for marathons

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,21 @@
from app.models.user import User
from app.models.marathon import Marathon, MarathonStatus
from app.models.participant import Participant
from app.models.game import Game
from app.models.user import User, UserRole
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode
from app.models.participant import Participant, ParticipantRole
from app.models.game import Game, GameStatus
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
from app.models.assignment import Assignment, AssignmentStatus
from app.models.activity import Activity, ActivityType
__all__ = [
"User",
"UserRole",
"Marathon",
"MarathonStatus",
"GameProposalMode",
"Participant",
"ParticipantRole",
"Game",
"GameStatus",
"Challenge",
"ChallengeType",
"Difficulty",

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -12,24 +12,31 @@ class MarathonStatus(str, Enum):
FINISHED = "finished"
class GameProposalMode(str, Enum):
ALL_PARTICIPANTS = "all_participants"
ORGANIZER_ONLY = "organizer_only"
class Marathon(Base):
__tablename__ = "marathons"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
organizer_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
creator_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(20), default=MarathonStatus.PREPARING.value)
invite_code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True)
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
game_proposal_mode: Mapped[str] = mapped_column(String(20), default=GameProposalMode.ALL_PARTICIPANTS.value)
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
organizer: Mapped["User"] = relationship(
creator: Mapped["User"] = relationship(
"User",
back_populates="organized_marathons",
foreign_keys=[organizer_id]
back_populates="created_marathons",
foreign_keys=[creator_id]
)
participants: Mapped[list["Participant"]] = relationship(
"Participant",

View File

@@ -1,10 +1,16 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint
from enum import Enum
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class ParticipantRole(str, Enum):
PARTICIPANT = "participant"
ORGANIZER = "organizer"
class Participant(Base):
__tablename__ = "participants"
__table_args__ = (
@@ -14,6 +20,7 @@ class Participant(Base):
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), index=True)
role: Mapped[str] = mapped_column(String(20), default=ParticipantRole.PARTICIPANT.value)
total_points: Mapped[int] = mapped_column(Integer, default=0)
current_streak: Mapped[int] = mapped_column(Integer, default=0)
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
@@ -27,3 +34,7 @@ class Participant(Base):
back_populates="participant",
cascade="all, delete-orphan"
)
@property
def is_organizer(self) -> bool:
return self.role == ParticipantRole.ORGANIZER.value

View File

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

View File

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

View File

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

View File

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

View File

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