434 lines
15 KiB
Python
434 lines
15 KiB
Python
"""
|
|
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
|
|
]
|