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") @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 # ==================== 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 )