2025-12-14 02:38:35 +07:00
|
|
|
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,
|
2025-12-15 03:22:29 +07:00
|
|
|
Assignment, AssignmentStatus, Activity, ActivityType,
|
2025-12-29 22:23:34 +03:00
|
|
|
EventType, Difficulty, User, BonusAssignment, BonusAssignmentStatus, GameType,
|
|
|
|
|
DisputeStatus,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
from app.schemas import (
|
|
|
|
|
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
2025-12-29 22:23:34 +03:00
|
|
|
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
2025-12-29 22:23:34 +03:00
|
|
|
from app.schemas.game import PlaythroughInfo
|
2025-12-14 02:38:35 +07:00
|
|
|
from app.services.points import PointsService
|
2025-12-15 03:22:29 +07:00
|
|
|
from app.services.events import event_service
|
2025-12-16 01:25:21 +07:00
|
|
|
from app.services.storage import storage_service
|
2026-01-05 07:15:50 +07:00
|
|
|
from app.services.coins import coins_service
|
|
|
|
|
from app.services.consumables import consumables_service
|
2025-12-29 22:23:34 +03:00
|
|
|
from app.api.v1.games import get_available_games_for_participant
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2025-12-15 23:03:59 +07:00
|
|
|
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.
|
|
|
|
|
"""
|
2025-12-14 02:38:35 +07:00
|
|
|
result = await db.execute(
|
|
|
|
|
select(Assignment)
|
|
|
|
|
.options(
|
2025-12-29 22:23:34 +03:00
|
|
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
|
|
|
|
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
|
|
|
|
|
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
.where(
|
|
|
|
|
Assignment.participant_id == participant_id,
|
|
|
|
|
Assignment.status == AssignmentStatus.ACTIVE.value,
|
2025-12-15 23:03:59 +07:00
|
|
|
Assignment.is_event_assignment == is_event,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
|
2025-12-16 00:33:50 +07:00
|
|
|
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(
|
2025-12-29 22:23:34 +03:00
|
|
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
|
|
|
|
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
|
|
|
|
|
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
|
2025-12-16 00:33:50 +07:00
|
|
|
)
|
|
|
|
|
.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
|
|
|
|
|
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
|
|
|
|
|
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
2025-12-29 22:23:34 +03:00
|
|
|
"""Spin the wheel to get a random game and challenge (or playthrough)"""
|
2025-12-14 02:38:35 +07:00
|
|
|
# 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")
|
|
|
|
|
|
2025-12-16 02:22:12 +07:00
|
|
|
# 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")
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
|
|
|
|
|
2025-12-15 23:03:59 +07:00
|
|
|
# Check no active regular assignment (event assignments are separate)
|
|
|
|
|
active = await get_active_assignment(db, participant.id, is_event=False)
|
2025-12-14 02:38:35 +07:00
|
|
|
if active:
|
|
|
|
|
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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")
|
|
|
|
|
|
2025-12-15 03:22:29 +07:00
|
|
|
# Check active event
|
|
|
|
|
active_event = await event_service.get_active_event(db, marathon_id)
|
|
|
|
|
|
|
|
|
|
game = None
|
|
|
|
|
challenge = None
|
2025-12-29 22:23:34 +03:00
|
|
|
is_playthrough = False
|
2025-12-15 03:22:29 +07:00
|
|
|
|
2025-12-15 23:03:59 +07:00
|
|
|
# Handle special event cases (excluding Common Enemy - it has separate flow)
|
2025-12-29 22:23:34 +03:00
|
|
|
# Events only apply to challenges-type games, not playthrough
|
2025-12-15 03:22:29 +07:00
|
|
|
if active_event:
|
|
|
|
|
if active_event.type == EventType.JACKPOT.value:
|
2025-12-29 22:23:34 +03:00
|
|
|
# Jackpot: Get hard challenge only (from challenges-type games)
|
2025-12-15 03:22:29 +07:00
|
|
|
challenge = await event_service.get_random_hard_challenge(db, marathon_id)
|
|
|
|
|
if challenge:
|
2025-12-29 22:23:34 +03:00
|
|
|
# Check if this game is available for the participant
|
2025-12-15 03:22:29 +07:00
|
|
|
result = await db.execute(
|
|
|
|
|
select(Game).where(Game.id == challenge.game_id)
|
|
|
|
|
)
|
|
|
|
|
game = result.scalar_one_or_none()
|
2025-12-29 22:23:34 +03:00
|
|
|
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
|
2025-12-15 23:03:59 +07:00
|
|
|
# Note: Common Enemy is handled separately via event-assignment endpoints
|
2025-12-15 03:22:29 +07:00
|
|
|
|
|
|
|
|
# Normal random selection if no special event handling
|
2025-12-29 22:23:34 +03:00
|
|
|
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
|
2025-12-15 03:22:29 +07:00
|
|
|
)
|
2025-12-29 22:23:34 +03:00
|
|
|
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
|
2025-12-15 03:22:29 +07:00
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
activity = Activity(
|
|
|
|
|
marathon_id=marathon_id,
|
|
|
|
|
user_id=current_user.id,
|
|
|
|
|
type=ActivityType.SPIN.value,
|
2025-12-15 03:22:29 +07:00
|
|
|
data=activity_data,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
db.add(activity)
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
await db.refresh(assignment)
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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
|
|
|
|
|
)
|
2025-12-15 03:22:29 +07:00
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# Get challenges count
|
2025-12-15 03:22:29 +07:00
|
|
|
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)
|
|
|
|
|
)
|
2025-12-14 02:38:35 +07:00
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
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,
|
2026-01-03 00:12:07 +07:00
|
|
|
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
2025-12-29 22:23:34 +03:00
|
|
|
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,
|
2026-01-03 00:12:07 +07:00
|
|
|
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
2025-12-29 22:23:34 +03:00
|
|
|
is_generated=challenge.is_generated,
|
|
|
|
|
created_at=challenge.created_at,
|
|
|
|
|
),
|
|
|
|
|
is_playthrough=False,
|
|
|
|
|
can_drop=True,
|
|
|
|
|
drop_penalty=drop_penalty,
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
|
|
|
|
|
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
2025-12-15 23:03:59 +07:00
|
|
|
"""Get current active regular assignment (not event assignments)"""
|
2025-12-14 02:38:35 +07:00
|
|
|
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
2025-12-15 23:03:59 +07:00
|
|
|
assignment = await get_active_assignment(db, participant.id, is_event=False)
|
2025-12-14 02:38:35 +07:00
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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)
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
if not assignment:
|
|
|
|
|
return None
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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,
|
2026-01-03 00:12:07 +07:00
|
|
|
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
2025-12-29 22:23:34 +03:00
|
|
|
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,
|
2026-01-03 00:12:07 +07:00
|
|
|
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
2025-12-29 22:23:34 +03:00
|
|
|
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
|
2025-12-14 02:38:35 +07:00
|
|
|
challenge = assignment.challenge
|
|
|
|
|
game = challenge.game
|
|
|
|
|
|
2025-12-16 02:22:12 +07:00
|
|
|
# Calculate drop penalty (considers active event for double_risk)
|
|
|
|
|
active_event = await event_service.get_active_event(db, marathon_id)
|
2025-12-16 03:06:26 +07:00
|
|
|
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
|
2025-12-16 02:22:12 +07:00
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
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,
|
2026-01-03 00:12:07 +07:00
|
|
|
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
2025-12-14 02:38:35 +07:00
|
|
|
is_generated=challenge.is_generated,
|
|
|
|
|
created_at=challenge.created_at,
|
|
|
|
|
),
|
|
|
|
|
status=assignment.status,
|
2025-12-16 01:25:21 +07:00
|
|
|
proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
|
2025-12-14 02:38:35 +07:00
|
|
|
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,
|
2025-12-16 02:22:12 +07:00
|
|
|
drop_penalty=drop_penalty,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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),
|
2026-01-03 00:12:07 +07:00
|
|
|
proof_file: UploadFile | None = File(None), # Legacy single file support
|
|
|
|
|
proof_files: list[UploadFile] = File([]), # Multiple files support
|
2025-12-14 02:38:35 +07:00
|
|
|
):
|
2025-12-15 23:03:59 +07:00
|
|
|
"""Complete a regular assignment with proof (not event assignments)"""
|
2025-12-29 22:23:34 +03:00
|
|
|
# Get assignment with all needed relationships
|
2025-12-14 02:38:35 +07:00
|
|
|
result = await db.execute(
|
|
|
|
|
select(Assignment)
|
|
|
|
|
.options(
|
|
|
|
|
selectinload(Assignment.participant),
|
2025-12-29 22:23:34 +03:00
|
|
|
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
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
.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")
|
|
|
|
|
|
2025-12-15 23:03:59 +07:00
|
|
|
# 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")
|
|
|
|
|
|
2026-01-03 00:12:07 +07:00
|
|
|
# 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
|
2025-12-29 22:23:34 +03:00
|
|
|
if assignment.is_playthrough:
|
2026-01-03 00:12:07 +07:00
|
|
|
if not all_files and not proof_url and not comment:
|
2025-12-29 22:23:34 +03:00
|
|
|
raise HTTPException(status_code=400, detail="Proof is required (file, URL, or comment)")
|
|
|
|
|
else:
|
2026-01-03 00:12:07 +07:00
|
|
|
if not all_files and not proof_url:
|
2025-12-29 22:23:34 +03:00
|
|
|
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
|
2025-12-14 02:38:35 +07:00
|
|
|
|
2026-01-03 00:12:07 +07:00
|
|
|
# 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",
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
|
2026-01-03 00:12:07 +07:00
|
|
|
# Create AssignmentProof record
|
|
|
|
|
proof_record = AssignmentProof(
|
|
|
|
|
assignment_id=assignment_id,
|
|
|
|
|
file_path=file_path,
|
|
|
|
|
file_type=file_type,
|
|
|
|
|
order_index=idx
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
2026-01-03 00:12:07 +07:00
|
|
|
db.add(proof_record)
|
2025-12-14 02:38:35 +07:00
|
|
|
|
2026-01-03 00:12:07 +07:00
|
|
|
# Legacy: set proof_path on first file for backward compatibility
|
|
|
|
|
if idx == 0:
|
|
|
|
|
assignment.proof_path = file_path
|
2025-12-14 02:38:35 +07:00
|
|
|
|
2026-01-03 00:12:07 +07:00
|
|
|
# Set proof URL if provided
|
|
|
|
|
if proof_url:
|
2025-12-14 02:38:35 +07:00
|
|
|
assignment.proof_url = proof_url
|
|
|
|
|
|
|
|
|
|
assignment.proof_comment = comment
|
|
|
|
|
|
|
|
|
|
participant = assignment.participant
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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
|
|
|
|
|
|
2026-01-05 07:15:50 +07:00
|
|
|
# Apply boost multiplier from consumable
|
|
|
|
|
boost_multiplier = consumables_service.get_active_boost_multiplier(participant)
|
|
|
|
|
if boost_multiplier > 1.0:
|
|
|
|
|
total_points = int(total_points * boost_multiplier)
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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
|
|
|
|
|
|
2026-01-05 07:15:50 +07:00
|
|
|
# Get marathon and award coins if certified
|
|
|
|
|
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
|
|
|
|
marathon = marathon_result.scalar_one()
|
|
|
|
|
coins_earned = 0
|
|
|
|
|
if marathon.is_certified:
|
|
|
|
|
coins_earned = await coins_service.award_playthrough_coins(
|
|
|
|
|
db, current_user, participant, marathon, total_points, assignment.id
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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
|
2026-01-05 07:15:50 +07:00
|
|
|
if boost_multiplier > 1.0:
|
|
|
|
|
activity_data["boost_multiplier"] = boost_multiplier
|
|
|
|
|
if coins_earned > 0:
|
|
|
|
|
activity_data["coins_earned"] = coins_earned
|
2025-12-29 22:23:34 +03:00
|
|
|
|
|
|
|
|
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,
|
2026-01-05 07:15:50 +07:00
|
|
|
coins_earned=coins_earned,
|
2025-12-29 22:23:34 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Regular challenge completion
|
|
|
|
|
challenge = assignment.challenge
|
|
|
|
|
marathon_id = challenge.game.marathon_id
|
2025-12-15 03:22:29 +07:00
|
|
|
|
|
|
|
|
# Check active event for point multipliers
|
|
|
|
|
active_event = await event_service.get_active_event(db, marathon_id)
|
|
|
|
|
|
2025-12-15 23:50:37 +07:00
|
|
|
# For jackpot: use the event_type stored in assignment (since event may be over)
|
2025-12-15 03:22:29 +07:00
|
|
|
effective_event = active_event
|
|
|
|
|
|
2025-12-15 23:50:37 +07:00
|
|
|
# Handle assignment-level event types (jackpot)
|
|
|
|
|
if assignment.event_type == EventType.JACKPOT.value:
|
2025-12-15 03:22:29 +07:00
|
|
|
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
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
|
2025-12-15 03:22:29 +07:00
|
|
|
# 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
|
2025-12-15 22:31:42 +07:00
|
|
|
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
|
2025-12-15 03:22:29 +07:00
|
|
|
|
2026-01-05 07:15:50 +07:00
|
|
|
# Apply boost multiplier from consumable
|
|
|
|
|
boost_multiplier = consumables_service.get_active_boost_multiplier(participant)
|
|
|
|
|
if boost_multiplier > 1.0:
|
|
|
|
|
total_points = int(total_points * boost_multiplier)
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
# 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
|
2025-12-29 22:23:34 +03:00
|
|
|
participant.drop_count = 0
|
|
|
|
|
|
2026-01-05 07:15:50 +07:00
|
|
|
# Get marathon and award coins if certified
|
|
|
|
|
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
|
|
|
|
marathon = marathon_result.scalar_one()
|
|
|
|
|
coins_earned = 0
|
|
|
|
|
if marathon.is_certified:
|
|
|
|
|
coins_earned = await coins_service.award_challenge_coins(
|
|
|
|
|
db, current_user, participant, marathon, challenge.difficulty, assignment.id
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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
|
|
|
|
|
)
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
# Log activity
|
2025-12-15 03:22:29 +07:00
|
|
|
activity_data = {
|
2025-12-16 00:33:50 +07:00
|
|
|
"assignment_id": assignment.id,
|
2025-12-29 22:23:34 +03:00
|
|
|
"game": challenge.game.title,
|
2025-12-15 03:22:29 +07:00
|
|
|
"challenge": challenge.title,
|
2025-12-15 22:31:42 +07:00
|
|
|
"difficulty": challenge.difficulty,
|
2025-12-15 03:22:29 +07:00
|
|
|
"points": total_points,
|
|
|
|
|
"streak": participant.current_streak,
|
|
|
|
|
}
|
2025-12-29 22:23:34 +03:00
|
|
|
if is_redo:
|
|
|
|
|
activity_data["is_redo"] = True
|
2026-01-05 07:15:50 +07:00
|
|
|
if boost_multiplier > 1.0:
|
|
|
|
|
activity_data["boost_multiplier"] = boost_multiplier
|
|
|
|
|
if coins_earned > 0:
|
|
|
|
|
activity_data["coins_earned"] = coins_earned
|
2025-12-15 23:50:37 +07:00
|
|
|
if assignment.event_type == EventType.JACKPOT.value:
|
2025-12-15 03:22:29 +07:00
|
|
|
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
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
activity = Activity(
|
2025-12-15 03:22:29 +07:00
|
|
|
marathon_id=marathon_id,
|
2025-12-14 02:38:35 +07:00
|
|
|
user_id=current_user.id,
|
|
|
|
|
type=ActivityType.COMPLETE.value,
|
2025-12-15 03:22:29 +07:00
|
|
|
data=activity_data,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
db.add(activity)
|
|
|
|
|
|
2025-12-15 03:22:29 +07:00
|
|
|
# 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
|
2025-12-15 22:31:42 +07:00
|
|
|
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}")
|
|
|
|
|
|
2025-12-15 03:22:29 +07:00
|
|
|
event_end_activity = Activity(
|
|
|
|
|
marathon_id=marathon_id,
|
2025-12-29 22:23:34 +03:00
|
|
|
user_id=current_user.id,
|
2025-12-15 03:22:29 +07:00
|
|
|
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,
|
2025-12-15 22:31:42 +07:00
|
|
|
"winners": winners_data,
|
2025-12-15 03:22:29 +07:00
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
db.add(event_end_activity)
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
await db.commit()
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# Check for returned assignments
|
2025-12-16 00:33:50 +07:00
|
|
|
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}")
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
return CompleteResult(
|
|
|
|
|
points_earned=total_points,
|
|
|
|
|
streak_bonus=streak_bonus,
|
|
|
|
|
total_points=participant.total_points,
|
|
|
|
|
new_streak=participant.current_streak,
|
2026-01-05 07:15:50 +07:00
|
|
|
coins_earned=coins_earned,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
|
|
|
|
|
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
|
|
|
|
|
"""Drop current assignment"""
|
2025-12-29 22:23:34 +03:00
|
|
|
# Get assignment with all needed relationships
|
2025-12-14 02:38:35 +07:00
|
|
|
result = await db.execute(
|
|
|
|
|
select(Assignment)
|
|
|
|
|
.options(
|
|
|
|
|
selectinload(Assignment.participant),
|
|
|
|
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
2025-12-29 22:23:34 +03:00
|
|
|
selectinload(Assignment.game), # For playthrough
|
|
|
|
|
selectinload(Assignment.bonus_assignments), # For resetting bonuses on drop
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
.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
|
2025-12-29 22:23:34 +03:00
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-05 07:15:50 +07:00
|
|
|
# Check for shield - if active, no penalty
|
|
|
|
|
shield_used = False
|
|
|
|
|
if consumables_service.consume_shield(participant):
|
|
|
|
|
penalty = 0
|
|
|
|
|
shield_used = True
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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
|
2026-01-05 07:15:50 +07:00
|
|
|
activity_data = {
|
|
|
|
|
"game": game.title,
|
|
|
|
|
"is_playthrough": True,
|
|
|
|
|
"penalty": penalty,
|
|
|
|
|
"lost_bonuses": completed_bonuses_count,
|
|
|
|
|
}
|
|
|
|
|
if shield_used:
|
|
|
|
|
activity_data["shield_used"] = True
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
activity = Activity(
|
|
|
|
|
marathon_id=marathon_id,
|
|
|
|
|
user_id=current_user.id,
|
|
|
|
|
type=ActivityType.DROP.value,
|
2026-01-05 07:15:50 +07:00
|
|
|
data=activity_data,
|
2025-12-29 22:23:34 +03:00
|
|
|
)
|
|
|
|
|
db.add(activity)
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
return DropResult(
|
|
|
|
|
penalty=penalty,
|
|
|
|
|
total_points=participant.total_points,
|
|
|
|
|
new_drop_count=participant.drop_count,
|
2026-01-05 07:15:50 +07:00
|
|
|
shield_used=shield_used,
|
2025-12-29 22:23:34 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Regular challenge drop
|
2025-12-15 03:22:29 +07:00
|
|
|
marathon_id = assignment.challenge.game.marathon_id
|
2025-12-14 02:38:35 +07:00
|
|
|
|
2025-12-15 03:22:29 +07:00
|
|
|
# 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)
|
2025-12-16 03:06:26 +07:00
|
|
|
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
|
2025-12-14 02:38:35 +07:00
|
|
|
|
2026-01-05 07:15:50 +07:00
|
|
|
# Check for shield - if active, no penalty
|
|
|
|
|
shield_used = False
|
|
|
|
|
if consumables_service.consume_shield(participant):
|
|
|
|
|
penalty = 0
|
|
|
|
|
shield_used = True
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
# 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
|
2025-12-15 03:22:29 +07:00
|
|
|
activity_data = {
|
2025-12-15 22:31:42 +07:00
|
|
|
"game": assignment.challenge.game.title,
|
2025-12-15 03:22:29 +07:00
|
|
|
"challenge": assignment.challenge.title,
|
2025-12-15 22:31:42 +07:00
|
|
|
"difficulty": assignment.challenge.difficulty,
|
2025-12-15 03:22:29 +07:00
|
|
|
"penalty": penalty,
|
|
|
|
|
}
|
2026-01-05 07:15:50 +07:00
|
|
|
if shield_used:
|
|
|
|
|
activity_data["shield_used"] = True
|
2025-12-15 03:22:29 +07:00
|
|
|
if active_event:
|
|
|
|
|
activity_data["event_type"] = active_event.type
|
|
|
|
|
if active_event.type == EventType.DOUBLE_RISK.value:
|
|
|
|
|
activity_data["free_drop"] = True
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
activity = Activity(
|
2025-12-15 03:22:29 +07:00
|
|
|
marathon_id=marathon_id,
|
2025-12-14 02:38:35 +07:00
|
|
|
user_id=current_user.id,
|
|
|
|
|
type=ActivityType.DROP.value,
|
2025-12-15 03:22:29 +07:00
|
|
|
data=activity_data,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
db.add(activity)
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
return DropResult(
|
|
|
|
|
penalty=penalty,
|
|
|
|
|
total_points=participant.total_points,
|
|
|
|
|
new_drop_count=participant.drop_count,
|
2026-01-05 07:15:50 +07:00
|
|
|
shield_used=shield_used,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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(
|
2025-12-29 22:23:34 +03:00
|
|
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
|
|
|
|
selectinload(Assignment.game), # For playthrough
|
|
|
|
|
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
.where(Assignment.participant_id == participant.id)
|
|
|
|
|
.order_by(Assignment.started_at.desc())
|
|
|
|
|
.limit(limit)
|
|
|
|
|
.offset(offset)
|
|
|
|
|
)
|
|
|
|
|
assignments = result.scalars().all()
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
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,
|
2026-01-03 00:12:07 +07:00
|
|
|
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
2025-12-29 22:23:34 +03:00
|
|
|
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,
|
2026-01-03 00:12:07 +07:00
|
|
|
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
2025-12-29 22:23:34 +03:00
|
|
|
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,
|
2025-12-14 02:38:35 +07:00
|
|
|
),
|
2025-12-29 22:23:34 +03:00
|
|
|
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,
|
2026-01-03 00:12:07 +07:00
|
|
|
download_url=a.challenge.game.download_url,
|
2025-12-29 22:23:34 +03:00
|
|
|
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
|