Files
game-marathon/backend/app/api/v1/events.py

1172 lines
41 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 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 fastapi import UploadFile, File, Form
from pathlib import Path
import uuid
from app.schemas import (
EventCreate, EventResponse, ActiveEventResponse, EventEffects,
MessageResponse, SwapRequest, ChallengeResponse, GameShort, SwapCandidate,
SwapRequestCreate, SwapRequestResponse, SwapRequestChallengeInfo, MySwapRequests,
CommonEnemyLeaderboard, EventAssignmentResponse, AssignmentResponse, CompleteResult,
)
from app.core.config import settings
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")
# ==================== Game Choice Event Endpoints ====================
class GameChoiceChallengeResponse(BaseModel):
"""Challenge option for game choice event"""
id: int
title: str
description: str
difficulty: str
points: int
estimated_time: int | None
proof_type: str
proof_hint: str | None
class GameChoiceChallengesResponse(BaseModel):
"""Response with available challenges for game choice"""
game_id: int
game_title: str
challenges: list[GameChoiceChallengeResponse]
class GameChoiceSelectRequest(BaseModel):
"""Request to select a challenge during game choice event"""
challenge_id: int
@router.get("/marathons/{marathon_id}/game-choice/challenges", response_model=GameChoiceChallengesResponse)
async def get_game_choice_challenges(
marathon_id: int,
game_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get 3 random challenges from a game for game choice event"""
from app.models import Game
from sqlalchemy.sql.expression import func
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Check active game_choice event
event = await event_service.get_active_event(db, marathon_id)
if not event or event.type != EventType.GAME_CHOICE.value:
raise HTTPException(status_code=400, detail="No active game choice event")
# Get the game
result = await db.execute(
select(Game).where(Game.id == game_id, Game.marathon_id == marathon_id)
)
game = result.scalar_one_or_none()
if not game:
raise HTTPException(status_code=404, detail="Game not found")
# Get 3 random challenges from this game
result = await db.execute(
select(Challenge)
.where(Challenge.game_id == game_id)
.order_by(func.random())
.limit(3)
)
challenges = result.scalars().all()
if not challenges:
raise HTTPException(status_code=400, detail="No challenges available for this game")
return GameChoiceChallengesResponse(
game_id=game.id,
game_title=game.title,
challenges=[
GameChoiceChallengeResponse(
id=c.id,
title=c.title,
description=c.description,
difficulty=c.difficulty,
points=c.points,
estimated_time=c.estimated_time,
proof_type=c.proof_type,
proof_hint=c.proof_hint,
)
for c in challenges
],
)
@router.post("/marathons/{marathon_id}/game-choice/select", response_model=MessageResponse)
async def select_game_choice_challenge(
marathon_id: int,
data: GameChoiceSelectRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Select a challenge during game choice event (replaces current assignment if any)"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Check active game_choice event
event = await event_service.get_active_event(db, marathon_id)
if not event or event.type != EventType.GAME_CHOICE.value:
raise HTTPException(status_code=400, detail="No active game choice event")
# Get the challenge
result = await db.execute(
select(Challenge)
.options(selectinload(Challenge.game))
.where(Challenge.id == data.challenge_id)
)
challenge = result.scalar_one_or_none()
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
# Verify challenge belongs to this marathon
if challenge.game.marathon_id != marathon_id:
raise HTTPException(status_code=400, detail="Challenge does not belong to this marathon")
# Check for current active assignment (non-event)
result = await db.execute(
select(Assignment)
.options(selectinload(Assignment.challenge))
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_event_assignment == False,
)
)
current_assignment = result.scalar_one_or_none()
# If there's a current assignment, replace it (free drop during this event)
old_challenge_title = None
if current_assignment:
old_challenge_title = current_assignment.challenge.title
# Mark old assignment as dropped (no penalty during game_choice event)
current_assignment.status = AssignmentStatus.DROPPED.value
current_assignment.completed_at = datetime.utcnow()
# Create new assignment with chosen challenge
new_assignment = Assignment(
participant_id=participant.id,
challenge_id=data.challenge_id,
status=AssignmentStatus.ACTIVE.value,
event_type=EventType.GAME_CHOICE.value,
)
db.add(new_assignment)
# Log activity
activity_data = {
"game": challenge.game.title,
"challenge": challenge.title,
"event_type": EventType.GAME_CHOICE.value,
}
if old_challenge_title:
activity_data["replaced_challenge"] = old_challenge_title
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.SPIN.value, # Treat as a spin activity
data=activity_data,
)
db.add(activity)
await db.commit()
if old_challenge_title:
return MessageResponse(message=f"Задание заменено! Теперь у вас: {challenge.title}")
else:
return MessageResponse(message=f"Задание выбрано: {challenge.title}")
@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
# ==================== Event Assignment Endpoints ====================
def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
"""Convert Assignment model to AssignmentResponse"""
challenge = assignment.challenge
game = challenge.game
return AssignmentResponse(
id=assignment.id,
challenge=ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(
id=game.id,
title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
),
status=assignment.status,
proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" if assignment.proof_path else assignment.proof_url,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
)
@router.get("/marathons/{marathon_id}/event-assignment", response_model=EventAssignmentResponse)
async def get_event_assignment(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get current user's event assignment (Common Enemy)"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Get active common enemy event
event = await event_service.get_active_event(db, marathon_id)
# Find event assignment for this participant
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant.id,
Assignment.is_event_assignment == True,
)
.order_by(Assignment.started_at.desc())
)
assignment = result.scalar_one_or_none()
# Check if completed
is_completed = assignment.status == AssignmentStatus.COMPLETED.value if assignment else False
# If no active event but we have an assignment, it might be from a past event
# Only return it if the event is still active
if not event or event.type != EventType.COMMON_ENEMY.value:
# Check if assignment belongs to an inactive event
if assignment and assignment.event_id:
result = await db.execute(
select(Event).where(Event.id == assignment.event_id)
)
assignment_event = result.scalar_one_or_none()
if assignment_event and not assignment_event.is_active:
# Event ended, don't return the assignment
return EventAssignmentResponse(
assignment=None,
event_id=None,
challenge_id=None,
is_completed=False,
)
return EventAssignmentResponse(
assignment=assignment_to_response(assignment) if assignment else None,
event_id=event.id if event else None,
challenge_id=event.data.get("challenge_id") if event and event.data else None,
is_completed=is_completed,
)
@router.post("/event-assignments/{assignment_id}/complete", response_model=CompleteResult)
async def complete_event_assignment(
assignment_id: int,
current_user: CurrentUser,
db: DbSession,
proof_url: str | None = Form(None),
comment: str | None = Form(None),
proof_file: UploadFile | None = File(None),
):
"""Complete an event assignment (Common Enemy) with proof"""
from app.services.points import PointsService
points_service = PointsService()
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if assignment.participant.user_id != current_user.id:
raise HTTPException(status_code=403, detail="This is not your assignment")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Assignment is not active")
# Must be event assignment
if not assignment.is_event_assignment:
raise HTTPException(status_code=400, detail="This is not an event assignment")
# Need either file or URL
if not proof_file and not proof_url:
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
# Handle file upload
if proof_file:
contents = await proof_file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
)
filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}"
filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "wb") as f:
f.write(contents)
assignment.proof_path = str(filepath)
else:
assignment.proof_url = proof_url
assignment.proof_comment = comment
# Get marathon_id
marathon_id = assignment.challenge.game.marathon_id
# Get active event for bonus calculation
active_event = await event_service.get_active_event(db, marathon_id)
# Calculate base points (no streak bonus for event assignments)
participant = assignment.participant
challenge = assignment.challenge
base_points = challenge.points
# Handle common enemy bonus
common_enemy_bonus = 0
common_enemy_closed = False
common_enemy_winners = None
if active_event and active_event.type == EventType.COMMON_ENEMY.value:
common_enemy_bonus, common_enemy_closed, common_enemy_winners = await event_service.record_common_enemy_completion(
db, active_event, participant.id, current_user.id
)
total_points = base_points + common_enemy_bonus
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
assignment.points_earned = total_points
assignment.completed_at = datetime.utcnow()
# Update participant points (event assignments add to total but don't affect streak)
participant.total_points += total_points
# Log activity
activity_data = {
"game": challenge.game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": total_points,
"event_type": EventType.COMMON_ENEMY.value,
"is_event_assignment": True,
}
if common_enemy_bonus:
activity_data["common_enemy_bonus"] = common_enemy_bonus
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.COMPLETE.value,
data=activity_data,
)
db.add(activity)
# If common enemy event auto-closed, log the event end with winners
if common_enemy_closed and common_enemy_winners:
# Load winner nicknames
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
users_result = await db.execute(
select(User).where(User.id.in_(winner_user_ids))
)
users_map = {u.id: u.nickname for u in users_result.scalars().all()}
winners_data = [
{
"user_id": w["user_id"],
"nickname": users_map.get(w["user_id"], "Unknown"),
"rank": w["rank"],
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
}
for w in common_enemy_winners
]
event_end_activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.EVENT_END.value,
data={
"event_type": EventType.COMMON_ENEMY.value,
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
"auto_closed": True,
"winners": winners_data,
},
)
db.add(event_end_activity)
await db.commit()
return CompleteResult(
points_earned=total_points,
streak_bonus=0, # Event assignments don't give streak bonus
total_points=participant.total_points,
new_streak=participant.current_streak, # Streak unchanged
)