Add dispute system

This commit is contained in:
2025-12-16 00:33:50 +07:00
parent 339a212e57
commit c7966656d8
22 changed files with 1584 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments
router = APIRouter(prefix="/api/v1")
@@ -13,3 +13,4 @@ router.include_router(wheel.router)
router.include_router(feed.router)
router.include_router(admin.router)
router.include_router(events.router)
router.include_router(assignments.router)

View File

@@ -0,0 +1,433 @@
"""
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
]

View File

@@ -1111,6 +1111,7 @@ async def complete_event_assignment(
# Log activity
activity_data = {
"assignment_id": assignment.id,
"game": challenge.game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,

View File

@@ -60,6 +60,39 @@ async def get_active_assignment(db, participant_id: int, is_event: bool = False)
return result.scalar_one_or_none()
async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment | None:
"""Get the oldest returned assignment that needs to be redone."""
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant_id,
Assignment.status == AssignmentStatus.RETURNED.value,
Assignment.is_event_assignment == False,
)
.order_by(Assignment.completed_at.asc()) # Oldest first
.limit(1)
)
return result.scalar_one_or_none()
async def activate_returned_assignment(db, returned_assignment: Assignment) -> None:
"""
Re-activate a returned assignment.
Simply changes the status back to ACTIVE.
"""
returned_assignment.status = AssignmentStatus.ACTIVE.value
returned_assignment.started_at = datetime.utcnow()
# Clear previous proof data for fresh attempt
returned_assignment.proof_path = None
returned_assignment.proof_url = None
returned_assignment.proof_comment = None
returned_assignment.completed_at = None
returned_assignment.points_earned = 0
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Spin the wheel to get a random game and challenge"""
@@ -347,6 +380,7 @@ async def complete_assignment(
# Log activity
activity_data = {
"assignment_id": assignment.id,
"game": full_challenge.game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
@@ -407,6 +441,13 @@ async def complete_assignment(
await db.commit()
# Check for returned assignments and activate the oldest one
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
if returned_assignment:
await activate_returned_assignment(db, returned_assignment)
await db.commit()
print(f"[WHEEL] Auto-activated returned assignment {returned_assignment.id} for participant {participant.id}")
return CompleteResult(
points_earned=total_points,
streak_bonus=streak_bonus,