""" Assignment details and dispute system endpoints. """ from datetime import datetime, timedelta from fastapi import APIRouter, HTTPException from sqlalchemy import select from sqlalchemy.orm import selectinload from app.api.deps import DbSession, CurrentUser from app.models import ( Assignment, AssignmentStatus, Participant, Challenge, User, Dispute, DisputeStatus, DisputeComment, DisputeVote, ) from app.schemas import ( AssignmentDetailResponse, DisputeCreate, DisputeResponse, DisputeCommentCreate, DisputeCommentResponse, DisputeVoteCreate, MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse, ) from app.schemas.user import UserPublic router = APIRouter(tags=["assignments"]) # Dispute window: 24 hours after completion DISPUTE_WINDOW_HOURS = 24 def user_to_public(user: User) -> UserPublic: """Convert User model to UserPublic schema""" return UserPublic( id=user.id, login=user.login, nickname=user.nickname, avatar_url=None, role=user.role, created_at=user.created_at, ) def build_dispute_response(dispute: Dispute, current_user_id: int) -> DisputeResponse: """Build DisputeResponse from Dispute model""" 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) my_vote = None for v in dispute.votes: if v.user_id == current_user_id: my_vote = v.vote break expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS) return DisputeResponse( id=dispute.id, raised_by=user_to_public(dispute.raised_by), reason=dispute.reason, status=dispute.status, comments=[ DisputeCommentResponse( id=c.id, user=user_to_public(c.user), text=c.text, created_at=c.created_at, ) for c in sorted(dispute.comments, key=lambda x: x.created_at) ], votes=[ { "user": user_to_public(v.user), "vote": v.vote, "created_at": v.created_at, } for v in dispute.votes ], votes_valid=votes_valid, votes_invalid=votes_invalid, my_vote=my_vote, expires_at=expires_at, created_at=dispute.created_at, resolved_at=dispute.resolved_at, ) @router.get("/assignments/{assignment_id}", response_model=AssignmentDetailResponse) async def get_assignment_detail( assignment_id: int, current_user: CurrentUser, db: DbSession, ): """Get detailed information about an assignment including proofs and dispute""" # Get assignment with all relationships result = await db.execute( select(Assignment) .options( selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.participant).selectinload(Participant.user), selectinload(Assignment.dispute).selectinload(Dispute.raised_by), selectinload(Assignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user), selectinload(Assignment.dispute).selectinload(Dispute.votes).selectinload(DisputeVote.user), ) .where(Assignment.id == assignment_id) ) assignment = result.scalar_one_or_none() if not assignment: raise HTTPException(status_code=404, detail="Assignment not found") # Check user is participant of the marathon marathon_id = assignment.challenge.game.marathon_id result = await db.execute( select(Participant).where( Participant.user_id == current_user.id, Participant.marathon_id == marathon_id, ) ) participant = result.scalar_one_or_none() if not participant: raise HTTPException(status_code=403, detail="You are not a participant of this marathon") # Build response challenge = assignment.challenge game = challenge.game owner_user = assignment.participant.user # Determine if user can dispute can_dispute = False if ( assignment.status == AssignmentStatus.COMPLETED.value and assignment.completed_at and assignment.participant.user_id != current_user.id and assignment.dispute is None ): time_since_completion = datetime.utcnow() - assignment.completed_at can_dispute = time_since_completion < timedelta(hours=DISPUTE_WINDOW_HOURS) # Build proof URLs proof_image_url = None if assignment.proof_path: # Extract filename from path proof_image_url = f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" return AssignmentDetailResponse( id=assignment.id, 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=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, ), is_generated=challenge.is_generated, created_at=challenge.created_at, ), participant=user_to_public(owner_user), status=assignment.status, proof_url=assignment.proof_url, proof_image_url=proof_image_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, can_dispute=can_dispute, dispute=build_dispute_response(assignment.dispute, current_user.id) if assignment.dispute else None, ) @router.post("/assignments/{assignment_id}/dispute", response_model=DisputeResponse) async def create_dispute( assignment_id: int, data: DisputeCreate, current_user: CurrentUser, db: DbSession, ): """Create a dispute against an assignment's proof""" # Get assignment result = await db.execute( select(Assignment) .options( selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.participant), selectinload(Assignment.dispute), ) .where(Assignment.id == assignment_id) ) assignment = result.scalar_one_or_none() if not assignment: raise HTTPException(status_code=404, detail="Assignment not found") # Check user is participant of the marathon marathon_id = assignment.challenge.game.marathon_id result = await db.execute( select(Participant).where( Participant.user_id == current_user.id, Participant.marathon_id == marathon_id, ) ) participant = result.scalar_one_or_none() if not participant: raise HTTPException(status_code=403, detail="You are not a participant of this marathon") # Validate if assignment.status != AssignmentStatus.COMPLETED.value: raise HTTPException(status_code=400, detail="Can only dispute completed assignments") if assignment.participant.user_id == current_user.id: raise HTTPException(status_code=400, detail="Cannot dispute your own assignment") if assignment.dispute: raise HTTPException(status_code=400, detail="A dispute already exists for this assignment") if not assignment.completed_at: raise HTTPException(status_code=400, detail="Assignment has no completion date") time_since_completion = datetime.utcnow() - assignment.completed_at if time_since_completion >= timedelta(hours=DISPUTE_WINDOW_HOURS): raise HTTPException(status_code=400, detail="Dispute window has expired (24 hours)") # Create dispute dispute = Dispute( assignment_id=assignment_id, raised_by_id=current_user.id, reason=data.reason, status=DisputeStatus.OPEN.value, ) db.add(dispute) await db.commit() await db.refresh(dispute) # Load relationships for response result = await db.execute( select(Dispute) .options( selectinload(Dispute.raised_by), selectinload(Dispute.comments).selectinload(DisputeComment.user), selectinload(Dispute.votes).selectinload(DisputeVote.user), ) .where(Dispute.id == dispute.id) ) dispute = result.scalar_one() return build_dispute_response(dispute, current_user.id) @router.post("/disputes/{dispute_id}/comments", response_model=DisputeCommentResponse) async def add_dispute_comment( dispute_id: int, data: DisputeCommentCreate, current_user: CurrentUser, db: DbSession, ): """Add a comment to a dispute discussion""" # Get dispute with assignment result = await db.execute( select(Dispute) .options( selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game), ) .where(Dispute.id == dispute_id) ) dispute = result.scalar_one_or_none() if not dispute: raise HTTPException(status_code=404, detail="Dispute not found") if dispute.status != DisputeStatus.OPEN.value: raise HTTPException(status_code=400, detail="Dispute is already resolved") # Check user is participant of the marathon marathon_id = dispute.assignment.challenge.game.marathon_id result = await db.execute( select(Participant).where( Participant.user_id == current_user.id, Participant.marathon_id == marathon_id, ) ) participant = result.scalar_one_or_none() if not participant: raise HTTPException(status_code=403, detail="You are not a participant of this marathon") # Create comment comment = DisputeComment( dispute_id=dispute_id, user_id=current_user.id, text=data.text, ) db.add(comment) await db.commit() await db.refresh(comment) # Get user for response result = await db.execute(select(User).where(User.id == current_user.id)) user = result.scalar_one() return DisputeCommentResponse( id=comment.id, user=user_to_public(user), text=comment.text, created_at=comment.created_at, ) @router.post("/disputes/{dispute_id}/vote", response_model=MessageResponse) async def vote_on_dispute( dispute_id: int, data: DisputeVoteCreate, current_user: CurrentUser, db: DbSession, ): """Vote on a dispute (True = valid/proof is OK, False = invalid/proof is not OK)""" # Get dispute with assignment result = await db.execute( select(Dispute) .options( selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game), ) .where(Dispute.id == dispute_id) ) dispute = result.scalar_one_or_none() if not dispute: raise HTTPException(status_code=404, detail="Dispute not found") if dispute.status != DisputeStatus.OPEN.value: raise HTTPException(status_code=400, detail="Dispute is already resolved") # Check user is participant of the marathon marathon_id = dispute.assignment.challenge.game.marathon_id result = await db.execute( select(Participant).where( Participant.user_id == current_user.id, Participant.marathon_id == marathon_id, ) ) participant = result.scalar_one_or_none() if not participant: raise HTTPException(status_code=403, detail="You are not a participant of this marathon") # Check if user already voted result = await db.execute( select(DisputeVote).where( DisputeVote.dispute_id == dispute_id, DisputeVote.user_id == current_user.id, ) ) existing_vote = result.scalar_one_or_none() if existing_vote: # Update existing vote existing_vote.vote = data.vote existing_vote.created_at = datetime.utcnow() else: # Create new vote vote = DisputeVote( dispute_id=dispute_id, user_id=current_user.id, vote=data.vote, ) db.add(vote) await db.commit() vote_text = "валидным" if data.vote else "невалидным" return MessageResponse(message=f"Вы проголосовали: пруф {vote_text}") @router.get("/marathons/{marathon_id}/returned-assignments", response_model=list[ReturnedAssignmentResponse]) async def get_returned_assignments( marathon_id: int, current_user: CurrentUser, db: DbSession, ): """Get current user's returned assignments that need to be redone""" # Check user is participant result = await db.execute( select(Participant).where( Participant.user_id == current_user.id, Participant.marathon_id == marathon_id, ) ) participant = result.scalar_one_or_none() if not participant: raise HTTPException(status_code=403, detail="You are not a participant of this marathon") # Get returned assignments result = await db.execute( select(Assignment) .options( selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.dispute), ) .where( Assignment.participant_id == participant.id, Assignment.status == AssignmentStatus.RETURNED.value, ) .order_by(Assignment.completed_at.asc()) # Oldest first ) assignments = result.scalars().all() return [ ReturnedAssignmentResponse( 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=f"/uploads/covers/{a.challenge.game.cover_path.split('/')[-1]}" if a.challenge.game.cover_path else None, ), is_generated=a.challenge.is_generated, created_at=a.challenge.created_at, ), original_completed_at=a.completed_at, dispute_reason=a.dispute.reason if a.dispute else "Оспорено", ) for a in assignments ]