Добавлена поддержка обмена играми с типом прохождения (playthrough)

- Обновлены схемы SwapCandidate и SwapRequestChallengeInfo для поддержки прохождений
- get_swap_candidates теперь возвращает и челленджи, и прохождения
- accept_swap_request теперь корректно меняет challenge_id, game_id, is_playthrough и bonus_assignments
- Обновлён UI для отображения прохождений в списке кандидатов и запросах обмена

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 15:56:34 +03:00
parent 58c390c768
commit 9f79daf796
4 changed files with 192 additions and 59 deletions

View File

@@ -10,6 +10,7 @@ from app.models import (
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game, Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game,
SwapRequest as SwapRequestModel, SwapRequestStatus, User, SwapRequest as SwapRequestModel, SwapRequestStatus, User,
) )
from app.models.bonus_assignment import BonusAssignment
from fastapi import UploadFile, File, Form from fastapi import UploadFile, File, Form
from app.schemas import ( from app.schemas import (
@@ -275,6 +276,26 @@ async def stop_event(
return MessageResponse(message="Event stopped") return MessageResponse(message="Event stopped")
def build_assignment_info(assignment: Assignment) -> SwapRequestChallengeInfo:
"""Build SwapRequestChallengeInfo from assignment (challenge or playthrough)"""
if assignment.is_playthrough:
return SwapRequestChallengeInfo(
is_playthrough=True,
playthrough_description=assignment.game.playthrough_description,
playthrough_points=assignment.game.playthrough_points,
game_title=assignment.game.title,
)
else:
return SwapRequestChallengeInfo(
is_playthrough=False,
title=assignment.challenge.title,
description=assignment.challenge.description,
points=assignment.challenge.points,
difficulty=assignment.challenge.difficulty,
game_title=assignment.challenge.game.title,
)
def build_swap_request_response( def build_swap_request_response(
swap_req: SwapRequestModel, swap_req: SwapRequestModel,
) -> SwapRequestResponse: ) -> SwapRequestResponse:
@@ -298,20 +319,8 @@ def build_swap_request_response(
role=swap_req.to_participant.user.role, role=swap_req.to_participant.user.role,
created_at=swap_req.to_participant.user.created_at, created_at=swap_req.to_participant.user.created_at,
), ),
from_challenge=SwapRequestChallengeInfo( from_challenge=build_assignment_info(swap_req.from_assignment),
title=swap_req.from_assignment.challenge.title, to_challenge=build_assignment_info(swap_req.to_assignment),
description=swap_req.from_assignment.challenge.description,
points=swap_req.from_assignment.challenge.points,
difficulty=swap_req.from_assignment.challenge.difficulty,
game_title=swap_req.from_assignment.challenge.game.title,
),
to_challenge=SwapRequestChallengeInfo(
title=swap_req.to_assignment.challenge.title,
description=swap_req.to_assignment.challenge.description,
points=swap_req.to_assignment.challenge.points,
difficulty=swap_req.to_assignment.challenge.difficulty,
game_title=swap_req.to_assignment.challenge.game.title,
),
created_at=swap_req.created_at, created_at=swap_req.created_at,
responded_at=swap_req.responded_at, responded_at=swap_req.responded_at,
) )
@@ -349,11 +358,12 @@ async def create_swap_request(
if target.id == participant.id: if target.id == participant.id:
raise HTTPException(status_code=400, detail="Cannot swap with yourself") raise HTTPException(status_code=400, detail="Cannot swap with yourself")
# Get both active assignments # Get both active assignments (with challenge.game or game for playthrough)
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
) )
.where( .where(
Assignment.participant_id == participant.id, Assignment.participant_id == participant.id,
@@ -365,7 +375,8 @@ async def create_swap_request(
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
) )
.where( .where(
Assignment.participant_id == target.id, Assignment.participant_id == target.id,
@@ -417,7 +428,7 @@ async def create_swap_request(
await db.commit() await db.commit()
await db.refresh(swap_request) await db.refresh(swap_request)
# Load relationships for response # Load relationships for response (including game for playthrough)
result = await db.execute( result = await db.execute(
select(SwapRequestModel) select(SwapRequestModel)
.options( .options(
@@ -426,9 +437,13 @@ async def create_swap_request(
selectinload(SwapRequestModel.from_assignment) selectinload(SwapRequestModel.from_assignment)
.selectinload(Assignment.challenge) .selectinload(Assignment.challenge)
.selectinload(Challenge.game), .selectinload(Challenge.game),
selectinload(SwapRequestModel.from_assignment)
.selectinload(Assignment.game),
selectinload(SwapRequestModel.to_assignment) selectinload(SwapRequestModel.to_assignment)
.selectinload(Assignment.challenge) .selectinload(Assignment.challenge)
.selectinload(Challenge.game), .selectinload(Challenge.game),
selectinload(SwapRequestModel.to_assignment)
.selectinload(Assignment.game),
) )
.where(SwapRequestModel.id == swap_request.id) .where(SwapRequestModel.id == swap_request.id)
) )
@@ -461,9 +476,13 @@ async def get_my_swap_requests(
selectinload(SwapRequestModel.from_assignment) selectinload(SwapRequestModel.from_assignment)
.selectinload(Assignment.challenge) .selectinload(Assignment.challenge)
.selectinload(Challenge.game), .selectinload(Challenge.game),
selectinload(SwapRequestModel.from_assignment)
.selectinload(Assignment.game),
selectinload(SwapRequestModel.to_assignment) selectinload(SwapRequestModel.to_assignment)
.selectinload(Assignment.challenge) .selectinload(Assignment.challenge)
.selectinload(Challenge.game), .selectinload(Challenge.game),
selectinload(SwapRequestModel.to_assignment)
.selectinload(Assignment.game),
) )
.where( .where(
SwapRequestModel.event_id == event.id, SwapRequestModel.event_id == event.id,
@@ -553,10 +572,39 @@ async def accept_swap_request(
await db.commit() await db.commit()
raise HTTPException(status_code=400, detail="One or both assignments are no longer active") raise HTTPException(status_code=400, detail="One or both assignments are no longer active")
# Perform the swap # Perform the swap (swap challenge_id, game_id, and is_playthrough)
from_challenge_id = from_assignment.challenge_id from_challenge_id = from_assignment.challenge_id
from_game_id = from_assignment.game_id
from_is_playthrough = from_assignment.is_playthrough
from_assignment.challenge_id = to_assignment.challenge_id from_assignment.challenge_id = to_assignment.challenge_id
from_assignment.game_id = to_assignment.game_id
from_assignment.is_playthrough = to_assignment.is_playthrough
to_assignment.challenge_id = from_challenge_id to_assignment.challenge_id = from_challenge_id
to_assignment.game_id = from_game_id
to_assignment.is_playthrough = from_is_playthrough
# Swap bonus assignments between the two assignments
from sqlalchemy import update as sql_update
# Get bonus assignments for both
from_bonus_result = await db.execute(
select(BonusAssignment).where(BonusAssignment.main_assignment_id == from_assignment.id)
)
from_bonus_assignments = from_bonus_result.scalars().all()
to_bonus_result = await db.execute(
select(BonusAssignment).where(BonusAssignment.main_assignment_id == to_assignment.id)
)
to_bonus_assignments = to_bonus_result.scalars().all()
# Move bonus assignments: from -> to, to -> from
for bonus in from_bonus_assignments:
bonus.main_assignment_id = to_assignment.id
for bonus in to_bonus_assignments:
bonus.main_assignment_id = from_assignment.id
# Update request status # Update request status
swap_request.status = SwapRequestStatus.ACCEPTED.value swap_request.status = SwapRequestStatus.ACCEPTED.value
@@ -865,8 +913,10 @@ async def get_swap_candidates(
if not event or event.type != EventType.SWAP.value: if not event or event.type != EventType.SWAP.value:
raise HTTPException(status_code=400, detail="No active swap event") raise HTTPException(status_code=400, detail="No active swap event")
# Get all participants except current user with active assignments
from app.models import Game from app.models import Game
candidates = []
# Get challenge-based assignments
result = await db.execute( result = await db.execute(
select(Participant, Assignment, Challenge, Game) select(Participant, Assignment, Challenge, Game)
.join(Assignment, Assignment.participant_id == Participant.id) .join(Assignment, Assignment.participant_id == Participant.id)
@@ -877,12 +927,11 @@ async def get_swap_candidates(
Participant.marathon_id == marathon_id, Participant.marathon_id == marathon_id,
Participant.id != participant.id, Participant.id != participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value, Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_playthrough == False,
) )
) )
rows = result.all() for p, assignment, challenge, game in result.all():
candidates.append(SwapCandidate(
return [
SwapCandidate(
participant_id=p.id, participant_id=p.id,
user=UserPublic( user=UserPublic(
id=p.user.id, id=p.user.id,
@@ -892,14 +941,45 @@ async def get_swap_candidates(
role=p.user.role, role=p.user.role,
created_at=p.user.created_at, created_at=p.user.created_at,
), ),
is_playthrough=False,
challenge_title=challenge.title, challenge_title=challenge.title,
challenge_description=challenge.description, challenge_description=challenge.description,
challenge_points=challenge.points, challenge_points=challenge.points,
challenge_difficulty=challenge.difficulty, challenge_difficulty=challenge.difficulty,
game_title=game.title, game_title=game.title,
))
# Get playthrough-based assignments
result = await db.execute(
select(Participant, Assignment, Game)
.join(Assignment, Assignment.participant_id == Participant.id)
.join(Game, Assignment.game_id == Game.id)
.options(selectinload(Participant.user))
.where(
Participant.marathon_id == marathon_id,
Participant.id != participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_playthrough == True,
) )
for p, assignment, challenge, game in rows )
] for p, assignment, game in result.all():
candidates.append(SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
login=p.user.login,
nickname=p.user.nickname,
avatar_url=None,
role=p.user.role,
created_at=p.user.created_at,
),
is_playthrough=True,
playthrough_description=game.playthrough_description,
playthrough_points=game.playthrough_points,
game_title=game.title,
))
return candidates
@router.get("/marathons/{marathon_id}/common-enemy-leaderboard", response_model=list[CommonEnemyLeaderboard]) @router.get("/marathons/{marathon_id}/common-enemy-leaderboard", response_model=list[CommonEnemyLeaderboard])

View File

@@ -128,10 +128,16 @@ class SwapCandidate(BaseModel):
"""Participant available for assignment swap""" """Participant available for assignment swap"""
participant_id: int participant_id: int
user: UserPublic user: UserPublic
challenge_title: str is_playthrough: bool = False
challenge_description: str # Challenge fields (used when is_playthrough=False)
challenge_points: int challenge_title: str | None = None
challenge_difficulty: str challenge_description: str | None = None
challenge_points: int | None = None
challenge_difficulty: str | None = None
# Playthrough fields (used when is_playthrough=True)
playthrough_description: str | None = None
playthrough_points: int | None = None
# Common field
game_title: str game_title: str
@@ -145,11 +151,17 @@ class SwapRequestCreate(BaseModel):
class SwapRequestChallengeInfo(BaseModel): class SwapRequestChallengeInfo(BaseModel):
"""Challenge info for swap request display""" """Challenge or playthrough info for swap request display"""
title: str is_playthrough: bool = False
description: str # Challenge fields (used when is_playthrough=False)
points: int title: str | None = None
difficulty: str description: str | None = None
points: int | None = None
difficulty: str | None = None
# Playthrough fields (used when is_playthrough=True)
playthrough_description: str | None = None
playthrough_points: int | None = None
# Common field
game_title: str game_title: str

View File

@@ -1054,7 +1054,14 @@ export function PlayPage() {
<p className="text-center text-gray-500 py-8">Нет доступных заданий для копирования</p> <p className="text-center text-gray-500 py-8">Нет доступных заданий для копирования</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{copycatCandidates.map((candidate) => ( {copycatCandidates.map((candidate) => {
const displayTitle = candidate.is_playthrough
? `Прохождение: ${candidate.game_title}`
: candidate.challenge_title || ''
const displayDetails = candidate.is_playthrough
? `${candidate.playthrough_points || 0} очков`
: `${candidate.game_title}${candidate.challenge_points} очков • ${candidate.challenge_difficulty}`
return (
<button <button
key={candidate.participant_id} key={candidate.participant_id}
onClick={() => handleUseCopycat(candidate.participant_id)} onClick={() => handleUseCopycat(candidate.participant_id)}
@@ -1062,12 +1069,13 @@ export function PlayPage() {
className="w-full p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-cyan-500/30 disabled:opacity-50" className="w-full p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-cyan-500/30 disabled:opacity-50"
> >
<p className="text-white font-medium">{candidate.user.nickname}</p> <p className="text-white font-medium">{candidate.user.nickname}</p>
<p className="text-cyan-400 text-sm">{candidate.challenge_title}</p> <p className="text-cyan-400 text-sm">{displayTitle}</p>
<p className="text-gray-500 text-xs"> <p className="text-gray-500 text-xs">
{candidate.game_title} {candidate.challenge_points} очков {candidate.challenge_difficulty} {displayDetails}
</p> </p>
</button> </button>
))} )
})}
</div> </div>
)} )}
</GlassCard> </GlassCard>
@@ -1832,7 +1840,14 @@ export function PlayPage() {
Входящие запросы ({swapRequests.incoming.length}) Входящие запросы ({swapRequests.incoming.length})
</h4> </h4>
<div className="space-y-3"> <div className="space-y-3">
{swapRequests.incoming.map((request) => ( {swapRequests.incoming.map((request) => {
const challengeTitle = request.from_challenge.is_playthrough
? `Прохождение: ${request.from_challenge.game_title}`
: request.from_challenge.title || ''
const challengeDetails = request.from_challenge.is_playthrough
? `${request.from_challenge.playthrough_points || 0} очков`
: `${request.from_challenge.game_title}${request.from_challenge.points} очков`
return (
<div <div
key={request.id} key={request.id}
className="p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-xl" className="p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-xl"
@@ -1843,10 +1858,10 @@ export function PlayPage() {
{request.from_user.nickname} предлагает обмен {request.from_user.nickname} предлагает обмен
</p> </p>
<p className="text-yellow-400 text-sm mt-1"> <p className="text-yellow-400 text-sm mt-1">
Вы получите: <span className="font-medium">{request.from_challenge.title}</span> Вы получите: <span className="font-medium">{challengeTitle}</span>
</p> </p>
<p className="text-gray-400 text-xs"> <p className="text-gray-400 text-xs">
{request.from_challenge.game_title} {request.from_challenge.points} очков {challengeDetails}
</p> </p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -1873,7 +1888,8 @@ export function PlayPage() {
</div> </div>
</div> </div>
</div> </div>
))} )
})}
</div> </div>
</div> </div>
)} )}
@@ -1886,7 +1902,11 @@ export function PlayPage() {
Отправленные запросы ({swapRequests.outgoing.length}) Отправленные запросы ({swapRequests.outgoing.length})
</h4> </h4>
<div className="space-y-3"> <div className="space-y-3">
{swapRequests.outgoing.map((request) => ( {swapRequests.outgoing.map((request) => {
const challengeTitle = request.to_challenge.is_playthrough
? `Прохождение: ${request.to_challenge.game_title}`
: request.to_challenge.title || ''
return (
<div <div
key={request.id} key={request.id}
className="p-4 bg-accent-500/10 border border-accent-500/30 rounded-xl" className="p-4 bg-accent-500/10 border border-accent-500/30 rounded-xl"
@@ -1897,7 +1917,7 @@ export function PlayPage() {
Запрос к {request.to_user.nickname} Запрос к {request.to_user.nickname}
</p> </p>
<p className="text-accent-400 text-sm mt-1"> <p className="text-accent-400 text-sm mt-1">
Вы получите: <span className="font-medium">{request.to_challenge.title}</span> Вы получите: <span className="font-medium">{challengeTitle}</span>
</p> </p>
<p className="text-gray-500 text-xs mt-1"> <p className="text-gray-500 text-xs mt-1">
Ожидание подтверждения... Ожидание подтверждения...
@@ -1915,7 +1935,8 @@ export function PlayPage() {
</NeonButton> </NeonButton>
</div> </div>
</div> </div>
))} )
})}
</div> </div>
</div> </div>
)} )}
@@ -1943,7 +1964,14 @@ export function PlayPage() {
!swapRequests.outgoing.some(r => r.to_user.id === c.user.id) && !swapRequests.outgoing.some(r => r.to_user.id === c.user.id) &&
!swapRequests.incoming.some(r => r.from_user.id === c.user.id) !swapRequests.incoming.some(r => r.from_user.id === c.user.id)
) )
.map((candidate) => ( .map((candidate) => {
const displayTitle = candidate.is_playthrough
? `Прохождение: ${candidate.game_title}`
: candidate.challenge_title || ''
const displayDetails = candidate.is_playthrough
? `${candidate.playthrough_points || 0} очков`
: `${candidate.game_title}${candidate.challenge_points} очков • ${candidate.challenge_difficulty}`
return (
<div <div
key={candidate.participant_id} key={candidate.participant_id}
className="p-4 bg-dark-700/50 rounded-xl border border-dark-600" className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
@@ -1954,10 +1982,10 @@ export function PlayPage() {
{candidate.user.nickname} {candidate.user.nickname}
</p> </p>
<p className="text-neon-400 text-sm font-medium truncate"> <p className="text-neon-400 text-sm font-medium truncate">
{candidate.challenge_title} {displayTitle}
</p> </p>
<p className="text-gray-400 text-xs mt-1"> <p className="text-gray-400 text-xs mt-1">
{candidate.game_title} {candidate.challenge_points} очков {candidate.challenge_difficulty} {displayDetails}
</p> </p>
</div> </div>
<NeonButton <NeonButton
@@ -1966,7 +1994,7 @@ export function PlayPage() {
onClick={() => handleSendSwapRequest( onClick={() => handleSendSwapRequest(
candidate.participant_id, candidate.participant_id,
candidate.user.nickname, candidate.user.nickname,
candidate.challenge_title displayTitle
)} )}
isLoading={sendingRequestTo === candidate.participant_id} isLoading={sendingRequestTo === candidate.participant_id}
disabled={sendingRequestTo !== null} disabled={sendingRequestTo !== null}
@@ -1976,7 +2004,8 @@ export function PlayPage() {
</NeonButton> </NeonButton>
</div> </div>
</div> </div>
))} )
})}
</div> </div>
)} )}
</div> </div>

View File

@@ -321,10 +321,16 @@ export interface DroppedAssignment {
export interface SwapCandidate { export interface SwapCandidate {
participant_id: number participant_id: number
user: User user: User
challenge_title: string is_playthrough: boolean
challenge_description: string // Challenge fields (used when is_playthrough=false)
challenge_points: number challenge_title: string | null
challenge_difficulty: Difficulty challenge_description: string | null
challenge_points: number | null
challenge_difficulty: Difficulty | null
// Playthrough fields (used when is_playthrough=true)
playthrough_description: string | null
playthrough_points: number | null
// Common field
game_title: string game_title: string
} }
@@ -332,10 +338,16 @@ export interface SwapCandidate {
export type SwapRequestStatus = 'pending' | 'accepted' | 'declined' | 'cancelled' export type SwapRequestStatus = 'pending' | 'accepted' | 'declined' | 'cancelled'
export interface SwapRequestChallengeInfo { export interface SwapRequestChallengeInfo {
title: string is_playthrough: boolean
description: string // Challenge fields (used when is_playthrough=false)
points: number title: string | null
difficulty: string description: string | null
points: number | null
difficulty: string | null
// Playthrough fields (used when is_playthrough=true)
playthrough_description: string | null
playthrough_points: number | null
// Common field
game_title: string game_title: string
} }