Files
game-marathon/backend/app/api/v1/marathons.py
2025-12-17 19:50:55 +07:00

537 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import timedelta
import secrets
import string
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import (
DbSession, CurrentUser,
require_participant, require_organizer, require_creator,
get_participant,
)
from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
)
from app.schemas import (
MarathonCreate,
MarathonUpdate,
MarathonResponse,
MarathonListItem,
MarathonPublicInfo,
JoinMarathon,
ParticipantInfo,
ParticipantWithUser,
LeaderboardEntry,
MessageResponse,
UserPublic,
SetParticipantRole,
)
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(prefix="/marathons", tags=["marathons"])
# 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(func.upper(Marathon.invite_code) == invite_code.upper())
.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,
)
def generate_invite_code() -> str:
"""Generate a clean 8-character uppercase alphanumeric code."""
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
result = await db.execute(
select(Marathon)
.options(selectinload(Marathon.creator))
.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):
"""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())
)
marathons = []
for row in result.all():
marathon = row[0]
marathons.append(MarathonListItem(
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,
))
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,
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 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)
await db.commit()
await db.refresh(marathon)
return MarathonResponse(
id=marathon.id,
title=marathon.title,
description=marathon.description,
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,
auto_events_enabled=marathon.auto_events_enabled,
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)
# 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,
Game.status == GameStatus.APPROVED.value,
)
)
# 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,
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,
auto_events_enabled=marathon.auto_events_enabled,
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,
):
# Require organizer role
await require_organizer(db, current_user, marathon_id)
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
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
if data.auto_events_enabled is not None:
marathon.auto_events_enabled = data.auto_events_enabled
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):
# Only creator or admin can delete
await require_creator(db, current_user, marathon_id)
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):
# Require organizer role
await require_organizer(db, current_user, marathon_id)
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")
# Check if there are approved games
games_result = await db.execute(
select(Game).where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
)
)
approved_games = games_result.scalars().all()
if len(approved_games) == 0:
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
# Check that all approved games have at least one challenge
games_without_challenges = []
for game in approved_games:
challenge_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
)
if challenge_count == 0:
games_without_challenges.append(game.title)
if games_without_challenges:
games_list = ", ".join(games_without_challenges)
raise HTTPException(
status_code=400,
detail=f"У следующих игр нет челленджей: {games_list}"
)
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()
# Send Telegram notifications
await telegram_notifier.notify_marathon_start(db, marathon_id, marathon.title)
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):
# Require organizer role
await require_organizer(db, current_user, marathon_id)
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()
# Send Telegram notifications
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
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(func.upper(Marathon.invite_code) == data.invite_code.upper())
)
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,
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)
# 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,
role=p.role,
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
]
@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)
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