Add 3 roles, settings for marathons
This commit is contained in:
@@ -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/"):
|
||||
|
||||
Reference in New Issue
Block a user