Улучшение системы оспариваний и исправления

- Оспаривания теперь требуют решения админа после 24ч голосования
  - Можно повторно оспаривать после разрешённых споров
  - Исправлены бонусные очки при перепрохождении после оспаривания
  - Сброс серии при невалидном пруфе
  - Колесо показывает только доступные игры
  - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
2025-12-29 22:23:34 +03:00
parent 1cedfeb3ee
commit 89dbe2c018
42 changed files with 5426 additions and 313 deletions

View File

@@ -9,15 +9,18 @@ from app.core.config import settings
from app.models import (
Marathon, MarathonStatus, Game, Challenge, Participant,
Assignment, AssignmentStatus, Activity, ActivityType,
EventType, Difficulty, User
EventType, Difficulty, User, BonusAssignment, BonusAssignmentStatus, GameType,
DisputeStatus,
)
from app.schemas import (
SpinResult, AssignmentResponse, CompleteResult, DropResult,
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse,
)
from app.schemas.game import PlaythroughInfo
from app.services.points import PointsService
from app.services.events import event_service
from app.services.storage import storage_service
from app.api.v1.games import get_available_games_for_participant
router = APIRouter(tags=["wheel"])
@@ -48,7 +51,9 @@ async def get_active_assignment(db, participant_id: int, is_event: bool = False)
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
)
.where(
Assignment.participant_id == participant_id,
@@ -64,7 +69,9 @@ async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
)
.where(
Assignment.participant_id == participant_id,
@@ -94,7 +101,7 @@ async def activate_returned_assignment(db, returned_assignment: Assignment) -> N
@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"""
"""Spin the wheel to get a random game and challenge (or playthrough)"""
# Check marathon is active
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
@@ -115,60 +122,127 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
if active:
raise HTTPException(status_code=400, detail="You already have an active assignment")
# Get available games (filtered by completion status)
available_games, _ = await get_available_games_for_participant(db, participant, marathon_id)
if not available_games:
raise HTTPException(status_code=400, detail="No games available for spin")
# Check active event
active_event = await event_service.get_active_event(db, marathon_id)
game = None
challenge = None
is_playthrough = False
# Handle special event cases (excluding Common Enemy - it has separate flow)
# Events only apply to challenges-type games, not playthrough
if active_event:
if active_event.type == EventType.JACKPOT.value:
# Jackpot: Get hard challenge only
# Jackpot: Get hard challenge only (from challenges-type games)
challenge = await event_service.get_random_hard_challenge(db, marathon_id)
if challenge:
# Load game for challenge
# Check if this game is available for the participant
result = await db.execute(
select(Game).where(Game.id == challenge.game_id)
)
game = result.scalar_one_or_none()
# Consume jackpot (one-time use)
await event_service.consume_jackpot(db, active_event.id)
if game and game.id in [g.id for g in available_games]:
# Consume jackpot (one-time use)
await event_service.consume_jackpot(db, active_event.id)
else:
# Game not available, fall back to normal selection
game = None
challenge = None
# Note: Common Enemy is handled separately via event-assignment endpoints
# Normal random selection if no special event handling
if not game or not challenge:
result = await db.execute(
select(Game)
.options(selectinload(Game.challenges))
.where(Game.marathon_id == marathon_id)
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
)
games = [g for g in result.scalars().all() if g.challenges]
db.add(assignment)
await db.flush() # Get assignment.id for bonus assignments
if not games:
raise HTTPException(status_code=400, detail="No games with challenges available")
# 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)
game = random.choice(games)
challenge = random.choice(game.challenges)
# 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)
# Create assignment (store event_type for jackpot multiplier on completion)
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
# Log activity
activity_data = {
"game": game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": challenge.points,
}
if active_event:
activity_data["event_type"] = active_event.type
activity = Activity(
marathon_id=marathon_id,
@@ -181,10 +255,17 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
await db.commit()
await db.refresh(assignment)
# Calculate drop penalty (considers active event for double_risk)
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
# Calculate drop penalty
if is_playthrough:
drop_penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, None # No events for playthrough
)
else:
drop_penalty = points_service.calculate_drop_penalty(
participant.drop_count, challenge.points, active_event
)
# Get challenges count (avoid lazy loading in async context)
# Get challenges count
challenges_count = 0
if 'challenges' in game.__dict__:
challenges_count = len(game.challenges)
@@ -193,36 +274,80 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
)
return SpinResult(
assignment_id=assignment.id,
game=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,
),
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,
# Build response
game_response = GameResponse(
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
genre=game.genre,
added_by=None,
challenges_count=challenges_count,
created_at=game.created_at,
game_type=game.game_type,
playthrough_points=game.playthrough_points,
playthrough_description=game.playthrough_description,
playthrough_proof_type=game.playthrough_proof_type,
playthrough_proof_hint=game.playthrough_proof_hint,
)
if is_playthrough:
# Return playthrough result
return SpinResult(
assignment_id=assignment.id,
game=game_response,
challenge=None,
is_playthrough=True,
playthrough_info=PlaythroughInfo(
description=game.playthrough_description,
points=game.playthrough_points,
proof_type=game.playthrough_proof_type,
proof_hint=game.playthrough_proof_hint,
),
bonus_challenges=[
ChallengeResponse(
id=ch.id,
title=ch.title,
description=ch.description,
type=ch.type,
difficulty=ch.difficulty,
points=ch.points,
estimated_time=ch.estimated_time,
proof_type=ch.proof_type,
proof_hint=ch.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
is_generated=ch.is_generated,
created_at=ch.created_at,
)
for ch in bonus_challenges
],
can_drop=True,
drop_penalty=drop_penalty,
)
else:
# Return challenge result
return SpinResult(
assignment_id=assignment.id,
game=game_response,
challenge=ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
),
is_playthrough=False,
can_drop=True,
drop_penalty=drop_penalty,
)
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
@@ -230,9 +355,77 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
participant = await get_participant_or_403(db, current_user.id, marathon_id)
assignment = await get_active_assignment(db, participant.id, is_event=False)
# If no active assignment, check for returned assignments
if not assignment:
returned = await get_oldest_returned_assignment(db, participant.id)
if returned:
# Activate the returned assignment
await activate_returned_assignment(db, returned)
await db.commit()
# Reload with all relationships
assignment = await get_active_assignment(db, participant.id, is_event=False)
if not assignment:
return None
# Handle playthrough assignments
if assignment.is_playthrough:
game = assignment.game
active_event = None # No events for playthrough
drop_penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, None
)
# Build bonus challenges response
from app.schemas.assignment import BonusAssignmentResponse
bonus_responses = []
for ba in assignment.bonus_assignments:
bonus_responses.append(BonusAssignmentResponse(
id=ba.id,
challenge=ChallengeResponse(
id=ba.challenge.id,
title=ba.challenge.title,
description=ba.challenge.description,
type=ba.challenge.type,
difficulty=ba.challenge.difficulty,
points=ba.challenge.points,
estimated_time=ba.challenge.estimated_time,
proof_type=ba.challenge.proof_type,
proof_hint=ba.challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
is_generated=ba.challenge.is_generated,
created_at=ba.challenge.created_at,
),
status=ba.status,
proof_url=ba.proof_url,
proof_comment=ba.proof_comment,
points_earned=ba.points_earned,
completed_at=ba.completed_at,
))
return AssignmentResponse(
id=assignment.id,
challenge=None,
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
is_playthrough=True,
playthrough_info=PlaythroughInfo(
description=game.playthrough_description,
points=game.playthrough_points,
proof_type=game.playthrough_proof_type,
proof_hint=game.playthrough_proof_hint,
),
status=assignment.status,
proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
drop_penalty=drop_penalty,
bonus_challenges=bonus_responses,
)
# Regular challenge assignment
challenge = assignment.challenge
game = challenge.game
@@ -252,7 +445,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
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),
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
),
@@ -277,12 +470,15 @@ async def complete_assignment(
proof_file: UploadFile | None = File(None),
):
"""Complete a regular assignment with proof (not event assignments)"""
# Get assignment
# Get assignment with all needed relationships
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge),
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For bonus points
selectinload(Assignment.dispute), # To check if it was previously disputed
)
.where(Assignment.id == assignment_id)
)
@@ -301,9 +497,14 @@ async def complete_assignment(
if assignment.is_event_assignment:
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
# 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)")
# For playthrough: need either file or URL or comment (proof is flexible)
# For challenges: need either file or URL
if assignment.is_playthrough:
if not proof_file and not proof_url and not comment:
raise HTTPException(status_code=400, detail="Proof is required (file, URL, or comment)")
else:
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:
@@ -336,27 +537,91 @@ async def complete_assignment(
assignment.proof_comment = comment
# Calculate points
participant = assignment.participant
challenge = assignment.challenge
# 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
# Handle playthrough completion
if assignment.is_playthrough:
game = assignment.game
marathon_id = game.marathon_id
base_points = game.playthrough_points
# No events for playthrough
total_points, streak_bonus, _ = points_service.calculate_completion_points(
base_points, participant.current_streak, None
)
# Calculate bonus points from completed bonus assignments
bonus_points = sum(
ba.points_earned for ba in assignment.bonus_assignments
if ba.status == BonusAssignmentStatus.COMPLETED.value
)
total_points += bonus_points
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
assignment.points_earned = total_points
assignment.streak_at_completion = participant.current_streak + 1
assignment.completed_at = datetime.utcnow()
# Update participant
participant.total_points += total_points
participant.current_streak += 1
participant.drop_count = 0
# Check if this is a redo of a previously disputed assignment
is_redo = (
assignment.dispute is not None and
assignment.dispute.status == DisputeStatus.RESOLVED_INVALID.value
)
# Log activity
activity_data = {
"assignment_id": assignment.id,
"game": game.title,
"is_playthrough": True,
"points": total_points,
"base_points": base_points,
"bonus_points": bonus_points,
"streak": participant.current_streak,
}
if is_redo:
activity_data["is_redo"] = True
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.COMPLETE.value,
data=activity_data,
)
db.add(activity)
await db.commit()
# Check for returned assignments
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
if returned_assignment:
await activate_returned_assignment(db, returned_assignment)
await db.commit()
return CompleteResult(
points_earned=total_points,
streak_bonus=streak_bonus,
total_points=participant.total_points,
new_streak=participant.current_streak,
)
# Regular challenge completion
challenge = assignment.challenge
marathon_id = challenge.game.marathon_id
# Check active event for point multipliers
active_event = await event_service.get_active_event(db, marathon_id)
# For jackpot: use the event_type stored in assignment (since event may be over)
# For other events: use the currently active event
effective_event = active_event
# Handle assignment-level event types (jackpot)
if assignment.event_type == EventType.JACKPOT.value:
# Create a mock event object for point calculation
class MockEvent:
def __init__(self, event_type):
self.type = event_type
@@ -386,18 +651,25 @@ async def complete_assignment(
# Update participant
participant.total_points += total_points
participant.current_streak += 1
participant.drop_count = 0 # Reset drop counter on success
participant.drop_count = 0
# Check if this is a redo of a previously disputed assignment
is_redo = (
assignment.dispute is not None and
assignment.dispute.status == DisputeStatus.RESOLVED_INVALID.value
)
# Log activity
activity_data = {
"assignment_id": assignment.id,
"game": full_challenge.game.title,
"game": challenge.game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": total_points,
"streak": participant.current_streak,
}
# Log event info (use assignment's event_type for jackpot, active_event for others)
if is_redo:
activity_data["is_redo"] = True
if assignment.event_type == EventType.JACKPOT.value:
activity_data["event_type"] = assignment.event_type
activity_data["event_bonus"] = event_bonus
@@ -418,7 +690,6 @@ async def complete_assignment(
# 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
# 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))
@@ -438,7 +709,7 @@ async def complete_assignment(
event_end_activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id, # Last completer triggers the close
user_id=current_user.id,
type=ActivityType.EVENT_END.value,
data={
"event_type": EventType.COMMON_ENEMY.value,
@@ -451,7 +722,7 @@ async def complete_assignment(
await db.commit()
# Check for returned assignments and activate the oldest one
# Check for returned assignments
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
if returned_assignment:
await activate_returned_assignment(db, returned_assignment)
@@ -469,12 +740,14 @@ async def complete_assignment(
@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
# Get assignment with all needed relationships
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.bonus_assignments), # For resetting bonuses on drop
)
.where(Assignment.id == assignment_id)
)
@@ -490,6 +763,61 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
raise HTTPException(status_code=400, detail="Assignment is not active")
participant = assignment.participant
# Handle playthrough drop
if assignment.is_playthrough:
game = assignment.game
marathon_id = game.marathon_id
# No events for playthrough
penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, None
)
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Reset all bonus assignments (lose any completed bonuses)
completed_bonuses_count = 0
for ba in assignment.bonus_assignments:
if ba.status == BonusAssignmentStatus.COMPLETED.value:
completed_bonuses_count += 1
ba.status = BonusAssignmentStatus.PENDING.value
ba.proof_path = None
ba.proof_url = None
ba.proof_comment = None
ba.points_earned = 0
ba.completed_at = None
# Update participant
participant.total_points = max(0, participant.total_points - penalty)
participant.current_streak = 0
participant.drop_count += 1
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.DROP.value,
data={
"game": game.title,
"is_playthrough": True,
"penalty": penalty,
"lost_bonuses": completed_bonuses_count,
},
)
db.add(activity)
await db.commit()
return DropResult(
penalty=penalty,
total_points=participant.total_points,
new_drop_count=participant.drop_count,
)
# Regular challenge drop
marathon_id = assignment.challenge.game.marathon_id
# Check active event for free drops (double_risk)
@@ -550,7 +878,9 @@ async def get_my_history(
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
)
.where(Assignment.participant_id == participant.id)
.order_by(Assignment.started_at.desc())
@@ -559,34 +889,88 @@ async def get_my_history(
)
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
responses = []
for a in assignments:
if a.is_playthrough:
# Playthrough assignment
game = a.game
from app.schemas.assignment import BonusAssignmentResponse
bonus_responses = [
BonusAssignmentResponse(
id=ba.id,
challenge=ChallengeResponse(
id=ba.challenge.id,
title=ba.challenge.title,
description=ba.challenge.description,
type=ba.challenge.type,
difficulty=ba.challenge.difficulty,
points=ba.challenge.points,
estimated_time=ba.challenge.estimated_time,
proof_type=ba.challenge.proof_type,
proof_hint=ba.challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
is_generated=ba.challenge.is_generated,
created_at=ba.challenge.created_at,
),
status=ba.status,
proof_url=ba.proof_url,
proof_comment=ba.proof_comment,
points_earned=ba.points_earned,
completed_at=ba.completed_at,
)
for ba in a.bonus_assignments
]
responses.append(AssignmentResponse(
id=a.id,
challenge=None,
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
is_playthrough=True,
playthrough_info=PlaythroughInfo(
description=game.playthrough_description,
points=game.playthrough_points,
proof_type=game.playthrough_proof_type,
proof_hint=game.playthrough_proof_hint,
),
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,
)
for a in assignments
]
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,
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