Add dispute system
This commit is contained in:
@@ -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)
|
||||
|
||||
433
backend/app/api/v1/assignments.py
Normal file
433
backend/app/api/v1/assignments.py
Normal 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
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user