Files
game-marathon/backend/app/api/v1/challenges.py

333 lines
11 KiB
Python

from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import Marathon, MarathonStatus, Game, Challenge, Participant
from app.schemas import (
ChallengeCreate,
ChallengeUpdate,
ChallengeResponse,
MessageResponse,
GameShort,
ChallengePreview,
ChallengesPreviewResponse,
ChallengesSaveRequest,
)
from app.services.gpt import GPTService
router = APIRouter(tags=["challenges"])
gpt_service = GPTService()
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
result = await db.execute(
select(Challenge)
.options(selectinload(Challenge.game))
.where(Challenge.id == challenge_id)
)
challenge = result.scalar_one_or_none()
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
return challenge
async def check_participant(db, user_id: int, marathon_id: int) -> Participant:
result = await db.execute(
select(Participant).where(
Participant.user_id == user_id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
return participant
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
# Get game and check access
result = await db.execute(
select(Game).where(Game.id == game_id)
)
game = result.scalar_one_or_none()
if not game:
raise HTTPException(status_code=404, detail="Game not found")
await check_participant(db, current_user.id, game.marathon_id)
result = await db.execute(
select(Challenge)
.where(Challenge.game_id == game_id)
.order_by(Challenge.difficulty, Challenge.created_at)
)
challenges = result.scalars().all()
return [
ChallengeResponse(
id=c.id,
title=c.title,
description=c.description,
type=c.type,
difficulty=c.difficulty,
points=c.points,
estimated_time=c.estimated_time,
proof_type=c.proof_type,
proof_hint=c.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=c.is_generated,
created_at=c.created_at,
)
for c in challenges
]
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
async def create_challenge(
game_id: int,
data: ChallengeCreate,
current_user: CurrentUser,
db: DbSession,
):
# Get game and check access
result = await db.execute(
select(Game).where(Game.id == game_id)
)
game = result.scalar_one_or_none()
if not game:
raise HTTPException(status_code=404, detail="Game not found")
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon")
await check_participant(db, current_user.id, game.marathon_id)
challenge = Challenge(
game_id=game_id,
title=data.title,
description=data.description,
type=data.type.value,
difficulty=data.difficulty.value,
points=data.points,
estimated_time=data.estimated_time,
proof_type=data.proof_type.value,
proof_hint=data.proof_hint,
is_generated=False,
)
db.add(challenge)
await db.commit()
await db.refresh(challenge)
return 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,
)
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Generate challenges preview for all games in marathon using GPT (without saving)"""
# Check marathon
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot generate challenges for active or finished marathon")
await check_participant(db, current_user.id, marathon_id)
# Get all games
result = await db.execute(
select(Game).where(Game.marathon_id == marathon_id)
)
games = result.scalars().all()
if not games:
raise HTTPException(status_code=400, detail="No games in marathon")
preview_challenges = []
for game in games:
# Check if game already has challenges
existing = await db.scalar(
select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
)
if existing:
continue # Skip if already has challenges
try:
challenges_data = await gpt_service.generate_challenges(game.title, game.genre)
for ch_data in challenges_data:
preview_challenges.append(ChallengePreview(
game_id=game.id,
game_title=game.title,
title=ch_data.title,
description=ch_data.description,
type=ch_data.type,
difficulty=ch_data.difficulty,
points=ch_data.points,
estimated_time=ch_data.estimated_time,
proof_type=ch_data.proof_type,
proof_hint=ch_data.proof_hint,
))
except Exception as e:
# Log error but continue with other games
print(f"Error generating challenges for {game.title}: {e}")
return ChallengesPreviewResponse(challenges=preview_challenges)
@router.post("/marathons/{marathon_id}/save-challenges", response_model=MessageResponse)
async def save_challenges(
marathon_id: int,
data: ChallengesSaveRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Save previewed challenges to database"""
# Check marathon
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon")
await check_participant(db, current_user.id, marathon_id)
# Verify all games belong to this marathon
result = await db.execute(
select(Game.id).where(Game.marathon_id == marathon_id)
)
valid_game_ids = set(row[0] for row in result.fetchall())
saved_count = 0
for ch_data in data.challenges:
if ch_data.game_id not in valid_game_ids:
continue # Skip challenges for invalid games
# Validate type
ch_type = ch_data.type
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
ch_type = "completion"
# Validate difficulty
difficulty = ch_data.difficulty
if difficulty not in ["easy", "medium", "hard"]:
difficulty = "medium"
# Validate proof_type
proof_type = ch_data.proof_type
if proof_type not in ["screenshot", "video", "steam"]:
proof_type = "screenshot"
challenge = Challenge(
game_id=ch_data.game_id,
title=ch_data.title[:100],
description=ch_data.description,
type=ch_type,
difficulty=difficulty,
points=max(1, min(500, ch_data.points)),
estimated_time=ch_data.estimated_time,
proof_type=proof_type,
proof_hint=ch_data.proof_hint,
is_generated=True,
)
db.add(challenge)
saved_count += 1
await db.commit()
return MessageResponse(message=f"Сохранено {saved_count} заданий")
@router.patch("/challenges/{challenge_id}", response_model=ChallengeResponse)
async def update_challenge(
challenge_id: int,
data: ChallengeUpdate,
current_user: CurrentUser,
db: DbSession,
):
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update challenges in active or finished marathon")
await check_participant(db, current_user.id, challenge.game.marathon_id)
if data.title is not None:
challenge.title = data.title
if data.description is not None:
challenge.description = data.description
if data.type is not None:
challenge.type = data.type.value
if data.difficulty is not None:
challenge.difficulty = data.difficulty.value
if data.points is not None:
challenge.points = data.points
if data.estimated_time is not None:
challenge.estimated_time = data.estimated_time
if data.proof_type is not None:
challenge.proof_type = data.proof_type.value
if data.proof_hint is not None:
challenge.proof_hint = data.proof_hint
await db.commit()
await db.refresh(challenge)
game = challenge.game
return 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,
)
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
await check_participant(db, current_user.id, challenge.game.marathon_id)
await db.delete(challenge)
await db.commit()
return MessageResponse(message="Challenge deleted")