556 lines
19 KiB
Python
556 lines
19 KiB
Python
"""
|
|
Assignment details and dispute system endpoints.
|
|
"""
|
|
from datetime import datetime, timedelta, timezone
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from fastapi.responses import Response, StreamingResponse
|
|
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, Marathon,
|
|
Dispute, DisputeStatus, DisputeComment, DisputeVote,
|
|
)
|
|
from app.schemas import (
|
|
AssignmentDetailResponse, DisputeCreate, DisputeResponse,
|
|
DisputeCommentCreate, DisputeCommentResponse, DisputeVoteCreate,
|
|
MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse,
|
|
)
|
|
from app.schemas.user import UserPublic
|
|
from app.services.storage import storage_service
|
|
from app.services.telegram_notifier import telegram_notifier
|
|
|
|
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
|
|
|
|
# Ensure expires_at has UTC timezone info for correct frontend parsing
|
|
created_at_utc = dispute.created_at.replace(tzinfo=timezone.utc) if dispute.created_at.tzinfo is None else dispute.created_at
|
|
expires_at = created_at_utc + 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 = storage_service.get_url(assignment.proof_path, "proofs")
|
|
|
|
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=storage_service.get_url(game.cover_path, "covers"),
|
|
),
|
|
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.get("/assignments/{assignment_id}/proof-media")
|
|
async def get_assignment_proof_media(
|
|
assignment_id: int,
|
|
request: Request,
|
|
current_user: CurrentUser,
|
|
db: DbSession,
|
|
):
|
|
"""Stream the proof media (image or video) for an assignment with Range support"""
|
|
# Get assignment
|
|
result = await db.execute(
|
|
select(Assignment)
|
|
.options(
|
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
|
)
|
|
.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")
|
|
|
|
# Check if proof exists
|
|
if not assignment.proof_path:
|
|
raise HTTPException(status_code=404, detail="No proof media for this assignment")
|
|
|
|
# Get file from storage
|
|
file_data = await storage_service.get_file(assignment.proof_path, "proofs")
|
|
if not file_data:
|
|
raise HTTPException(status_code=404, detail="Proof media not found in storage")
|
|
|
|
content, content_type = file_data
|
|
file_size = len(content)
|
|
|
|
# Check if it's a video and handle Range requests
|
|
is_video = content_type.startswith("video/")
|
|
|
|
if is_video:
|
|
range_header = request.headers.get("range")
|
|
|
|
if range_header:
|
|
# Parse range header
|
|
range_match = range_header.replace("bytes=", "").split("-")
|
|
start = int(range_match[0]) if range_match[0] else 0
|
|
end = int(range_match[1]) if range_match[1] else file_size - 1
|
|
|
|
# Ensure valid range
|
|
if start >= file_size:
|
|
raise HTTPException(status_code=416, detail="Range not satisfiable")
|
|
|
|
end = min(end, file_size - 1)
|
|
chunk = content[start:end + 1]
|
|
|
|
return Response(
|
|
content=chunk,
|
|
status_code=206,
|
|
media_type=content_type,
|
|
headers={
|
|
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": str(len(chunk)),
|
|
"Cache-Control": "public, max-age=31536000",
|
|
}
|
|
)
|
|
|
|
# No range header - return full video with Accept-Ranges
|
|
return Response(
|
|
content=content,
|
|
media_type=content_type,
|
|
headers={
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": str(file_size),
|
|
"Cache-Control": "public, max-age=31536000",
|
|
}
|
|
)
|
|
|
|
# For images, just return the content
|
|
return Response(
|
|
content=content,
|
|
media_type=content_type,
|
|
headers={
|
|
"Cache-Control": "public, max-age=31536000",
|
|
}
|
|
)
|
|
|
|
|
|
# Keep old endpoint for backwards compatibility
|
|
@router.get("/assignments/{assignment_id}/proof-image")
|
|
async def get_assignment_proof_image(
|
|
assignment_id: int,
|
|
request: Request,
|
|
current_user: CurrentUser,
|
|
db: DbSession,
|
|
):
|
|
"""Deprecated: Use proof-media instead. Redirects to proof-media."""
|
|
return await get_assignment_proof_media(assignment_id, request, current_user, db)
|
|
|
|
|
|
@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)
|
|
|
|
# Send notification to assignment owner
|
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
|
marathon = result.scalar_one_or_none()
|
|
if marathon:
|
|
await telegram_notifier.notify_dispute_raised(
|
|
db,
|
|
user_id=assignment.participant.user_id,
|
|
marathon_title=marathon.title,
|
|
challenge_title=assignment.challenge.title
|
|
)
|
|
|
|
# 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=storage_service.get_url(a.challenge.game.cover_path, "covers"),
|
|
),
|
|
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
|
|
]
|