From 9f79daf79643bd074e130e9defccc593e02ee2c2 Mon Sep 17 00:00:00 2001 From: "mamonov.ep" Date: Wed, 21 Jan 2026 15:56:34 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BE=D0=B1=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=B8=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=B8=20=D1=81=20=D1=82=D0=B8=D0=BF=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=BF=D1=80=D0=BE=D1=85=D0=BE=D0=B6=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20(playthrough)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Обновлены схемы SwapCandidate и SwapRequestChallengeInfo для поддержки прохождений - get_swap_candidates теперь возвращает и челленджи, и прохождения - accept_swap_request теперь корректно меняет challenge_id, game_id, is_playthrough и bonus_assignments - Обновлён UI для отображения прохождений в списке кандидатов и запросах обмена Co-Authored-By: Claude Opus 4.5 --- backend/app/api/v1/events.py | 132 +++++++++++++++++++++++++------- backend/app/schemas/event.py | 30 +++++--- frontend/src/pages/PlayPage.tsx | 61 +++++++++++---- frontend/src/types/index.ts | 28 +++++-- 4 files changed, 192 insertions(+), 59 deletions(-) diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 390d600..c962ed9 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -10,6 +10,7 @@ from app.models import ( Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game, SwapRequest as SwapRequestModel, SwapRequestStatus, User, ) +from app.models.bonus_assignment import BonusAssignment from fastapi import UploadFile, File, Form from app.schemas import ( @@ -275,6 +276,26 @@ async def stop_event( 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( swap_req: SwapRequestModel, ) -> SwapRequestResponse: @@ -298,20 +319,8 @@ def build_swap_request_response( role=swap_req.to_participant.user.role, created_at=swap_req.to_participant.user.created_at, ), - from_challenge=SwapRequestChallengeInfo( - title=swap_req.from_assignment.challenge.title, - 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, - ), + from_challenge=build_assignment_info(swap_req.from_assignment), + to_challenge=build_assignment_info(swap_req.to_assignment), created_at=swap_req.created_at, responded_at=swap_req.responded_at, ) @@ -349,11 +358,12 @@ async def create_swap_request( if target.id == participant.id: 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( select(Assignment) .options( - selectinload(Assignment.challenge).selectinload(Challenge.game) + selectinload(Assignment.challenge).selectinload(Challenge.game), + selectinload(Assignment.game), # For playthrough ) .where( Assignment.participant_id == participant.id, @@ -365,7 +375,8 @@ async def create_swap_request( result = await db.execute( select(Assignment) .options( - selectinload(Assignment.challenge).selectinload(Challenge.game) + selectinload(Assignment.challenge).selectinload(Challenge.game), + selectinload(Assignment.game), # For playthrough ) .where( Assignment.participant_id == target.id, @@ -417,7 +428,7 @@ async def create_swap_request( await db.commit() await db.refresh(swap_request) - # Load relationships for response + # Load relationships for response (including game for playthrough) result = await db.execute( select(SwapRequestModel) .options( @@ -426,9 +437,13 @@ async def create_swap_request( selectinload(SwapRequestModel.from_assignment) .selectinload(Assignment.challenge) .selectinload(Challenge.game), + selectinload(SwapRequestModel.from_assignment) + .selectinload(Assignment.game), selectinload(SwapRequestModel.to_assignment) .selectinload(Assignment.challenge) .selectinload(Challenge.game), + selectinload(SwapRequestModel.to_assignment) + .selectinload(Assignment.game), ) .where(SwapRequestModel.id == swap_request.id) ) @@ -461,9 +476,13 @@ async def get_my_swap_requests( selectinload(SwapRequestModel.from_assignment) .selectinload(Assignment.challenge) .selectinload(Challenge.game), + selectinload(SwapRequestModel.from_assignment) + .selectinload(Assignment.game), selectinload(SwapRequestModel.to_assignment) .selectinload(Assignment.challenge) .selectinload(Challenge.game), + selectinload(SwapRequestModel.to_assignment) + .selectinload(Assignment.game), ) .where( SwapRequestModel.event_id == event.id, @@ -553,10 +572,39 @@ async def accept_swap_request( await db.commit() 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_game_id = from_assignment.game_id + from_is_playthrough = from_assignment.is_playthrough + 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.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 swap_request.status = SwapRequestStatus.ACCEPTED.value @@ -865,8 +913,10 @@ async def get_swap_candidates( if not event or event.type != EventType.SWAP.value: raise HTTPException(status_code=400, detail="No active swap event") - # Get all participants except current user with active assignments from app.models import Game + candidates = [] + + # Get challenge-based assignments result = await db.execute( select(Participant, Assignment, Challenge, Game) .join(Assignment, Assignment.participant_id == Participant.id) @@ -877,12 +927,11 @@ async def get_swap_candidates( Participant.marathon_id == marathon_id, Participant.id != participant.id, Assignment.status == AssignmentStatus.ACTIVE.value, + Assignment.is_playthrough == False, ) ) - rows = result.all() - - return [ - SwapCandidate( + for p, assignment, challenge, game in result.all(): + candidates.append(SwapCandidate( participant_id=p.id, user=UserPublic( id=p.user.id, @@ -892,14 +941,45 @@ async def get_swap_candidates( role=p.user.role, created_at=p.user.created_at, ), + is_playthrough=False, challenge_title=challenge.title, challenge_description=challenge.description, challenge_points=challenge.points, challenge_difficulty=challenge.difficulty, 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]) diff --git a/backend/app/schemas/event.py b/backend/app/schemas/event.py index b0a1e87..d0c9e51 100644 --- a/backend/app/schemas/event.py +++ b/backend/app/schemas/event.py @@ -128,10 +128,16 @@ class SwapCandidate(BaseModel): """Participant available for assignment swap""" participant_id: int user: UserPublic - challenge_title: str - challenge_description: str - challenge_points: int - challenge_difficulty: str + is_playthrough: bool = False + # Challenge fields (used when is_playthrough=False) + challenge_title: str | None = None + 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 @@ -145,11 +151,17 @@ class SwapRequestCreate(BaseModel): class SwapRequestChallengeInfo(BaseModel): - """Challenge info for swap request display""" - title: str - description: str - points: int - difficulty: str + """Challenge or playthrough info for swap request display""" + is_playthrough: bool = False + # Challenge fields (used when is_playthrough=False) + title: str | None = None + 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 diff --git a/frontend/src/pages/PlayPage.tsx b/frontend/src/pages/PlayPage.tsx index 8b9addc..87012c7 100644 --- a/frontend/src/pages/PlayPage.tsx +++ b/frontend/src/pages/PlayPage.tsx @@ -1054,7 +1054,14 @@ export function PlayPage() {

Нет доступных заданий для копирования

) : (
- {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 ( - ))} + ) + })}
)} @@ -1832,7 +1840,14 @@ export function PlayPage() { Входящие запросы ({swapRequests.incoming.length})
- {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 (

- Вы получите: {request.from_challenge.title} + Вы получите: {challengeTitle}

- {request.from_challenge.game_title} • {request.from_challenge.points} очков + {challengeDetails}

@@ -1873,7 +1888,8 @@ export function PlayPage() {
- ))} + ) + })} )} @@ -1886,7 +1902,11 @@ export function PlayPage() { Отправленные запросы ({swapRequests.outgoing.length})
- {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 (

- Вы получите: {request.to_challenge.title} + Вы получите: {challengeTitle}

Ожидание подтверждения... @@ -1915,7 +1935,8 @@ export function PlayPage() {

- ))} + ) + })} )} @@ -1943,7 +1964,14 @@ export function PlayPage() { !swapRequests.outgoing.some(r => r.to_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 (

- {candidate.challenge_title} + {displayTitle}

- {candidate.game_title} • {candidate.challenge_points} очков • {candidate.challenge_difficulty} + {displayDetails}

handleSendSwapRequest( candidate.participant_id, candidate.user.nickname, - candidate.challenge_title + displayTitle )} isLoading={sendingRequestTo === candidate.participant_id} disabled={sendingRequestTo !== null} @@ -1976,7 +2004,8 @@ export function PlayPage() { - ))} + ) + })} )} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 54bc84c..882cca4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -321,10 +321,16 @@ export interface DroppedAssignment { export interface SwapCandidate { participant_id: number user: User - challenge_title: string - challenge_description: string - challenge_points: number - challenge_difficulty: Difficulty + is_playthrough: boolean + // Challenge fields (used when is_playthrough=false) + challenge_title: string | null + 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 } @@ -332,10 +338,16 @@ export interface SwapCandidate { export type SwapRequestStatus = 'pending' | 'accepted' | 'declined' | 'cancelled' export interface SwapRequestChallengeInfo { - title: string - description: string - points: number - difficulty: string + is_playthrough: boolean + // Challenge fields (used when is_playthrough=false) + title: string | null + 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 }