405 lines
13 KiB
Python
405 lines
13 KiB
Python
|
|
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,
|
||
|
|
Assignment, AssignmentStatus, Activity, ActivityType
|
||
|
|
)
|
||
|
|
from app.schemas import (
|
||
|
|
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
||
|
|
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
|
||
|
|
)
|
||
|
|
from app.services.points import PointsService
|
||
|
|
|
||
|
|
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) -> Assignment | None:
|
||
|
|
result = await db.execute(
|
||
|
|
select(Assignment)
|
||
|
|
.options(
|
||
|
|
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||
|
|
)
|
||
|
|
.where(
|
||
|
|
Assignment.participant_id == participant_id,
|
||
|
|
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
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)
|
||
|
|
|
||
|
|
# Check no active assignment
|
||
|
|
active = await get_active_assignment(db, participant.id)
|
||
|
|
if active:
|
||
|
|
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
||
|
|
|
||
|
|
# Get all games with challenges
|
||
|
|
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]
|
||
|
|
|
||
|
|
if not games:
|
||
|
|
raise HTTPException(status_code=400, detail="No games with challenges available")
|
||
|
|
|
||
|
|
# Random selection
|
||
|
|
game = random.choice(games)
|
||
|
|
challenge = random.choice(game.challenges)
|
||
|
|
|
||
|
|
# Create assignment
|
||
|
|
assignment = Assignment(
|
||
|
|
participant_id=participant.id,
|
||
|
|
challenge_id=challenge.id,
|
||
|
|
status=AssignmentStatus.ACTIVE.value,
|
||
|
|
)
|
||
|
|
db.add(assignment)
|
||
|
|
|
||
|
|
# Log activity
|
||
|
|
activity = Activity(
|
||
|
|
marathon_id=marathon_id,
|
||
|
|
user_id=current_user.id,
|
||
|
|
type=ActivityType.SPIN.value,
|
||
|
|
data={
|
||
|
|
"game": game.title,
|
||
|
|
"challenge": challenge.title,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
db.add(activity)
|
||
|
|
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(assignment)
|
||
|
|
|
||
|
|
# Calculate drop penalty
|
||
|
|
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count)
|
||
|
|
|
||
|
|
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,
|
||
|
|
challenges_count=len(game.challenges),
|
||
|
|
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):
|
||
|
|
"""Get current active assignment"""
|
||
|
|
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
||
|
|
assignment = await get_active_assignment(db, participant.id)
|
||
|
|
|
||
|
|
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),
|
||
|
|
):
|
||
|
|
"""Complete an assignment with proof"""
|
||
|
|
# 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")
|
||
|
|
|
||
|
|
# 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
|
||
|
|
|
||
|
|
total_points, streak_bonus = points_service.calculate_completion_points(
|
||
|
|
challenge.points, participant.current_streak
|
||
|
|
)
|
||
|
|
|
||
|
|
# 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
|
||
|
|
|
||
|
|
# Get marathon_id for activity
|
||
|
|
result = await db.execute(
|
||
|
|
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
|
||
|
|
)
|
||
|
|
full_challenge = result.scalar_one()
|
||
|
|
|
||
|
|
# Log activity
|
||
|
|
activity = Activity(
|
||
|
|
marathon_id=full_challenge.game.marathon_id,
|
||
|
|
user_id=current_user.id,
|
||
|
|
type=ActivityType.COMPLETE.value,
|
||
|
|
data={
|
||
|
|
"challenge": challenge.title,
|
||
|
|
"points": total_points,
|
||
|
|
"streak": participant.current_streak,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
db.add(activity)
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
# Calculate penalty
|
||
|
|
penalty = points_service.calculate_drop_penalty(participant.drop_count)
|
||
|
|
|
||
|
|
# 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 = Activity(
|
||
|
|
marathon_id=assignment.challenge.game.marathon_id,
|
||
|
|
user_id=current_user.id,
|
||
|
|
type=ActivityType.DROP.value,
|
||
|
|
data={
|
||
|
|
"challenge": assignment.challenge.title,
|
||
|
|
"penalty": penalty,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
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
|
||
|
|
]
|