Add 3 roles, settings for marathons
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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
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.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()
|
||||
|
||||
@@ -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/"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,7 @@ class UserPublic(UserBase):
|
||||
id: int
|
||||
login: str
|
||||
avatar_url: str | None = None
|
||||
role: str = "user"
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
|
||||
Reference in New Issue
Block a user