2025-12-14 02:38:35 +07:00
|
|
|
from datetime import timedelta
|
|
|
|
|
import secrets
|
|
|
|
|
from fastapi import APIRouter, HTTPException, status
|
|
|
|
|
from sqlalchemy import select, func
|
|
|
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
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,
|
|
|
|
|
)
|
2025-12-14 02:38:35 +07:00
|
|
|
from app.schemas import (
|
|
|
|
|
MarathonCreate,
|
|
|
|
|
MarathonUpdate,
|
|
|
|
|
MarathonResponse,
|
|
|
|
|
MarathonListItem,
|
2025-12-14 20:39:26 +07:00
|
|
|
MarathonPublicInfo,
|
2025-12-14 02:38:35 +07:00
|
|
|
JoinMarathon,
|
|
|
|
|
ParticipantInfo,
|
|
|
|
|
ParticipantWithUser,
|
|
|
|
|
LeaderboardEntry,
|
|
|
|
|
MessageResponse,
|
|
|
|
|
UserPublic,
|
2025-12-14 20:21:56 +07:00
|
|
|
SetParticipantRole,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/marathons", tags=["marathons"])
|
|
|
|
|
|
|
|
|
|
|
2025-12-14 20:39:26 +07:00
|
|
|
# Public endpoint (no auth required)
|
|
|
|
|
@router.get("/by-code/{invite_code}", response_model=MarathonPublicInfo)
|
|
|
|
|
async def get_marathon_by_code(invite_code: str, db: DbSession):
|
|
|
|
|
"""Get public marathon info by invite code. No authentication required."""
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Marathon, func.count(Participant.id).label("participants_count"))
|
|
|
|
|
.outerjoin(Participant)
|
|
|
|
|
.options(selectinload(Marathon.creator))
|
|
|
|
|
.where(Marathon.invite_code == invite_code)
|
|
|
|
|
.group_by(Marathon.id)
|
|
|
|
|
)
|
|
|
|
|
row = result.first()
|
|
|
|
|
|
|
|
|
|
if not row:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
|
|
|
|
|
|
|
|
|
marathon = row[0]
|
|
|
|
|
participants_count = row[1]
|
|
|
|
|
|
|
|
|
|
return MarathonPublicInfo(
|
|
|
|
|
id=marathon.id,
|
|
|
|
|
title=marathon.title,
|
|
|
|
|
description=marathon.description,
|
|
|
|
|
status=marathon.status,
|
|
|
|
|
participants_count=participants_count,
|
|
|
|
|
creator_nickname=marathon.creator.nickname,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
def generate_invite_code() -> str:
|
|
|
|
|
return secrets.token_urlsafe(8)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Marathon)
|
2025-12-14 20:21:56 +07:00
|
|
|
.options(selectinload(Marathon.creator))
|
2025-12-14 02:38:35 +07:00
|
|
|
.where(Marathon.id == marathon_id)
|
|
|
|
|
)
|
|
|
|
|
marathon = result.scalar_one_or_none()
|
|
|
|
|
if not marathon:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
|
|
|
|
return marathon
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_participation(db, user_id: int, marathon_id: int) -> Participant | None:
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Participant).where(
|
|
|
|
|
Participant.user_id == user_id,
|
|
|
|
|
Participant.marathon_id == marathon_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("", response_model=list[MarathonListItem])
|
|
|
|
|
async def list_marathons(current_user: CurrentUser, db: DbSession):
|
2025-12-14 20:21:56 +07:00
|
|
|
"""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())
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
marathons = []
|
|
|
|
|
for row in result.all():
|
|
|
|
|
marathon = row[0]
|
|
|
|
|
marathons.append(MarathonListItem(
|
|
|
|
|
id=marathon.id,
|
|
|
|
|
title=marathon.title,
|
|
|
|
|
status=marathon.status,
|
2025-12-14 20:21:56 +07:00
|
|
|
is_public=marathon.is_public,
|
2025-12-14 02:38:35 +07:00
|
|
|
participants_count=row[1],
|
|
|
|
|
start_date=marathon.start_date,
|
|
|
|
|
end_date=marathon.end_date,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
return marathons
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("", response_model=MarathonResponse)
|
|
|
|
|
async def create_marathon(
|
|
|
|
|
data: MarathonCreate,
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
db: DbSession,
|
|
|
|
|
):
|
|
|
|
|
# Strip timezone info for naive datetime columns
|
|
|
|
|
start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date
|
|
|
|
|
end_date = start_date + timedelta(days=data.duration_days)
|
|
|
|
|
|
|
|
|
|
marathon = Marathon(
|
|
|
|
|
title=data.title,
|
|
|
|
|
description=data.description,
|
2025-12-14 20:21:56 +07:00
|
|
|
creator_id=current_user.id,
|
2025-12-14 02:38:35 +07:00
|
|
|
invite_code=generate_invite_code(),
|
2025-12-14 20:21:56 +07:00
|
|
|
is_public=data.is_public,
|
|
|
|
|
game_proposal_mode=data.game_proposal_mode,
|
2025-12-14 02:38:35 +07:00
|
|
|
start_date=start_date,
|
|
|
|
|
end_date=end_date,
|
|
|
|
|
)
|
|
|
|
|
db.add(marathon)
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
# Auto-add creator as organizer participant
|
2025-12-14 02:38:35 +07:00
|
|
|
participant = Participant(
|
|
|
|
|
user_id=current_user.id,
|
|
|
|
|
marathon_id=marathon.id,
|
2025-12-14 20:21:56 +07:00
|
|
|
role=ParticipantRole.ORGANIZER.value, # Creator is organizer
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
db.add(participant)
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
await db.refresh(marathon)
|
|
|
|
|
|
|
|
|
|
return MarathonResponse(
|
|
|
|
|
id=marathon.id,
|
|
|
|
|
title=marathon.title,
|
|
|
|
|
description=marathon.description,
|
2025-12-14 20:21:56 +07:00
|
|
|
creator=UserPublic.model_validate(current_user),
|
2025-12-14 02:38:35 +07:00
|
|
|
status=marathon.status,
|
|
|
|
|
invite_code=marathon.invite_code,
|
2025-12-14 20:21:56 +07:00
|
|
|
is_public=marathon.is_public,
|
|
|
|
|
game_proposal_mode=marathon.game_proposal_mode,
|
2025-12-15 03:22:29 +07:00
|
|
|
auto_events_enabled=marathon.auto_events_enabled,
|
2025-12-14 02:38:35 +07:00
|
|
|
start_date=marathon.start_date,
|
|
|
|
|
end_date=marathon.end_date,
|
|
|
|
|
participants_count=1,
|
|
|
|
|
games_count=0,
|
|
|
|
|
created_at=marathon.created_at,
|
|
|
|
|
my_participation=ParticipantInfo.model_validate(participant),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{marathon_id}", response_model=MarathonResponse)
|
|
|
|
|
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
|
|
|
|
marathon = await get_marathon_or_404(db, marathon_id)
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
# Count participants and approved games
|
2025-12-14 02:38:35 +07:00
|
|
|
participants_count = await db.scalar(
|
|
|
|
|
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
|
|
|
|
|
)
|
|
|
|
|
games_count = await db.scalar(
|
2025-12-14 20:21:56 +07:00
|
|
|
select(func.count()).select_from(Game).where(
|
|
|
|
|
Game.marathon_id == marathon_id,
|
|
|
|
|
Game.status == GameStatus.APPROVED.value,
|
|
|
|
|
)
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Get user's participation
|
|
|
|
|
participation = await get_participation(db, current_user.id, marathon_id)
|
|
|
|
|
|
|
|
|
|
return MarathonResponse(
|
|
|
|
|
id=marathon.id,
|
|
|
|
|
title=marathon.title,
|
|
|
|
|
description=marathon.description,
|
2025-12-14 20:21:56 +07:00
|
|
|
creator=UserPublic.model_validate(marathon.creator),
|
2025-12-14 02:38:35 +07:00
|
|
|
status=marathon.status,
|
|
|
|
|
invite_code=marathon.invite_code,
|
2025-12-14 20:21:56 +07:00
|
|
|
is_public=marathon.is_public,
|
|
|
|
|
game_proposal_mode=marathon.game_proposal_mode,
|
2025-12-15 03:22:29 +07:00
|
|
|
auto_events_enabled=marathon.auto_events_enabled,
|
2025-12-14 02:38:35 +07:00
|
|
|
start_date=marathon.start_date,
|
|
|
|
|
end_date=marathon.end_date,
|
|
|
|
|
participants_count=participants_count,
|
|
|
|
|
games_count=games_count,
|
|
|
|
|
created_at=marathon.created_at,
|
|
|
|
|
my_participation=ParticipantInfo.model_validate(participation) if participation else None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/{marathon_id}", response_model=MarathonResponse)
|
|
|
|
|
async def update_marathon(
|
|
|
|
|
marathon_id: int,
|
|
|
|
|
data: MarathonUpdate,
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
db: DbSession,
|
|
|
|
|
):
|
2025-12-14 20:21:56 +07:00
|
|
|
# Require organizer role
|
|
|
|
|
await require_organizer(db, current_user, marathon_id)
|
2025-12-14 02:38:35 +07:00
|
|
|
marathon = await get_marathon_or_404(db, marathon_id)
|
|
|
|
|
|
|
|
|
|
if marathon.status != MarathonStatus.PREPARING.value:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Cannot update active or finished marathon")
|
|
|
|
|
|
|
|
|
|
if data.title is not None:
|
|
|
|
|
marathon.title = data.title
|
|
|
|
|
if data.description is not None:
|
|
|
|
|
marathon.description = data.description
|
|
|
|
|
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
|
2025-12-14 20:21:56 +07:00
|
|
|
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
|
2025-12-15 03:22:29 +07:00
|
|
|
if data.auto_events_enabled is not None:
|
|
|
|
|
marathon.auto_events_enabled = data.auto_events_enabled
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
return await get_marathon(marathon_id, current_user, db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/{marathon_id}", response_model=MessageResponse)
|
|
|
|
|
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
2025-12-14 20:21:56 +07:00
|
|
|
# Only creator or admin can delete
|
|
|
|
|
await require_creator(db, current_user, marathon_id)
|
2025-12-14 02:38:35 +07:00
|
|
|
marathon = await get_marathon_or_404(db, marathon_id)
|
|
|
|
|
|
|
|
|
|
await db.delete(marathon)
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
return MessageResponse(message="Marathon deleted")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/{marathon_id}/start", response_model=MarathonResponse)
|
|
|
|
|
async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
2025-12-14 20:21:56 +07:00
|
|
|
# Require organizer role
|
|
|
|
|
await require_organizer(db, current_user, marathon_id)
|
2025-12-14 02:38:35 +07:00
|
|
|
marathon = await get_marathon_or_404(db, marathon_id)
|
|
|
|
|
|
|
|
|
|
if marathon.status != MarathonStatus.PREPARING.value:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
# Check if there are approved games with challenges
|
2025-12-14 02:38:35 +07:00
|
|
|
games_count = await db.scalar(
|
2025-12-14 20:21:56 +07:00
|
|
|
select(func.count()).select_from(Game).where(
|
|
|
|
|
Game.marathon_id == marathon_id,
|
|
|
|
|
Game.status == GameStatus.APPROVED.value,
|
|
|
|
|
)
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
if games_count == 0:
|
2025-12-14 20:21:56 +07:00
|
|
|
raise HTTPException(status_code=400, detail="Add and approve at least one game before starting")
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
marathon.status = MarathonStatus.ACTIVE.value
|
|
|
|
|
|
|
|
|
|
# Log activity
|
|
|
|
|
activity = Activity(
|
|
|
|
|
marathon_id=marathon_id,
|
|
|
|
|
user_id=current_user.id,
|
|
|
|
|
type=ActivityType.START_MARATHON.value,
|
|
|
|
|
data={"title": marathon.title},
|
|
|
|
|
)
|
|
|
|
|
db.add(activity)
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
return await get_marathon(marathon_id, current_user, db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/{marathon_id}/finish", response_model=MarathonResponse)
|
|
|
|
|
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
2025-12-14 20:21:56 +07:00
|
|
|
# Require organizer role
|
|
|
|
|
await require_organizer(db, current_user, marathon_id)
|
2025-12-14 02:38:35 +07:00
|
|
|
marathon = await get_marathon_or_404(db, marathon_id)
|
|
|
|
|
|
|
|
|
|
if marathon.status != MarathonStatus.ACTIVE.value:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Marathon is not active")
|
|
|
|
|
|
|
|
|
|
marathon.status = MarathonStatus.FINISHED.value
|
|
|
|
|
|
|
|
|
|
# Log activity
|
|
|
|
|
activity = Activity(
|
|
|
|
|
marathon_id=marathon_id,
|
|
|
|
|
user_id=current_user.id,
|
|
|
|
|
type=ActivityType.FINISH_MARATHON.value,
|
|
|
|
|
data={"title": marathon.title},
|
|
|
|
|
)
|
|
|
|
|
db.add(activity)
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
return await get_marathon(marathon_id, current_user, db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/join", response_model=MarathonResponse)
|
|
|
|
|
async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession):
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Marathon).where(Marathon.invite_code == data.invite_code)
|
|
|
|
|
)
|
|
|
|
|
marathon = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if not marathon:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Invalid invite code")
|
|
|
|
|
|
|
|
|
|
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,
|
2025-12-14 20:21:56 +07:00
|
|
|
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,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
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.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser])
|
|
|
|
|
async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
|
|
|
|
await get_marathon_or_404(db, marathon_id)
|
|
|
|
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Participant)
|
|
|
|
|
.options(selectinload(Participant.user))
|
|
|
|
|
.where(Participant.marathon_id == marathon_id)
|
|
|
|
|
.order_by(Participant.joined_at)
|
|
|
|
|
)
|
|
|
|
|
participants = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
ParticipantWithUser(
|
|
|
|
|
id=p.id,
|
2025-12-14 20:21:56 +07:00
|
|
|
role=p.role,
|
2025-12-14 02:38:35 +07:00
|
|
|
total_points=p.total_points,
|
|
|
|
|
current_streak=p.current_streak,
|
|
|
|
|
drop_count=p.drop_count,
|
|
|
|
|
joined_at=p.joined_at,
|
|
|
|
|
user=UserPublic.model_validate(p.user),
|
|
|
|
|
)
|
|
|
|
|
for p in participants
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
@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),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Participant)
|
|
|
|
|
.options(selectinload(Participant.user))
|
|
|
|
|
.where(Participant.marathon_id == marathon_id)
|
|
|
|
|
.order_by(Participant.total_points.desc())
|
|
|
|
|
)
|
|
|
|
|
participants = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
leaderboard = []
|
|
|
|
|
for rank, p in enumerate(participants, 1):
|
|
|
|
|
# Count completed and dropped assignments
|
|
|
|
|
completed = await db.scalar(
|
|
|
|
|
select(func.count()).select_from(Assignment).where(
|
|
|
|
|
Assignment.participant_id == p.id,
|
|
|
|
|
Assignment.status == AssignmentStatus.COMPLETED.value,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
dropped = await db.scalar(
|
|
|
|
|
select(func.count()).select_from(Assignment).where(
|
|
|
|
|
Assignment.participant_id == p.id,
|
|
|
|
|
Assignment.status == AssignmentStatus.DROPPED.value,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
leaderboard.append(LeaderboardEntry(
|
|
|
|
|
rank=rank,
|
|
|
|
|
user=UserPublic.model_validate(p.user),
|
|
|
|
|
total_points=p.total_points,
|
|
|
|
|
current_streak=p.current_streak,
|
|
|
|
|
completed_count=completed,
|
|
|
|
|
dropped_count=dropped,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
return leaderboard
|