from datetime import timedelta import secrets import string from fastapi import APIRouter, HTTPException, status, Depends, UploadFile, File, Response from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy import select, func from sqlalchemy.orm import selectinload from app.api.deps import ( DbSession, CurrentUser, require_participant, require_organizer, require_creator, get_participant, ) from app.core.config import settings from app.core.security import decode_access_token from app.services.storage import storage_service # Optional auth for endpoints that need it conditionally optional_auth = HTTPBearer(auto_error=False) from app.models import ( Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge, Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole, Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, ) from app.schemas import ( MarathonCreate, MarathonUpdate, MarathonResponse, MarathonListItem, MarathonPublicInfo, JoinMarathon, ParticipantInfo, ParticipantWithUser, LeaderboardEntry, MessageResponse, UserPublic, SetParticipantRole, ) from app.services.telegram_notifier import telegram_notifier router = APIRouter(prefix="/marathons", tags=["marathons"]) # Public endpoint (no auth required) @router.get("/by-code/{invite_code}", response_model=MarathonPublicInfo) async def get_marathon_by_code(invite_code: str, db: DbSession): """Get public marathon info by invite code. No authentication required.""" result = await db.execute( select(Marathon, func.count(Participant.id).label("participants_count")) .outerjoin(Participant) .options(selectinload(Marathon.creator)) .where(func.upper(Marathon.invite_code) == invite_code.upper()) .group_by(Marathon.id) ) row = result.first() if not row: raise HTTPException(status_code=404, detail="Marathon not found") marathon = row[0] participants_count = row[1] return MarathonPublicInfo( id=marathon.id, title=marathon.title, description=marathon.description, status=marathon.status, cover_url=marathon.cover_url, participants_count=participants_count, creator_nickname=marathon.creator.nickname, ) def generate_invite_code() -> str: """Generate a clean 8-character uppercase alphanumeric code.""" alphabet = string.ascii_uppercase + string.digits return ''.join(secrets.choice(alphabet) for _ in range(8)) async def get_marathon_or_404(db, marathon_id: int) -> Marathon: result = await db.execute( select(Marathon) .options(selectinload(Marathon.creator)) .where(Marathon.id == marathon_id) ) marathon = result.scalar_one_or_none() if not marathon: raise HTTPException(status_code=404, detail="Marathon not found") return marathon async def get_participation(db, user_id: int, marathon_id: int) -> Participant | None: result = await db.execute( select(Participant).where( Participant.user_id == user_id, Participant.marathon_id == marathon_id, ) ) return result.scalar_one_or_none() @router.get("", response_model=list[MarathonListItem]) async def list_marathons(current_user: CurrentUser, db: DbSession): """Get all marathons where user is participant, creator, or public marathons""" # Admin can see all marathons if current_user.is_admin: result = await db.execute( select(Marathon, func.count(Participant.id).label("participants_count")) .outerjoin(Participant) .group_by(Marathon.id) .order_by(Marathon.created_at.desc()) ) else: # User can see: own marathons, participated marathons, and public marathons result = await db.execute( select(Marathon, func.count(Participant.id).label("participants_count")) .outerjoin(Participant) .where( (Marathon.creator_id == current_user.id) | (Participant.user_id == current_user.id) | (Marathon.is_public == True) ) .group_by(Marathon.id) .order_by(Marathon.created_at.desc()) ) marathons = [] for row in result.all(): marathon = row[0] marathons.append(MarathonListItem( id=marathon.id, title=marathon.title, status=marathon.status, is_public=marathon.is_public, cover_url=marathon.cover_url, participants_count=row[1], start_date=marathon.start_date, end_date=marathon.end_date, )) return marathons @router.post("", response_model=MarathonResponse) async def create_marathon( data: MarathonCreate, current_user: CurrentUser, db: DbSession, ): # Strip timezone info for naive datetime columns start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date end_date = start_date + timedelta(days=data.duration_days) marathon = Marathon( title=data.title, description=data.description, creator_id=current_user.id, invite_code=generate_invite_code(), is_public=data.is_public, game_proposal_mode=data.game_proposal_mode, start_date=start_date, end_date=end_date, ) db.add(marathon) await db.flush() # Auto-add creator as organizer participant participant = Participant( user_id=current_user.id, marathon_id=marathon.id, role=ParticipantRole.ORGANIZER.value, # Creator is organizer ) db.add(participant) await db.commit() await db.refresh(marathon) return MarathonResponse( id=marathon.id, title=marathon.title, description=marathon.description, creator=UserPublic.model_validate(current_user), status=marathon.status, invite_code=marathon.invite_code, is_public=marathon.is_public, game_proposal_mode=marathon.game_proposal_mode, auto_events_enabled=marathon.auto_events_enabled, cover_url=marathon.cover_url, start_date=marathon.start_date, end_date=marathon.end_date, participants_count=1, games_count=0, created_at=marathon.created_at, my_participation=ParticipantInfo.model_validate(participant), ) @router.get("/{marathon_id}", response_model=MarathonResponse) async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): marathon = await get_marathon_or_404(db, marathon_id) # For private marathons, require participation (or admin/creator) if not marathon.is_public and not current_user.is_admin: participation = await get_participation(db, current_user.id, marathon_id) if not participation: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You are not a participant of this private marathon", ) # Count participants and approved games participants_count = await db.scalar( select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id) ) games_count = await db.scalar( select(func.count()).select_from(Game).where( Game.marathon_id == marathon_id, Game.status == GameStatus.APPROVED.value, ) ) # Get user's participation participation = await get_participation(db, current_user.id, marathon_id) return MarathonResponse( id=marathon.id, title=marathon.title, description=marathon.description, creator=UserPublic.model_validate(marathon.creator), status=marathon.status, invite_code=marathon.invite_code, is_public=marathon.is_public, game_proposal_mode=marathon.game_proposal_mode, auto_events_enabled=marathon.auto_events_enabled, cover_url=marathon.cover_url, start_date=marathon.start_date, end_date=marathon.end_date, participants_count=participants_count, games_count=games_count, created_at=marathon.created_at, my_participation=ParticipantInfo.model_validate(participation) if participation else None, ) @router.patch("/{marathon_id}", response_model=MarathonResponse) async def update_marathon( marathon_id: int, data: MarathonUpdate, current_user: CurrentUser, db: DbSession, ): # Require organizer role await require_organizer(db, current_user, marathon_id) marathon = await get_marathon_or_404(db, marathon_id) if marathon.status != MarathonStatus.PREPARING.value: raise HTTPException(status_code=400, detail="Cannot update active or finished marathon") if data.title is not None: marathon.title = data.title if data.description is not None: marathon.description = data.description if data.start_date is not None: # Strip timezone info for naive datetime columns marathon.start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date if data.is_public is not None: marathon.is_public = data.is_public if data.game_proposal_mode is not None: marathon.game_proposal_mode = data.game_proposal_mode if data.auto_events_enabled is not None: marathon.auto_events_enabled = data.auto_events_enabled await db.commit() return await get_marathon(marathon_id, current_user, db) @router.delete("/{marathon_id}", response_model=MessageResponse) async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): # Only creator or admin can delete await require_creator(db, current_user, marathon_id) marathon = await get_marathon_or_404(db, marathon_id) await db.delete(marathon) await db.commit() return MessageResponse(message="Marathon deleted") @router.post("/{marathon_id}/start", response_model=MarathonResponse) async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): # Require organizer role await require_organizer(db, current_user, marathon_id) marathon = await get_marathon_or_404(db, marathon_id) if marathon.status != MarathonStatus.PREPARING.value: raise HTTPException(status_code=400, detail="Marathon is not in preparing state") # Check if there are approved games games_result = await db.execute( select(Game).where( Game.marathon_id == marathon_id, Game.status == GameStatus.APPROVED.value, ) ) approved_games = games_result.scalars().all() if len(approved_games) == 0: raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру") # Check that all approved games have at least one challenge games_without_challenges = [] for game in approved_games: challenge_count = await db.scalar( select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id) ) if challenge_count == 0: games_without_challenges.append(game.title) if games_without_challenges: games_list = ", ".join(games_without_challenges) raise HTTPException( status_code=400, detail=f"У следующих игр нет челленджей: {games_list}" ) marathon.status = MarathonStatus.ACTIVE.value # Log activity activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.START_MARATHON.value, data={"title": marathon.title}, ) db.add(activity) await db.commit() # Send Telegram notifications await telegram_notifier.notify_marathon_start(db, marathon_id, marathon.title) return await get_marathon(marathon_id, current_user, db) @router.post("/{marathon_id}/finish", response_model=MarathonResponse) async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): # Require organizer role await require_organizer(db, current_user, marathon_id) marathon = await get_marathon_or_404(db, marathon_id) if marathon.status != MarathonStatus.ACTIVE.value: raise HTTPException(status_code=400, detail="Marathon is not active") marathon.status = MarathonStatus.FINISHED.value # Log activity activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.FINISH_MARATHON.value, data={"title": marathon.title}, ) db.add(activity) await db.commit() # Send Telegram notifications await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title) return await get_marathon(marathon_id, current_user, db) @router.post("/join", response_model=MarathonResponse) async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession): result = await db.execute( select(Marathon).where(func.upper(Marathon.invite_code) == data.invite_code.upper()) ) marathon = result.scalar_one_or_none() if not marathon: raise HTTPException(status_code=404, detail="Invalid invite code") if marathon.status == MarathonStatus.FINISHED.value: raise HTTPException(status_code=400, detail="Marathon has already finished") # Check if already participant existing = await get_participation(db, current_user.id, marathon.id) if existing: raise HTTPException(status_code=400, detail="Already joined this marathon") participant = Participant( user_id=current_user.id, marathon_id=marathon.id, role=ParticipantRole.PARTICIPANT.value, # Regular participant ) db.add(participant) # Log activity activity = Activity( marathon_id=marathon.id, user_id=current_user.id, type=ActivityType.JOIN.value, data={"nickname": current_user.nickname}, ) db.add(activity) await db.commit() return await get_marathon(marathon.id, current_user, db) @router.post("/{marathon_id}/join", response_model=MarathonResponse) async def join_public_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): """Join a public marathon without invite code""" marathon = await get_marathon_or_404(db, marathon_id) if not marathon.is_public: raise HTTPException(status_code=403, detail="This marathon is private. Use invite code to join.") if marathon.status == MarathonStatus.FINISHED.value: raise HTTPException(status_code=400, detail="Marathon has already finished") # Check if already participant existing = await get_participation(db, current_user.id, marathon.id) if existing: raise HTTPException(status_code=400, detail="Already joined this marathon") participant = Participant( user_id=current_user.id, marathon_id=marathon.id, role=ParticipantRole.PARTICIPANT.value, ) db.add(participant) # Log activity activity = Activity( marathon_id=marathon.id, user_id=current_user.id, type=ActivityType.JOIN.value, data={"nickname": current_user.nickname}, ) db.add(activity) await db.commit() return await get_marathon(marathon.id, current_user, db) @router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser]) async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession): marathon = await get_marathon_or_404(db, marathon_id) # For private marathons, require participation (or admin) if not marathon.is_public and not current_user.is_admin: participation = await get_participation(db, current_user.id, marathon_id) if not participation: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You are not a participant of this private marathon", ) result = await db.execute( select(Participant) .options(selectinload(Participant.user)) .where(Participant.marathon_id == marathon_id) .order_by(Participant.joined_at) ) participants = result.scalars().all() return [ ParticipantWithUser( id=p.id, role=p.role, total_points=p.total_points, current_streak=p.current_streak, drop_count=p.drop_count, joined_at=p.joined_at, user=UserPublic.model_validate(p.user), ) for p in participants ] @router.patch("/{marathon_id}/participants/{user_id}/role", response_model=ParticipantWithUser) async def set_participant_role( marathon_id: int, user_id: int, data: SetParticipantRole, current_user: CurrentUser, db: DbSession, ): """Set participant's role (only creator can do this)""" # Only creator can change roles marathon = await require_creator(db, current_user, marathon_id) # Cannot change creator's role if user_id == marathon.creator_id: raise HTTPException(status_code=400, detail="Cannot change creator's role") # Get participant result = await db.execute( select(Participant) .options(selectinload(Participant.user)) .where( Participant.marathon_id == marathon_id, Participant.user_id == user_id, ) ) participant = result.scalar_one_or_none() if not participant: raise HTTPException(status_code=404, detail="Participant not found") participant.role = data.role await db.commit() await db.refresh(participant) return ParticipantWithUser( id=participant.id, role=participant.role, total_points=participant.total_points, current_streak=participant.current_streak, drop_count=participant.drop_count, joined_at=participant.joined_at, user=UserPublic.model_validate(participant.user), ) @router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry]) async def get_leaderboard( marathon_id: int, db: DbSession, credentials: HTTPAuthorizationCredentials | None = Depends(optional_auth), ): """ Get marathon leaderboard. Public marathons: no auth required. Private marathons: requires auth + participation check. """ marathon = await get_marathon_or_404(db, marathon_id) # For private marathons, require authentication and participation if not marathon.is_public: if not credentials: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required for private marathon leaderboard", headers={"WWW-Authenticate": "Bearer"}, ) payload = decode_access_token(credentials.credentials) if not payload: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}, ) user_id = int(payload.get("sub")) participant = await get_participant(db, user_id, marathon_id) if not participant: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You are not a participant of this marathon", ) result = await db.execute( select(Participant) .options(selectinload(Participant.user)) .where(Participant.marathon_id == marathon_id) .order_by(Participant.total_points.desc()) ) participants = result.scalars().all() leaderboard = [] for rank, p in enumerate(participants, 1): # Count completed and dropped assignments completed = await db.scalar( select(func.count()).select_from(Assignment).where( Assignment.participant_id == p.id, Assignment.status == AssignmentStatus.COMPLETED.value, ) ) dropped = await db.scalar( select(func.count()).select_from(Assignment).where( Assignment.participant_id == p.id, Assignment.status == AssignmentStatus.DROPPED.value, ) ) leaderboard.append(LeaderboardEntry( rank=rank, user=UserPublic.model_validate(p.user), total_points=p.total_points, current_streak=p.current_streak, completed_count=completed, dropped_count=dropped, )) return leaderboard @router.get("/{marathon_id}/cover") async def get_marathon_cover(marathon_id: int, db: DbSession): """Get marathon cover image""" marathon = await get_marathon_or_404(db, marathon_id) if not marathon.cover_path: raise HTTPException(status_code=404, detail="Marathon has no cover") file_data = await storage_service.get_file(marathon.cover_path, "covers") if not file_data: raise HTTPException(status_code=404, detail="Cover not found in storage") content, content_type = file_data return Response( content=content, media_type=content_type, headers={ "Cache-Control": "public, max-age=3600", } ) @router.post("/{marathon_id}/cover", response_model=MarathonResponse) async def upload_marathon_cover( marathon_id: int, current_user: CurrentUser, db: DbSession, file: UploadFile = File(...), ): """Upload marathon cover image (organizers only, preparing status)""" await require_organizer(db, current_user, marathon_id) marathon = await get_marathon_or_404(db, marathon_id) if marathon.status != MarathonStatus.PREPARING.value: raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon") # Validate file if not file.content_type or not file.content_type.startswith("image/"): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="File must be an image", ) contents = await file.read() if len(contents) > settings.MAX_UPLOAD_SIZE: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", ) # Get file extension ext = file.filename.split(".")[-1].lower() if file.filename else "jpg" if ext not in settings.ALLOWED_IMAGE_EXTENSIONS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}", ) # Delete old cover if exists if marathon.cover_path: await storage_service.delete_file(marathon.cover_path) # Upload file filename = storage_service.generate_filename(marathon_id, file.filename) file_path = await storage_service.upload_file( content=contents, folder="covers", filename=filename, content_type=file.content_type or "image/jpeg", ) # Update marathon with cover path and URL marathon.cover_path = file_path marathon.cover_url = f"/api/v1/marathons/{marathon_id}/cover" await db.commit() return await get_marathon(marathon_id, current_user, db) @router.delete("/{marathon_id}/cover", response_model=MarathonResponse) async def delete_marathon_cover( marathon_id: int, current_user: CurrentUser, db: DbSession, ): """Delete marathon cover image (organizers only, preparing status)""" await require_organizer(db, current_user, marathon_id) marathon = await get_marathon_or_404(db, marathon_id) if marathon.status != MarathonStatus.PREPARING.value: raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon") if not marathon.cover_path: raise HTTPException(status_code=400, detail="Marathon has no cover") # Delete file from storage await storage_service.delete_file(marathon.cover_path) marathon.cover_path = None marathon.cover_url = None await db.commit() return await get_marathon(marathon_id, current_user, db) # ============ Marathon Disputes (for organizers) ============ from pydantic import BaseModel, Field from datetime import datetime class MarathonDisputeResponse(BaseModel): id: int assignment_id: int | None bonus_assignment_id: int | None challenge_title: str participant_nickname: str raised_by_nickname: str reason: str status: str votes_valid: int votes_invalid: int created_at: str expires_at: str class Config: from_attributes = True class ResolveDisputeRequest(BaseModel): is_valid: bool = Field(..., description="True = proof is valid, False = proof is invalid") @router.get("/{marathon_id}/disputes", response_model=list[MarathonDisputeResponse]) async def list_marathon_disputes( marathon_id: int, current_user: CurrentUser, db: DbSession, status_filter: str = "open", ): """List disputes in a marathon. Organizers only.""" await require_organizer(db, current_user, marathon_id) marathon = await get_marathon_or_404(db, marathon_id) from datetime import timedelta DISPUTE_WINDOW_HOURS = 24 # Get all assignments in this marathon (through games) games_result = await db.execute( select(Game.id).where(Game.marathon_id == marathon_id) ) game_ids = [g[0] for g in games_result.all()] if not game_ids: return [] # Get disputes for assignments in these games # Using selectinload for eager loading - no explicit joins needed query = ( select(Dispute) .options( selectinload(Dispute.raised_by), selectinload(Dispute.votes), selectinload(Dispute.assignment).selectinload(Assignment.participant).selectinload(Participant.user), selectinload(Dispute.assignment).selectinload(Assignment.challenge), selectinload(Dispute.assignment).selectinload(Assignment.game), selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant).selectinload(Participant.user), selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game), selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge), ) .order_by(Dispute.created_at.desc()) ) if status_filter == "open": query = query.where(Dispute.status == DisputeStatus.OPEN.value) result = await db.execute(query) all_disputes = result.scalars().unique().all() # Filter disputes that belong to this marathon's games response = [] for dispute in all_disputes: # Check if dispute belongs to this marathon if dispute.bonus_assignment_id: bonus = dispute.bonus_assignment if not bonus or not bonus.main_assignment: continue if bonus.main_assignment.game_id not in game_ids: continue participant = bonus.main_assignment.participant challenge_title = f"Бонус: {bonus.challenge.title}" else: assignment = dispute.assignment if not assignment: continue if assignment.is_playthrough: if assignment.game_id not in game_ids: continue challenge_title = f"Прохождение: {assignment.game.title}" else: if not assignment.challenge or assignment.challenge.game_id not in game_ids: continue challenge_title = assignment.challenge.title participant = assignment.participant # Count votes votes_valid = sum(1 for v in dispute.votes if v.vote is True) votes_invalid = sum(1 for v in dispute.votes if v.vote is False) # Calculate expiry expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS) response.append(MarathonDisputeResponse( id=dispute.id, assignment_id=dispute.assignment_id, bonus_assignment_id=dispute.bonus_assignment_id, challenge_title=challenge_title, participant_nickname=participant.user.nickname, raised_by_nickname=dispute.raised_by.nickname, reason=dispute.reason, status=dispute.status, votes_valid=votes_valid, votes_invalid=votes_invalid, created_at=dispute.created_at.isoformat(), expires_at=expires_at.isoformat(), )) return response @router.post("/{marathon_id}/disputes/{dispute_id}/resolve", response_model=MessageResponse) async def resolve_marathon_dispute( marathon_id: int, dispute_id: int, data: ResolveDisputeRequest, current_user: CurrentUser, db: DbSession, ): """Manually resolve a dispute in a marathon. Organizers only.""" await require_organizer(db, current_user, marathon_id) marathon = await get_marathon_or_404(db, marathon_id) # Get dispute result = await db.execute( select(Dispute) .options( selectinload(Dispute.assignment).selectinload(Assignment.participant), selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Dispute.assignment).selectinload(Assignment.game), selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant), selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game), selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge), ) .where(Dispute.id == dispute_id) ) dispute = result.scalar_one_or_none() if not dispute: raise HTTPException(status_code=404, detail="Dispute not found") # Verify dispute belongs to this marathon if dispute.bonus_assignment_id: bonus = dispute.bonus_assignment if bonus.main_assignment.game.marathon_id != marathon_id: raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon") else: assignment = dispute.assignment if assignment.is_playthrough: if assignment.game.marathon_id != marathon_id: raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon") else: if assignment.challenge.game.marathon_id != marathon_id: raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon") if dispute.status != DisputeStatus.OPEN.value: raise HTTPException(status_code=400, detail="Dispute is already resolved") # Determine result if data.is_valid: result_status = DisputeStatus.RESOLVED_VALID.value else: result_status = DisputeStatus.RESOLVED_INVALID.value # Handle invalid proof if dispute.bonus_assignment_id: # Reset bonus assignment bonus = dispute.bonus_assignment main_assignment = bonus.main_assignment participant = main_assignment.participant # Only subtract points if main playthrough was already completed # (bonus points are added only when main playthrough is completed) if main_assignment.status == AssignmentStatus.COMPLETED.value: points_to_subtract = bonus.points_earned participant.total_points = max(0, participant.total_points - points_to_subtract) # Also reduce the points_earned on the main assignment main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract) bonus.status = BonusAssignmentStatus.PENDING.value bonus.proof_path = None bonus.proof_url = None bonus.proof_comment = None bonus.points_earned = 0 bonus.completed_at = None else: # Reset main assignment assignment = dispute.assignment participant = assignment.participant # Subtract points points_to_subtract = assignment.points_earned participant.total_points = max(0, participant.total_points - points_to_subtract) # Reset streak - the completion was invalid participant.current_streak = 0 # Reset assignment assignment.status = AssignmentStatus.RETURNED.value assignment.points_earned = 0 # For playthrough: reset all bonus assignments if assignment.is_playthrough: bonus_result = await db.execute( select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id) ) for ba in bonus_result.scalars().all(): 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 dispute dispute.status = result_status dispute.resolved_at = datetime.utcnow() await db.commit() # Send notification if dispute.bonus_assignment_id: participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}" elif dispute.assignment.is_playthrough: participant_user_id = dispute.assignment.participant.user_id challenge_title = f"Прохождение: {dispute.assignment.game.title}" else: participant_user_id = dispute.assignment.participant.user_id challenge_title = dispute.assignment.challenge.title await telegram_notifier.notify_dispute_resolved( db, user_id=participant_user_id, marathon_title=marathon.title, challenge_title=challenge_title, is_valid=data.is_valid ) return MessageResponse( message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}" )