import random from datetime import datetime from fastapi import APIRouter, HTTPException, UploadFile, File, Form from sqlalchemy import select, func from sqlalchemy.orm import selectinload from app.api.deps import DbSession, CurrentUser from app.core.config import settings from app.models import ( Marathon, MarathonStatus, Game, Challenge, Participant, Assignment, AssignmentStatus, Activity, ActivityType, EventType, Difficulty, User, BonusAssignment, BonusAssignmentStatus, GameType, DisputeStatus, ) from app.schemas import ( SpinResult, AssignmentResponse, CompleteResult, DropResult, GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse, ) from app.schemas.game import PlaythroughInfo from app.services.points import PointsService from app.services.events import event_service from app.services.storage import storage_service from app.api.v1.games import get_available_games_for_participant router = APIRouter(tags=["wheel"]) points_service = PointsService() async def get_participant_or_403(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="You are not a participant of this marathon") return participant async def get_active_assignment(db, participant_id: int, is_event: bool = False) -> Assignment | None: """Get active assignment for participant. Args: db: Database session participant_id: Participant ID is_event: If True, get event assignment (Common Enemy). If False, get regular assignment. """ result = await db.execute( select(Assignment) .options( selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses ) .where( Assignment.participant_id == participant_id, Assignment.status == AssignmentStatus.ACTIVE.value, Assignment.is_event_assignment == is_event, ) ) return result.scalar_one_or_none() async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment | None: """Get the oldest returned assignment that needs to be redone.""" result = await db.execute( select(Assignment) .options( selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses ) .where( Assignment.participant_id == participant_id, Assignment.status == AssignmentStatus.RETURNED.value, Assignment.is_event_assignment == False, ) .order_by(Assignment.completed_at.asc()) # Oldest first .limit(1) ) return result.scalar_one_or_none() async def activate_returned_assignment(db, returned_assignment: Assignment) -> None: """ Re-activate a returned assignment. Simply changes the status back to ACTIVE. """ returned_assignment.status = AssignmentStatus.ACTIVE.value returned_assignment.started_at = datetime.utcnow() # Clear previous proof data for fresh attempt returned_assignment.proof_path = None returned_assignment.proof_url = None returned_assignment.proof_comment = None returned_assignment.completed_at = None returned_assignment.points_earned = 0 @router.post("/marathons/{marathon_id}/spin", response_model=SpinResult) async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession): """Spin the wheel to get a random game and challenge (or playthrough)""" # Check marathon is active 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") if marathon.status != MarathonStatus.ACTIVE.value: raise HTTPException(status_code=400, detail="Marathon is not active") # Check if marathon has expired by end_date if marathon.end_date and datetime.utcnow() > marathon.end_date: raise HTTPException(status_code=400, detail="Marathon has ended") participant = await get_participant_or_403(db, current_user.id, marathon_id) # Check no active regular assignment (event assignments are separate) active = await get_active_assignment(db, participant.id, is_event=False) if active: raise HTTPException(status_code=400, detail="You already have an active assignment") # Get available games (filtered by completion status) available_games, _ = await get_available_games_for_participant(db, participant, marathon_id) if not available_games: raise HTTPException(status_code=400, detail="No games available for spin") # Check active event active_event = await event_service.get_active_event(db, marathon_id) game = None challenge = None is_playthrough = False # Handle special event cases (excluding Common Enemy - it has separate flow) # Events only apply to challenges-type games, not playthrough if active_event: if active_event.type == EventType.JACKPOT.value: # Jackpot: Get hard challenge only (from challenges-type games) challenge = await event_service.get_random_hard_challenge(db, marathon_id) if challenge: # Check if this game is available for the participant result = await db.execute( select(Game).where(Game.id == challenge.game_id) ) game = result.scalar_one_or_none() if game and game.id in [g.id for g in available_games]: # Consume jackpot (one-time use) await event_service.consume_jackpot(db, active_event.id) else: # Game not available, fall back to normal selection game = None challenge = None # Note: Common Enemy is handled separately via event-assignment endpoints # Normal random selection if no special event handling if not game: game = random.choice(available_games) if game.game_type == GameType.PLAYTHROUGH.value: # Playthrough game - no challenge selection, ignore events is_playthrough = True challenge = None active_event = None # Ignore events for playthrough else: # Challenges game - select random challenge if not game.challenges: # Reload challenges if not loaded result = await db.execute( select(Game) .options(selectinload(Game.challenges)) .where(Game.id == game.id) ) game = result.scalar_one() # Filter out already completed challenges completed_result = await db.execute( select(Assignment.challenge_id) .where( Assignment.participant_id == participant.id, Assignment.challenge_id.in_([c.id for c in game.challenges]), Assignment.status == AssignmentStatus.COMPLETED.value, ) ) completed_ids = set(completed_result.scalars().all()) available_challenges = [c for c in game.challenges if c.id not in completed_ids] if not available_challenges: raise HTTPException(status_code=400, detail="No challenges available for this game") challenge = random.choice(available_challenges) # Create assignment if is_playthrough: # Playthrough assignment - link to game, not challenge assignment = Assignment( participant_id=participant.id, game_id=game.id, is_playthrough=True, status=AssignmentStatus.ACTIVE.value, # No event_type for playthrough ) db.add(assignment) await db.flush() # Get assignment.id for bonus assignments # Create bonus assignments for all challenges bonus_challenges = [] if game.challenges: for ch in game.challenges: bonus = BonusAssignment( main_assignment_id=assignment.id, challenge_id=ch.id, ) db.add(bonus) bonus_challenges.append(ch) # Log activity activity_data = { "game": game.title, "is_playthrough": True, "points": game.playthrough_points, "bonus_challenges_count": len(bonus_challenges), } else: # Regular challenge assignment assignment = Assignment( participant_id=participant.id, challenge_id=challenge.id, status=AssignmentStatus.ACTIVE.value, event_type=active_event.type if active_event else None, ) db.add(assignment) # Log activity activity_data = { "game": game.title, "challenge": challenge.title, "difficulty": challenge.difficulty, "points": challenge.points, } if active_event: activity_data["event_type"] = active_event.type activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.SPIN.value, data=activity_data, ) db.add(activity) await db.commit() await db.refresh(assignment) # Calculate drop penalty if is_playthrough: drop_penalty = points_service.calculate_drop_penalty( participant.drop_count, game.playthrough_points, None # No events for playthrough ) else: drop_penalty = points_service.calculate_drop_penalty( participant.drop_count, challenge.points, active_event ) # Get challenges count challenges_count = 0 if 'challenges' in game.__dict__: challenges_count = len(game.challenges) else: challenges_count = await db.scalar( select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id) ) # Build response game_response = GameResponse( id=game.id, title=game.title, cover_url=storage_service.get_url(game.cover_path, "covers"), download_url=game.download_url, genre=game.genre, added_by=None, challenges_count=challenges_count, created_at=game.created_at, game_type=game.game_type, playthrough_points=game.playthrough_points, playthrough_description=game.playthrough_description, playthrough_proof_type=game.playthrough_proof_type, playthrough_proof_hint=game.playthrough_proof_hint, ) if is_playthrough: # Return playthrough result return SpinResult( assignment_id=assignment.id, game=game_response, challenge=None, is_playthrough=True, playthrough_info=PlaythroughInfo( description=game.playthrough_description, points=game.playthrough_points, proof_type=game.playthrough_proof_type, proof_hint=game.playthrough_proof_hint, ), bonus_challenges=[ ChallengeResponse( id=ch.id, title=ch.title, description=ch.description, type=ch.type, difficulty=ch.difficulty, points=ch.points, estimated_time=ch.estimated_time, proof_type=ch.proof_type, proof_hint=ch.proof_hint, game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_generated=ch.is_generated, created_at=ch.created_at, ) for ch in bonus_challenges ], can_drop=True, drop_penalty=drop_penalty, ) else: # Return challenge result return SpinResult( assignment_id=assignment.id, game=game_response, 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=None, download_url=game.download_url, game_type=game.game_type), is_generated=challenge.is_generated, created_at=challenge.created_at, ), is_playthrough=False, can_drop=True, drop_penalty=drop_penalty, ) @router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None) async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession): """Get current active regular assignment (not event assignments)""" participant = await get_participant_or_403(db, current_user.id, marathon_id) assignment = await get_active_assignment(db, participant.id, is_event=False) # If no active assignment, check for returned assignments if not assignment: returned = await get_oldest_returned_assignment(db, participant.id) if returned: # Activate the returned assignment await activate_returned_assignment(db, returned) await db.commit() # Reload with all relationships assignment = await get_active_assignment(db, participant.id, is_event=False) if not assignment: return None # Handle playthrough assignments if assignment.is_playthrough: game = assignment.game active_event = None # No events for playthrough drop_penalty = points_service.calculate_drop_penalty( participant.drop_count, game.playthrough_points, None ) # Build bonus challenges response from app.schemas.assignment import BonusAssignmentResponse bonus_responses = [] for ba in assignment.bonus_assignments: bonus_responses.append(BonusAssignmentResponse( id=ba.id, challenge=ChallengeResponse( id=ba.challenge.id, title=ba.challenge.title, description=ba.challenge.description, type=ba.challenge.type, difficulty=ba.challenge.difficulty, points=ba.challenge.points, estimated_time=ba.challenge.estimated_time, proof_type=ba.challenge.proof_type, proof_hint=ba.challenge.proof_hint, game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_generated=ba.challenge.is_generated, created_at=ba.challenge.created_at, ), status=ba.status, proof_url=ba.proof_url, proof_comment=ba.proof_comment, points_earned=ba.points_earned, completed_at=ba.completed_at, )) return AssignmentResponse( id=assignment.id, challenge=None, game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_playthrough=True, playthrough_info=PlaythroughInfo( description=game.playthrough_description, points=game.playthrough_points, proof_type=game.playthrough_proof_type, proof_hint=game.playthrough_proof_hint, ), status=assignment.status, proof_url=storage_service.get_url(assignment.proof_path, "proofs") 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, drop_penalty=drop_penalty, bonus_challenges=bonus_responses, ) # Regular challenge assignment challenge = assignment.challenge game = challenge.game # Calculate drop penalty (considers active event for double_risk) active_event = await event_service.get_active_event(db, marathon_id) drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event) 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=None, download_url=game.download_url, game_type=game.game_type), is_generated=challenge.is_generated, created_at=challenge.created_at, ), status=assignment.status, proof_url=storage_service.get_url(assignment.proof_path, "proofs") 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, drop_penalty=drop_penalty, ) @router.post("/assignments/{assignment_id}/complete", response_model=CompleteResult) async def complete_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), # Legacy single file support proof_files: list[UploadFile] = File([]), # Multiple files support ): """Complete a regular assignment with proof (not event assignments)""" # Get assignment with all needed relationships result = await db.execute( select(Assignment) .options( selectinload(Assignment.participant), selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.game), # For playthrough selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For bonus points selectinload(Assignment.dispute), # To check if it was previously disputed ) .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") # Event assignments should be completed via /event-assignments/{id}/complete if assignment.is_event_assignment: raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments") # Combine legacy single file with new multiple files all_files = [] if proof_file: all_files.append(proof_file) if proof_files: all_files.extend(proof_files) # For playthrough: need either file(s) or URL or comment (proof is flexible) # For challenges: need either file(s) or URL if assignment.is_playthrough: if not all_files and not proof_url and not comment: raise HTTPException(status_code=400, detail="Proof is required (file, URL, or comment)") else: if not all_files and not proof_url: raise HTTPException(status_code=400, detail="Proof is required (file or URL)") # Handle multiple file uploads if all_files: from app.models import AssignmentProof for idx, file in enumerate(all_files): contents = await file.read() if len(contents) > settings.MAX_UPLOAD_SIZE: raise HTTPException( status_code=400, detail=f"File {file.filename} too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", ) ext = file.filename.split(".")[-1].lower() if file.filename else "jpg" if ext not in settings.ALLOWED_EXTENSIONS: raise HTTPException( status_code=400, detail=f"Invalid file type for {file.filename}. Allowed: {settings.ALLOWED_EXTENSIONS}", ) # Determine file type (image or video) file_type = "video" if ext in ["mp4", "webm", "mov", "avi"] else "image" # Upload file to storage filename = storage_service.generate_filename(f"{assignment_id}_{idx}", file.filename) file_path = await storage_service.upload_file( content=contents, folder="proofs", filename=filename, content_type=file.content_type or "application/octet-stream", ) # Create AssignmentProof record proof_record = AssignmentProof( assignment_id=assignment_id, file_path=file_path, file_type=file_type, order_index=idx ) db.add(proof_record) # Legacy: set proof_path on first file for backward compatibility if idx == 0: assignment.proof_path = file_path # Set proof URL if provided if proof_url: assignment.proof_url = proof_url assignment.proof_comment = comment participant = assignment.participant # Handle playthrough completion if assignment.is_playthrough: game = assignment.game marathon_id = game.marathon_id base_points = game.playthrough_points # No events for playthrough total_points, streak_bonus, _ = points_service.calculate_completion_points( base_points, participant.current_streak, None ) # Calculate bonus points from completed bonus assignments bonus_points = sum( ba.points_earned for ba in assignment.bonus_assignments if ba.status == BonusAssignmentStatus.COMPLETED.value ) total_points += bonus_points # Update assignment assignment.status = AssignmentStatus.COMPLETED.value assignment.points_earned = total_points assignment.streak_at_completion = participant.current_streak + 1 assignment.completed_at = datetime.utcnow() # Update participant participant.total_points += total_points participant.current_streak += 1 participant.drop_count = 0 # Check if this is a redo of a previously disputed assignment is_redo = ( assignment.dispute is not None and assignment.dispute.status == DisputeStatus.RESOLVED_INVALID.value ) # Log activity activity_data = { "assignment_id": assignment.id, "game": game.title, "is_playthrough": True, "points": total_points, "base_points": base_points, "bonus_points": bonus_points, "streak": participant.current_streak, } if is_redo: activity_data["is_redo"] = True activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.COMPLETE.value, data=activity_data, ) db.add(activity) await db.commit() # Check for returned assignments returned_assignment = await get_oldest_returned_assignment(db, participant.id) if returned_assignment: await activate_returned_assignment(db, returned_assignment) await db.commit() return CompleteResult( points_earned=total_points, streak_bonus=streak_bonus, total_points=participant.total_points, new_streak=participant.current_streak, ) # Regular challenge completion challenge = assignment.challenge marathon_id = challenge.game.marathon_id # Check active event for point multipliers active_event = await event_service.get_active_event(db, marathon_id) # For jackpot: use the event_type stored in assignment (since event may be over) effective_event = active_event # Handle assignment-level event types (jackpot) if assignment.event_type == EventType.JACKPOT.value: class MockEvent: def __init__(self, event_type): self.type = event_type effective_event = MockEvent(assignment.event_type) total_points, streak_bonus, event_bonus = points_service.calculate_completion_points( challenge.points, participant.current_streak, effective_event ) # 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 += common_enemy_bonus print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}") # Update assignment assignment.status = AssignmentStatus.COMPLETED.value assignment.points_earned = total_points assignment.streak_at_completion = participant.current_streak + 1 assignment.completed_at = datetime.utcnow() # Update participant participant.total_points += total_points participant.current_streak += 1 participant.drop_count = 0 # Check if this is a redo of a previously disputed assignment is_redo = ( assignment.dispute is not None and assignment.dispute.status == DisputeStatus.RESOLVED_INVALID.value ) # Log activity activity_data = { "assignment_id": assignment.id, "game": challenge.game.title, "challenge": challenge.title, "difficulty": challenge.difficulty, "points": total_points, "streak": participant.current_streak, } if is_redo: activity_data["is_redo"] = True if assignment.event_type == EventType.JACKPOT.value: activity_data["event_type"] = assignment.event_type activity_data["event_bonus"] = event_bonus elif active_event: activity_data["event_type"] = active_event.type activity_data["event_bonus"] = event_bonus 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: from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES 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 ] print(f"[COMMON_ENEMY] Creating event_end activity with winners: {winners_data}") 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() # Check for returned assignments returned_assignment = await get_oldest_returned_assignment(db, participant.id) if returned_assignment: await activate_returned_assignment(db, returned_assignment) await db.commit() print(f"[WHEEL] Auto-activated returned assignment {returned_assignment.id} for participant {participant.id}") return CompleteResult( points_earned=total_points, streak_bonus=streak_bonus, total_points=participant.total_points, new_streak=participant.current_streak, ) @router.post("/assignments/{assignment_id}/drop", response_model=DropResult) async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession): """Drop current assignment""" # Get assignment with all needed relationships result = await db.execute( select(Assignment) .options( selectinload(Assignment.participant), selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.game), # For playthrough selectinload(Assignment.bonus_assignments), # For resetting bonuses on drop ) .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") participant = assignment.participant # Handle playthrough drop if assignment.is_playthrough: game = assignment.game marathon_id = game.marathon_id # No events for playthrough penalty = points_service.calculate_drop_penalty( participant.drop_count, game.playthrough_points, None ) # Update assignment assignment.status = AssignmentStatus.DROPPED.value assignment.completed_at = datetime.utcnow() # Reset all bonus assignments (lose any completed bonuses) completed_bonuses_count = 0 for ba in assignment.bonus_assignments: if ba.status == BonusAssignmentStatus.COMPLETED.value: completed_bonuses_count += 1 ba.status = BonusAssignmentStatus.PENDING.value ba.proof_path = None ba.proof_url = None ba.proof_comment = None ba.points_earned = 0 ba.completed_at = None # Update participant participant.total_points = max(0, participant.total_points - penalty) participant.current_streak = 0 participant.drop_count += 1 # Log activity activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.DROP.value, data={ "game": game.title, "is_playthrough": True, "penalty": penalty, "lost_bonuses": completed_bonuses_count, }, ) db.add(activity) await db.commit() return DropResult( penalty=penalty, total_points=participant.total_points, new_drop_count=participant.drop_count, ) # Regular challenge drop marathon_id = assignment.challenge.game.marathon_id # Check active event for free drops (double_risk) active_event = await event_service.get_active_event(db, marathon_id) # Calculate penalty (0 if double_risk event is active) penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event) # Update assignment assignment.status = AssignmentStatus.DROPPED.value assignment.completed_at = datetime.utcnow() # Update participant participant.total_points = max(0, participant.total_points - penalty) participant.current_streak = 0 participant.drop_count += 1 # Log activity activity_data = { "game": assignment.challenge.game.title, "challenge": assignment.challenge.title, "difficulty": assignment.challenge.difficulty, "penalty": penalty, } if active_event: activity_data["event_type"] = active_event.type if active_event.type == EventType.DOUBLE_RISK.value: activity_data["free_drop"] = True activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.DROP.value, data=activity_data, ) db.add(activity) await db.commit() return DropResult( penalty=penalty, total_points=participant.total_points, new_drop_count=participant.drop_count, ) @router.get("/marathons/{marathon_id}/my-history", response_model=list[AssignmentResponse]) async def get_my_history( marathon_id: int, current_user: CurrentUser, db: DbSession, limit: int = 20, offset: int = 0, ): """Get history of user's assignments in marathon""" participant = await get_participant_or_403(db, current_user.id, marathon_id) result = await db.execute( select(Assignment) .options( selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.game), # For playthrough selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses ) .where(Assignment.participant_id == participant.id) .order_by(Assignment.started_at.desc()) .limit(limit) .offset(offset) ) assignments = result.scalars().all() responses = [] for a in assignments: if a.is_playthrough: # Playthrough assignment game = a.game from app.schemas.assignment import BonusAssignmentResponse bonus_responses = [ BonusAssignmentResponse( id=ba.id, challenge=ChallengeResponse( id=ba.challenge.id, title=ba.challenge.title, description=ba.challenge.description, type=ba.challenge.type, difficulty=ba.challenge.difficulty, points=ba.challenge.points, estimated_time=ba.challenge.estimated_time, proof_type=ba.challenge.proof_type, proof_hint=ba.challenge.proof_hint, game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_generated=ba.challenge.is_generated, created_at=ba.challenge.created_at, ), status=ba.status, proof_url=ba.proof_url, proof_comment=ba.proof_comment, points_earned=ba.points_earned, completed_at=ba.completed_at, ) for ba in a.bonus_assignments ] responses.append(AssignmentResponse( id=a.id, challenge=None, game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_playthrough=True, playthrough_info=PlaythroughInfo( description=game.playthrough_description, points=game.playthrough_points, proof_type=game.playthrough_proof_type, proof_hint=game.playthrough_proof_hint, ), status=a.status, proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url, proof_comment=a.proof_comment, points_earned=a.points_earned, streak_at_completion=a.streak_at_completion, started_at=a.started_at, completed_at=a.completed_at, bonus_challenges=bonus_responses, )) else: # Regular challenge assignment responses.append(AssignmentResponse( 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, download_url=a.challenge.game.download_url, game_type=a.challenge.game.game_type, ), is_generated=a.challenge.is_generated, created_at=a.challenge.created_at, ), status=a.status, proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url, proof_comment=a.proof_comment, points_earned=a.points_earned, streak_at_completion=a.streak_at_completion, started_at=a.started_at, completed_at=a.completed_at, )) return responses