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
|
|
|
|
|
import uuid
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
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-15 22:31:42 +07:00
|
|
|
EventType, Difficulty, User
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
from app.schemas import (
|
|
|
|
|
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
|
|
|
|
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
|
|
|
|
|
)
|
|
|
|
|
from app.services.points import PointsService
|
2025-12-15 03:22:29 +07:00
|
|
|
from app.services.events import event_service
|
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(
|
|
|
|
|
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
|
|
|
|
)
|
|
|
|
|
.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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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"""
|
|
|
|
|
# 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")
|
|
|
|
|
|
|
|
|
|
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-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-15 23:03:59 +07:00
|
|
|
# Handle special event cases (excluding Common Enemy - it has separate flow)
|
2025-12-15 03:22:29 +07:00
|
|
|
if active_event:
|
|
|
|
|
if active_event.type == EventType.JACKPOT.value:
|
|
|
|
|
# Jackpot: Get hard challenge only
|
|
|
|
|
challenge = await event_service.get_random_hard_challenge(db, marathon_id)
|
|
|
|
|
if challenge:
|
|
|
|
|
# Load game for challenge
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Game).where(Game.id == challenge.game_id)
|
|
|
|
|
)
|
|
|
|
|
game = result.scalar_one_or_none()
|
|
|
|
|
# Consume jackpot (one-time use)
|
|
|
|
|
await event_service.consume_jackpot(db, active_event.id)
|
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
|
|
|
|
|
if not game or not challenge:
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Game)
|
|
|
|
|
.options(selectinload(Game.challenges))
|
|
|
|
|
.where(Game.marathon_id == marathon_id)
|
|
|
|
|
)
|
|
|
|
|
games = [g for g in result.scalars().all() if g.challenges]
|
2025-12-14 02:38:35 +07:00
|
|
|
|
2025-12-15 03:22:29 +07:00
|
|
|
if not games:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No games with challenges available")
|
2025-12-14 02:38:35 +07:00
|
|
|
|
2025-12-15 03:22:29 +07:00
|
|
|
game = random.choice(games)
|
|
|
|
|
challenge = random.choice(game.challenges)
|
2025-12-14 02:38:35 +07:00
|
|
|
|
2025-12-15 03:22:29 +07:00
|
|
|
# Create assignment (store event_type for jackpot multiplier on completion)
|
2025-12-14 02:38:35 +07:00
|
|
|
assignment = Assignment(
|
|
|
|
|
participant_id=participant.id,
|
|
|
|
|
challenge_id=challenge.id,
|
|
|
|
|
status=AssignmentStatus.ACTIVE.value,
|
2025-12-15 03:22:29 +07:00
|
|
|
event_type=active_event.type if active_event else None,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
db.add(assignment)
|
|
|
|
|
|
|
|
|
|
# Log activity
|
2025-12-15 03:22:29 +07:00
|
|
|
activity_data = {
|
|
|
|
|
"game": game.title,
|
|
|
|
|
"challenge": challenge.title,
|
2025-12-15 22:31:42 +07:00
|
|
|
"difficulty": challenge.difficulty,
|
|
|
|
|
"points": challenge.points,
|
2025-12-15 03:22:29 +07:00
|
|
|
}
|
|
|
|
|
if active_event:
|
|
|
|
|
activity_data["event_type"] = active_event.type
|
|
|
|
|
|
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-15 03:22:29 +07:00
|
|
|
# Calculate drop penalty (considers active event for double_risk)
|
|
|
|
|
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event)
|
|
|
|
|
|
|
|
|
|
# Get challenges count (avoid lazy loading in async context)
|
|
|
|
|
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
|
|
|
|
|
|
|
|
return SpinResult(
|
|
|
|
|
assignment_id=assignment.id,
|
|
|
|
|
game=GameResponse(
|
|
|
|
|
id=game.id,
|
|
|
|
|
title=game.title,
|
|
|
|
|
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
|
|
|
|
|
download_url=game.download_url,
|
|
|
|
|
genre=game.genre,
|
|
|
|
|
added_by=None,
|
2025-12-15 03:22:29 +07:00
|
|
|
challenges_count=challenges_count,
|
2025-12-14 02:38:35 +07:00
|
|
|
created_at=game.created_at,
|
|
|
|
|
),
|
|
|
|
|
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),
|
|
|
|
|
is_generated=challenge.is_generated,
|
|
|
|
|
created_at=challenge.created_at,
|
|
|
|
|
),
|
|
|
|
|
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):
|
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
|
|
|
|
|
|
|
|
if not assignment:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
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=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.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),
|
|
|
|
|
):
|
2025-12-15 23:03:59 +07:00
|
|
|
"""Complete a regular assignment with proof (not event assignments)"""
|
2025-12-14 02:38:35 +07:00
|
|
|
# Get assignment
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Assignment)
|
|
|
|
|
.options(
|
|
|
|
|
selectinload(Assignment.participant),
|
|
|
|
|
selectinload(Assignment.challenge),
|
|
|
|
|
)
|
|
|
|
|
.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")
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
# Calculate points
|
|
|
|
|
participant = assignment.participant
|
|
|
|
|
challenge = assignment.challenge
|
|
|
|
|
|
2025-12-15 03:22:29 +07:00
|
|
|
# Get marathon_id for activity and event check
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
|
|
|
|
|
)
|
|
|
|
|
full_challenge = result.scalar_one()
|
|
|
|
|
marathon_id = full_challenge.game.marathon_id
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
# For other events: use the currently active event
|
|
|
|
|
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
|
|
|
# Create a mock event object for point calculation
|
|
|
|
|
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
|
|
|
|
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
|
|
|
|
|
participant.drop_count = 0 # Reset drop counter on success
|
|
|
|
|
|
|
|
|
|
# Log activity
|
2025-12-15 03:22:29 +07:00
|
|
|
activity_data = {
|
2025-12-15 22:31:42 +07:00
|
|
|
"game": full_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-15 23:50:37 +07:00
|
|
|
# Log event info (use assignment's event_type for jackpot, active_event for others)
|
|
|
|
|
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
|
|
|
# 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
|
|
|
|
|
]
|
|
|
|
|
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,
|
|
|
|
|
user_id=current_user.id, # Last completer triggers the close
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
participant = assignment.participant
|
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)
|
|
|
|
|
penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event)
|
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,
|
|
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
)
|
|
|
|
|
.where(Assignment.participant_id == participant.id)
|
|
|
|
|
.order_by(Assignment.started_at.desc())
|
|
|
|
|
.limit(limit)
|
|
|
|
|
.offset(offset)
|
|
|
|
|
)
|
|
|
|
|
assignments = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
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
|
|
|
|
|
),
|
|
|
|
|
is_generated=a.challenge.is_generated,
|
|
|
|
|
created_at=a.challenge.created_at,
|
|
|
|
|
),
|
|
|
|
|
status=a.status,
|
|
|
|
|
proof_url=f"/uploads/proofs/{a.proof_path.split('/')[-1]}" 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,
|
|
|
|
|
)
|
|
|
|
|
for a in assignments
|
|
|
|
|
]
|