Add events

This commit is contained in:
2025-12-15 03:22:29 +07:00
parent 1a882fb2e0
commit 4239ea8516
31 changed files with 7288 additions and 75 deletions

View File

@@ -0,0 +1,866 @@
from datetime import datetime
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, and_, or_
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import (
Marathon, MarathonStatus, Participant, ParticipantRole,
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge,
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
)
from app.schemas import (
EventCreate, EventResponse, ActiveEventResponse, EventEffects,
MessageResponse, SwapRequest, ChallengeResponse, GameShort, SwapCandidate,
SwapRequestCreate, SwapRequestResponse, SwapRequestChallengeInfo, MySwapRequests,
CommonEnemyLeaderboard,
)
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
from app.schemas.user import UserPublic
from app.services.events import event_service
router = APIRouter(tags=["events"])
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
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 marathon
async def require_organizer(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="Not a participant")
if participant.role != ParticipantRole.ORGANIZER.value:
raise HTTPException(status_code=403, detail="Only organizers can manage events")
return participant
async def require_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="Not a participant")
return participant
def event_to_response(event: Event) -> EventResponse:
return EventResponse(
id=event.id,
type=event.type,
start_time=event.start_time,
end_time=event.end_time,
is_active=event.is_active,
created_by=UserPublic(
id=event.created_by.id,
login=event.created_by.login,
nickname=event.created_by.nickname,
avatar_url=None,
role=event.created_by.role,
created_at=event.created_by.created_at,
) if event.created_by else None,
data=event.data,
created_at=event.created_at,
)
@router.get("/marathons/{marathon_id}/event", response_model=ActiveEventResponse)
async def get_active_event(
marathon_id: int,
current_user: CurrentUser,
db: DbSession
):
"""Get currently active event for marathon"""
await get_marathon_or_404(db, marathon_id)
await require_participant(db, current_user.id, marathon_id)
event = await event_service.get_active_event(db, marathon_id)
effects = event_service.get_event_effects(event)
time_remaining = event_service.get_time_remaining(event)
return ActiveEventResponse(
event=event_to_response(event) if event else None,
effects=effects,
time_remaining_seconds=time_remaining,
)
@router.get("/marathons/{marathon_id}/events", response_model=list[EventResponse])
async def list_events(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
limit: int = 20,
offset: int = 0,
):
"""Get event history for marathon"""
await get_marathon_or_404(db, marathon_id)
await require_participant(db, current_user.id, marathon_id)
result = await db.execute(
select(Event)
.options(selectinload(Event.created_by))
.where(Event.marathon_id == marathon_id)
.order_by(Event.created_at.desc())
.limit(limit)
.offset(offset)
)
events = result.scalars().all()
return [event_to_response(e) for e in events]
@router.post("/marathons/{marathon_id}/events", response_model=EventResponse)
async def start_event(
marathon_id: int,
data: EventCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Start a new event (organizer only)"""
marathon = await get_marathon_or_404(db, marathon_id)
await require_organizer(db, current_user.id, marathon_id)
if marathon.status != MarathonStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Marathon is not active")
# Validate common_enemy requires challenge_id
if data.type == EventType.COMMON_ENEMY.value and not data.challenge_id:
raise HTTPException(
status_code=400,
detail="Common enemy event requires challenge_id"
)
try:
event = await event_service.start_event(
db=db,
marathon_id=marathon_id,
event_type=data.type,
created_by_id=current_user.id,
duration_minutes=data.duration_minutes,
challenge_id=data.challenge_id,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Log activity
event_info = EVENT_INFO.get(EventType(data.type), {})
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.EVENT_START.value,
data={
"event_type": data.type,
"event_name": event_info.get("name", data.type),
},
)
db.add(activity)
await db.commit()
# Reload with relationship
await db.refresh(event, ["created_by"])
return event_to_response(event)
@router.delete("/marathons/{marathon_id}/event", response_model=MessageResponse)
async def stop_event(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Stop active event (organizer only)"""
await get_marathon_or_404(db, marathon_id)
await require_organizer(db, current_user.id, marathon_id)
event = await event_service.get_active_event(db, marathon_id)
if not event:
raise HTTPException(status_code=404, detail="No active event")
# Build activity data before ending event
event_info = EVENT_INFO.get(EventType(event.type), {})
activity_data = {
"event_type": event.type,
"event_name": event_info.get("name", event.type),
"auto_closed": False,
}
# For common_enemy, include winners in activity
if event.type == EventType.COMMON_ENEMY.value:
event_data = event.data or {}
completions = event_data.get("completions", [])
if completions:
activity_data["winners"] = [
{
"user_id": c["user_id"],
"rank": c["rank"],
"bonus_points": COMMON_ENEMY_BONUSES.get(c["rank"], 0),
}
for c in completions[:3] # Top 3
]
await event_service.end_event(db, event.id)
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.EVENT_END.value,
data=activity_data,
)
db.add(activity)
await db.commit()
return MessageResponse(message="Event stopped")
def build_swap_request_response(
swap_req: SwapRequestModel,
) -> SwapRequestResponse:
"""Build SwapRequestResponse from model with loaded relationships"""
return SwapRequestResponse(
id=swap_req.id,
status=swap_req.status,
from_user=UserPublic(
id=swap_req.from_participant.user.id,
login=swap_req.from_participant.user.login,
nickname=swap_req.from_participant.user.nickname,
avatar_url=None,
role=swap_req.from_participant.user.role,
created_at=swap_req.from_participant.user.created_at,
),
to_user=UserPublic(
id=swap_req.to_participant.user.id,
login=swap_req.to_participant.user.login,
nickname=swap_req.to_participant.user.nickname,
avatar_url=None,
role=swap_req.to_participant.user.role,
created_at=swap_req.to_participant.user.created_at,
),
from_challenge=SwapRequestChallengeInfo(
title=swap_req.from_assignment.challenge.title,
description=swap_req.from_assignment.challenge.description,
points=swap_req.from_assignment.challenge.points,
difficulty=swap_req.from_assignment.challenge.difficulty,
game_title=swap_req.from_assignment.challenge.game.title,
),
to_challenge=SwapRequestChallengeInfo(
title=swap_req.to_assignment.challenge.title,
description=swap_req.to_assignment.challenge.description,
points=swap_req.to_assignment.challenge.points,
difficulty=swap_req.to_assignment.challenge.difficulty,
game_title=swap_req.to_assignment.challenge.game.title,
),
created_at=swap_req.created_at,
responded_at=swap_req.responded_at,
)
@router.post("/marathons/{marathon_id}/swap-requests", response_model=SwapRequestResponse)
async def create_swap_request(
marathon_id: int,
data: SwapRequestCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a swap request to another participant (requires their confirmation)"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Check active swap event
event = await event_service.get_active_event(db, marathon_id)
if not event or event.type != EventType.SWAP.value:
raise HTTPException(status_code=400, detail="No active swap event")
# Get target participant
result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(
Participant.id == data.target_participant_id,
Participant.marathon_id == marathon_id,
)
)
target = result.scalar_one_or_none()
if not target:
raise HTTPException(status_code=404, detail="Target participant not found")
if target.id == participant.id:
raise HTTPException(status_code=400, detail="Cannot swap with yourself")
# Get both active assignments
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
)
my_assignment = result.scalar_one_or_none()
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == target.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
)
target_assignment = result.scalar_one_or_none()
if not my_assignment or not target_assignment:
raise HTTPException(
status_code=400,
detail="Both participants must have active assignments to swap"
)
# Check if there's already a pending request between these participants
result = await db.execute(
select(SwapRequestModel).where(
SwapRequestModel.event_id == event.id,
SwapRequestModel.status == SwapRequestStatus.PENDING.value,
or_(
and_(
SwapRequestModel.from_participant_id == participant.id,
SwapRequestModel.to_participant_id == target.id,
),
and_(
SwapRequestModel.from_participant_id == target.id,
SwapRequestModel.to_participant_id == participant.id,
),
)
)
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=400,
detail="A pending swap request already exists between you and this participant"
)
# Create swap request
swap_request = SwapRequestModel(
event_id=event.id,
from_participant_id=participant.id,
to_participant_id=target.id,
from_assignment_id=my_assignment.id,
to_assignment_id=target_assignment.id,
status=SwapRequestStatus.PENDING.value,
)
db.add(swap_request)
await db.commit()
await db.refresh(swap_request)
# Load relationships for response
result = await db.execute(
select(SwapRequestModel)
.options(
selectinload(SwapRequestModel.from_participant).selectinload(Participant.user),
selectinload(SwapRequestModel.to_participant).selectinload(Participant.user),
selectinload(SwapRequestModel.from_assignment)
.selectinload(Assignment.challenge)
.selectinload(Challenge.game),
selectinload(SwapRequestModel.to_assignment)
.selectinload(Assignment.challenge)
.selectinload(Challenge.game),
)
.where(SwapRequestModel.id == swap_request.id)
)
swap_request = result.scalar_one()
return build_swap_request_response(swap_request)
@router.get("/marathons/{marathon_id}/swap-requests", response_model=MySwapRequests)
async def get_my_swap_requests(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get current user's incoming and outgoing swap requests"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Check active swap event
event = await event_service.get_active_event(db, marathon_id)
if not event or event.type != EventType.SWAP.value:
return MySwapRequests(incoming=[], outgoing=[])
# Get all pending requests for this event involving this participant
result = await db.execute(
select(SwapRequestModel)
.options(
selectinload(SwapRequestModel.from_participant).selectinload(Participant.user),
selectinload(SwapRequestModel.to_participant).selectinload(Participant.user),
selectinload(SwapRequestModel.from_assignment)
.selectinload(Assignment.challenge)
.selectinload(Challenge.game),
selectinload(SwapRequestModel.to_assignment)
.selectinload(Assignment.challenge)
.selectinload(Challenge.game),
)
.where(
SwapRequestModel.event_id == event.id,
SwapRequestModel.status == SwapRequestStatus.PENDING.value,
or_(
SwapRequestModel.from_participant_id == participant.id,
SwapRequestModel.to_participant_id == participant.id,
)
)
.order_by(SwapRequestModel.created_at.desc())
)
requests = result.scalars().all()
incoming = []
outgoing = []
for req in requests:
response = build_swap_request_response(req)
if req.to_participant_id == participant.id:
incoming.append(response)
else:
outgoing.append(response)
return MySwapRequests(incoming=incoming, outgoing=outgoing)
@router.post("/marathons/{marathon_id}/swap-requests/{request_id}/accept", response_model=MessageResponse)
async def accept_swap_request(
marathon_id: int,
request_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Accept a swap request (performs the actual swap)"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Check active swap event
event = await event_service.get_active_event(db, marathon_id)
if not event or event.type != EventType.SWAP.value:
raise HTTPException(status_code=400, detail="No active swap event")
# Get the swap request
result = await db.execute(
select(SwapRequestModel)
.options(
selectinload(SwapRequestModel.from_participant).selectinload(Participant.user),
selectinload(SwapRequestModel.to_participant),
selectinload(SwapRequestModel.from_assignment),
selectinload(SwapRequestModel.to_assignment),
)
.where(
SwapRequestModel.id == request_id,
SwapRequestModel.event_id == event.id,
)
)
swap_request = result.scalar_one_or_none()
if not swap_request:
raise HTTPException(status_code=404, detail="Swap request not found")
# Check that current user is the target
if swap_request.to_participant_id != participant.id:
raise HTTPException(status_code=403, detail="You can only accept requests sent to you")
if swap_request.status != SwapRequestStatus.PENDING.value:
raise HTTPException(status_code=400, detail="This request is no longer pending")
# Verify both assignments are still active
result = await db.execute(
select(Assignment).where(Assignment.id == swap_request.from_assignment_id)
)
from_assignment = result.scalar_one_or_none()
result = await db.execute(
select(Assignment).where(Assignment.id == swap_request.to_assignment_id)
)
to_assignment = result.scalar_one_or_none()
if not from_assignment or not to_assignment:
swap_request.status = SwapRequestStatus.CANCELLED.value
swap_request.responded_at = datetime.utcnow()
await db.commit()
raise HTTPException(status_code=400, detail="One or both assignments no longer exist")
if from_assignment.status != AssignmentStatus.ACTIVE.value or to_assignment.status != AssignmentStatus.ACTIVE.value:
swap_request.status = SwapRequestStatus.CANCELLED.value
swap_request.responded_at = datetime.utcnow()
await db.commit()
raise HTTPException(status_code=400, detail="One or both assignments are no longer active")
# Perform the swap
from_challenge_id = from_assignment.challenge_id
from_assignment.challenge_id = to_assignment.challenge_id
to_assignment.challenge_id = from_challenge_id
# Update request status
swap_request.status = SwapRequestStatus.ACCEPTED.value
swap_request.responded_at = datetime.utcnow()
# Cancel any other pending requests involving these participants in this event
await db.execute(
select(SwapRequestModel)
.where(
SwapRequestModel.event_id == event.id,
SwapRequestModel.status == SwapRequestStatus.PENDING.value,
SwapRequestModel.id != request_id,
or_(
SwapRequestModel.from_participant_id.in_([swap_request.from_participant_id, swap_request.to_participant_id]),
SwapRequestModel.to_participant_id.in_([swap_request.from_participant_id, swap_request.to_participant_id]),
)
)
)
# Update those to cancelled
from sqlalchemy import update
await db.execute(
update(SwapRequestModel)
.where(
SwapRequestModel.event_id == event.id,
SwapRequestModel.status == SwapRequestStatus.PENDING.value,
SwapRequestModel.id != request_id,
or_(
SwapRequestModel.from_participant_id.in_([swap_request.from_participant_id, swap_request.to_participant_id]),
SwapRequestModel.to_participant_id.in_([swap_request.from_participant_id, swap_request.to_participant_id]),
)
)
.values(status=SwapRequestStatus.CANCELLED.value, responded_at=datetime.utcnow())
)
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.SWAP.value,
data={
"swapped_with_user_id": swap_request.from_participant.user_id,
"swapped_with_nickname": swap_request.from_participant.user.nickname,
},
)
db.add(activity)
await db.commit()
return MessageResponse(message="Swap completed successfully!")
@router.post("/marathons/{marathon_id}/swap-requests/{request_id}/decline", response_model=MessageResponse)
async def decline_swap_request(
marathon_id: int,
request_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Decline a swap request"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Check active swap event (allow declining even if event ended)
event = await event_service.get_active_event(db, marathon_id)
# Get the swap request
result = await db.execute(
select(SwapRequestModel).where(SwapRequestModel.id == request_id)
)
swap_request = result.scalar_one_or_none()
if not swap_request:
raise HTTPException(status_code=404, detail="Swap request not found")
# Check that current user is the target
if swap_request.to_participant_id != participant.id:
raise HTTPException(status_code=403, detail="You can only decline requests sent to you")
if swap_request.status != SwapRequestStatus.PENDING.value:
raise HTTPException(status_code=400, detail="This request is no longer pending")
# Update status
swap_request.status = SwapRequestStatus.DECLINED.value
swap_request.responded_at = datetime.utcnow()
await db.commit()
return MessageResponse(message="Swap request declined")
@router.delete("/marathons/{marathon_id}/swap-requests/{request_id}", response_model=MessageResponse)
async def cancel_swap_request(
marathon_id: int,
request_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Cancel your own outgoing swap request"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Get the swap request
result = await db.execute(
select(SwapRequestModel).where(SwapRequestModel.id == request_id)
)
swap_request = result.scalar_one_or_none()
if not swap_request:
raise HTTPException(status_code=404, detail="Swap request not found")
# Check that current user is the sender
if swap_request.from_participant_id != participant.id:
raise HTTPException(status_code=403, detail="You can only cancel your own requests")
if swap_request.status != SwapRequestStatus.PENDING.value:
raise HTTPException(status_code=400, detail="This request is no longer pending")
# Update status
swap_request.status = SwapRequestStatus.CANCELLED.value
swap_request.responded_at = datetime.utcnow()
await db.commit()
return MessageResponse(message="Swap request cancelled")
@router.post("/marathons/{marathon_id}/rematch/{assignment_id}", response_model=MessageResponse)
async def rematch_assignment(
marathon_id: int,
assignment_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Retry a dropped assignment (during rematch event)"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Check active rematch event
event = await event_service.get_active_event(db, marathon_id)
if not event or event.type != EventType.REMATCH.value:
raise HTTPException(status_code=400, detail="No active rematch event")
# Check no current active assignment
result = await db.execute(
select(Assignment).where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="You already have an active assignment")
# Get the dropped assignment
result = await db.execute(
select(Assignment)
.options(selectinload(Assignment.challenge))
.where(
Assignment.id == assignment_id,
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.DROPPED.value,
)
)
dropped = result.scalar_one_or_none()
if not dropped:
raise HTTPException(status_code=404, detail="Dropped assignment not found")
# Create new assignment for the same challenge (with rematch event_type for 50% points)
new_assignment = Assignment(
participant_id=participant.id,
challenge_id=dropped.challenge_id,
status=AssignmentStatus.ACTIVE.value,
event_type=EventType.REMATCH.value,
)
db.add(new_assignment)
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.REMATCH.value,
data={
"challenge": dropped.challenge.title,
"original_assignment_id": assignment_id,
},
)
db.add(activity)
await db.commit()
return MessageResponse(message="Rematch started! Complete for 50% points")
class DroppedAssignmentResponse(BaseModel):
id: int
challenge: ChallengeResponse
dropped_at: datetime
class Config:
from_attributes = True
@router.get("/marathons/{marathon_id}/dropped-assignments", response_model=list[DroppedAssignmentResponse])
async def get_dropped_assignments(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get dropped assignments that can be rematched"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.DROPPED.value,
)
.order_by(Assignment.started_at.desc())
)
dropped = result.scalars().all()
return [
DroppedAssignmentResponse(
id=a.id,
challenge=ChallengeResponse(
id=a.challenge.id,
title=a.challenge.title,
description=a.challenge.description,
type=a.challenge.type,
difficulty=a.challenge.difficulty,
points=a.challenge.points,
estimated_time=a.challenge.estimated_time,
proof_type=a.challenge.proof_type,
proof_hint=a.challenge.proof_hint,
game=GameShort(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=None,
),
is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at,
),
dropped_at=a.completed_at or a.started_at,
)
for a in dropped
]
@router.get("/marathons/{marathon_id}/swap-candidates", response_model=list[SwapCandidate])
async def get_swap_candidates(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get participants with active assignments available for swap (during swap event)"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Check active swap event
event = await event_service.get_active_event(db, marathon_id)
if not event or event.type != EventType.SWAP.value:
raise HTTPException(status_code=400, detail="No active swap event")
# Get all participants except current user with active assignments
from app.models import Game
result = await db.execute(
select(Participant, Assignment, Challenge, Game)
.join(Assignment, Assignment.participant_id == Participant.id)
.join(Challenge, Assignment.challenge_id == Challenge.id)
.join(Game, Challenge.game_id == Game.id)
.options(selectinload(Participant.user))
.where(
Participant.marathon_id == marathon_id,
Participant.id != participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
)
rows = result.all()
return [
SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
login=p.user.login,
nickname=p.user.nickname,
avatar_url=None,
role=p.user.role,
created_at=p.user.created_at,
),
challenge_title=challenge.title,
challenge_description=challenge.description,
challenge_points=challenge.points,
challenge_difficulty=challenge.difficulty,
game_title=game.title,
)
for p, assignment, challenge, game in rows
]
@router.get("/marathons/{marathon_id}/common-enemy-leaderboard", response_model=list[CommonEnemyLeaderboard])
async def get_common_enemy_leaderboard(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get leaderboard for common enemy event (who completed the challenge)"""
await get_marathon_or_404(db, marathon_id)
await require_participant(db, current_user.id, marathon_id)
# Get active common enemy event
event = await event_service.get_active_event(db, marathon_id)
if not event or event.type != EventType.COMMON_ENEMY.value:
return []
# Get completions from event data
data = event.data or {}
completions = data.get("completions", [])
if not completions:
return []
# Get user info for all participants who completed
user_ids = [c["user_id"] for c in completions]
result = await db.execute(
select(User).where(User.id.in_(user_ids))
)
users_by_id = {u.id: u for u in result.scalars().all()}
# Build leaderboard
leaderboard = []
for completion in completions:
user = users_by_id.get(completion["user_id"])
if user:
leaderboard.append(
CommonEnemyLeaderboard(
participant_id=completion["participant_id"],
user=UserPublic(
id=user.id,
login=user.login,
nickname=user.nickname,
avatar_url=None,
role=user.role,
created_at=user.created_at,
),
completed_at=completion.get("completed_at"),
rank=completion.get("rank"),
bonus_points=COMMON_ENEMY_BONUSES.get(completion.get("rank", 0), 0),
)
)
return leaderboard