1173 lines
41 KiB
Python
1173 lines
41 KiB
Python
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 = {
|
||
"assignment_id": assignment.id,
|
||
"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
|
||
)
|