Files
game-marathon/backend/app/api/v1/assignments.py

1380 lines
50 KiB
Python
Raw Normal View History

2025-12-16 00:33:50 +07:00
"""
Assignment details and dispute system endpoints.
"""
2025-12-16 02:35:59 +07:00
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form
from fastapi.responses import Response, StreamingResponse
2025-12-16 00:33:50 +07:00
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import (
2025-12-16 20:06:16 +07:00
Assignment, AssignmentStatus, Participant, Challenge, User, Marathon,
Dispute, DisputeStatus, DisputeComment, DisputeVote, BonusAssignment, BonusAssignmentStatus,
2025-12-16 00:33:50 +07:00
)
from app.schemas import (
AssignmentDetailResponse, DisputeCreate, DisputeResponse,
DisputeCommentCreate, DisputeCommentResponse, DisputeVoteCreate,
MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse,
BonusAssignmentResponse, CompleteBonusAssignment, BonusCompleteResult,
2025-12-16 00:33:50 +07:00
)
from app.schemas.user import UserPublic
2025-12-16 01:25:21 +07:00
from app.services.storage import storage_service
2025-12-16 20:06:16 +07:00
from app.services.telegram_notifier import telegram_notifier
2025-12-16 00:33:50 +07:00
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
2025-12-16 02:35:59 +07:00
# 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)
2025-12-16 00:33:50 +07:00
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"""
from app.models import Game
2025-12-16 00:33:50 +07:00
# Get assignment with all relationships
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
2026-01-03 00:12:07 +07:00
selectinload(Assignment.proof_files), # Load multiple proof files
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough
2026-01-03 00:12:07 +07:00
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.proof_files), # Load bonus proof files
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.raised_by), # Bonus disputes
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user),
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.votes).selectinload(DisputeVote.user),
2025-12-16 00:33:50 +07:00
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")
# Get marathon_id based on assignment type
if assignment.is_playthrough:
marathon_id = assignment.game.marathon_id
else:
marathon_id = assignment.challenge.game.marathon_id
2025-12-16 00:33:50 +07:00
# Check user is participant of the marathon
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")
owner_user = assignment.participant.user
# Determine if user can dispute (including playthrough)
# Allow disputing if no active dispute exists (resolved disputes don't block new ones)
has_active_dispute = (
assignment.dispute is not None and
assignment.dispute.status in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]
)
2025-12-16 00:33:50 +07:00
can_dispute = False
if (
assignment.status == AssignmentStatus.COMPLETED.value
and assignment.completed_at
and assignment.participant.user_id != current_user.id
and not has_active_dispute
2025-12-16 00:33:50 +07:00
):
time_since_completion = datetime.utcnow() - assignment.completed_at
can_dispute = time_since_completion < timedelta(hours=DISPUTE_WINDOW_HOURS)
# Build proof URLs
2025-12-16 01:25:21 +07:00
proof_image_url = storage_service.get_url(assignment.proof_path, "proofs")
2025-12-16 00:33:50 +07:00
# Handle playthrough assignments
if assignment.is_playthrough:
game = assignment.game
bonus_challenges = []
for ba in assignment.bonus_assignments:
# Determine if user can dispute this bonus
# Allow disputing if no active dispute exists
bonus_has_active_dispute = (
ba.dispute is not None and
ba.dispute.status in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]
)
bonus_can_dispute = False
if (
ba.status == BonusAssignmentStatus.COMPLETED.value
and ba.completed_at
and assignment.participant.user_id != current_user.id
and not bonus_has_active_dispute
):
time_since_bonus_completion = datetime.utcnow() - ba.completed_at
bonus_can_dispute = time_since_bonus_completion < timedelta(hours=DISPUTE_WINDOW_HOURS)
2026-01-03 00:12:07 +07:00
# Build bonus proof files list
from app.schemas.assignment import ProofFileResponse
bonus_proof_files = [
ProofFileResponse(
id=pf.id,
file_type=pf.file_type,
order_index=pf.order_index,
created_at=pf.created_at,
)
for pf in ba.proof_files
]
bonus_challenges.append({
"id": ba.id,
"challenge": {
"id": ba.challenge.id,
"title": ba.challenge.title,
"description": ba.challenge.description,
"points": ba.challenge.points,
"difficulty": ba.challenge.difficulty,
"proof_hint": ba.challenge.proof_hint,
},
"status": ba.status,
"proof_url": ba.proof_url,
"proof_image_url": storage_service.get_url(ba.proof_path, "bonus_proofs") if ba.proof_path else None,
2026-01-03 00:12:07 +07:00
"proof_files": bonus_proof_files,
"proof_comment": ba.proof_comment,
"points_earned": ba.points_earned,
"completed_at": ba.completed_at.isoformat() if ba.completed_at else None,
"can_dispute": bonus_can_dispute,
"dispute": build_dispute_response(ba.dispute, current_user.id) if ba.dispute else None,
})
2026-01-03 00:12:07 +07:00
# Build proof files list
from app.schemas.assignment import ProofFileResponse
proof_files_list = [
ProofFileResponse(
id=pf.id,
file_type=pf.file_type,
order_index=pf.order_index,
created_at=pf.created_at,
)
for pf in assignment.proof_files
]
return AssignmentDetailResponse(
id=assignment.id,
challenge=None,
game=GameShort(
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
2026-01-03 00:12:07 +07:00
download_url=game.download_url,
game_type=game.game_type,
),
is_playthrough=True,
playthrough_info={
"description": game.playthrough_description,
"points": game.playthrough_points,
"proof_type": game.playthrough_proof_type,
"proof_hint": game.playthrough_proof_hint,
},
participant=user_to_public(owner_user),
status=assignment.status,
proof_url=assignment.proof_url,
proof_image_url=proof_image_url,
2026-01-03 00:12:07 +07:00
proof_files=proof_files_list,
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,
bonus_challenges=bonus_challenges,
)
# Regular challenge assignment
challenge = assignment.challenge
game = challenge.game
2026-01-03 00:12:07 +07:00
# Build proof files list
from app.schemas.assignment import ProofFileResponse
proof_files_list = [
ProofFileResponse(
id=pf.id,
file_type=pf.file_type,
order_index=pf.order_index,
created_at=pf.created_at,
)
for pf in assignment.proof_files
]
2025-12-16 00:33:50 +07:00
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,
2025-12-16 01:25:21 +07:00
cover_url=storage_service.get_url(game.cover_path, "covers"),
2026-01-03 00:12:07 +07:00
download_url=game.download_url,
2025-12-16 00:33:50 +07:00
),
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,
2026-01-03 00:12:07 +07:00
proof_files=proof_files_list,
2025-12-16 00:33:50 +07:00
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(
2025-12-16 01:25:21 +07:00
assignment_id: int,
request: Request,
2025-12-16 01:25:21 +07:00
current_user: CurrentUser,
db: DbSession,
):
"""Stream the proof media (image or video) for an assignment with Range support"""
2025-12-16 01:25:21 +07:00
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
2025-12-16 01:25:21 +07:00
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Get marathon_id based on assignment type
if assignment.is_playthrough:
marathon_id = assignment.game.marathon_id
else:
marathon_id = assignment.challenge.game.marathon_id
2025-12-16 01:25:21 +07:00
# Check user is participant of the marathon
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")
2025-12-16 01:25:21 +07:00
# 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")
2025-12-16 01:25:21 +07:00
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",
}
)
2025-12-16 01:25:21 +07:00
# For images, just return the content
2025-12-16 01:25:21 +07:00
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.get("/assignments/{assignment_id}/bonus/{bonus_id}/proof-media")
async def get_bonus_proof_media(
assignment_id: int,
bonus_id: int,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Stream the proof media (image or video) for a bonus assignment"""
# Get assignment with bonus assignments
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.game),
selectinload(Assignment.bonus_assignments),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if not assignment.is_playthrough:
raise HTTPException(status_code=400, detail="This is not a playthrough assignment")
# Get marathon_id
marathon_id = assignment.game.marathon_id
# Check user is participant of the marathon
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")
# Find the bonus assignment
bonus_assignment = None
for ba in assignment.bonus_assignments:
if ba.id == bonus_id:
bonus_assignment = ba
break
if not bonus_assignment:
raise HTTPException(status_code=404, detail="Bonus assignment not found")
# Check if proof exists
if not bonus_assignment.proof_path:
raise HTTPException(status_code=404, detail="No proof media for this bonus assignment")
# Get file from storage
file_data = await storage_service.get_file(bonus_assignment.proof_path, "bonus_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:
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
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",
}
)
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",
}
)
2026-01-03 00:12:07 +07:00
@router.get("/assignments/{assignment_id}/proof-files/{proof_file_id}/media")
async def get_assignment_proof_file_media(
assignment_id: int,
proof_file_id: int,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Stream a specific proof file (image or video) for an assignment"""
from app.models import AssignmentProof, Game
# Get proof file
result = await db.execute(
select(AssignmentProof)
.options(
selectinload(AssignmentProof.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(AssignmentProof.assignment).selectinload(Assignment.game), # For playthrough
)
.where(
AssignmentProof.id == proof_file_id,
AssignmentProof.assignment_id == assignment_id,
)
)
proof_file = result.scalar_one_or_none()
if not proof_file:
raise HTTPException(status_code=404, detail="Proof file not found")
assignment = proof_file.assignment
# Get marathon_id based on assignment type
if assignment.is_playthrough:
marathon_id = assignment.game.marathon_id
else:
marathon_id = assignment.challenge.game.marathon_id
# Check user is participant of the marathon
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 file from storage
file_data = await storage_service.get_file(proof_file.file_path, "proofs")
if not file_data:
raise HTTPException(status_code=404, detail="Proof file 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 len(range_match) > 1 and range_match[1] else 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",
}
)
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",
}
)
@router.get("/assignments/{assignment_id}/bonus/{bonus_id}/proof-files/{proof_file_id}/media")
async def get_bonus_proof_file_media(
assignment_id: int,
bonus_id: int,
proof_file_id: int,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Stream a specific proof file (image or video) for a bonus assignment"""
from app.models import BonusAssignmentProof, Game
# Get proof file
result = await db.execute(
select(BonusAssignmentProof)
.options(
selectinload(BonusAssignmentProof.bonus_assignment)
.selectinload(BonusAssignment.main_assignment)
.selectinload(Assignment.game), # For playthrough
)
.where(
BonusAssignmentProof.id == proof_file_id,
BonusAssignmentProof.bonus_assignment_id == bonus_id,
)
)
proof_file = result.scalar_one_or_none()
if not proof_file:
raise HTTPException(status_code=404, detail="Proof file not found")
bonus = proof_file.bonus_assignment
assignment = bonus.main_assignment
# Check assignment matches
if assignment.id != assignment_id:
raise HTTPException(status_code=404, detail="Proof file not found for this assignment")
# Get marathon_id (bonus assignments are always for playthrough)
marathon_id = assignment.game.marathon_id
# Check user is participant of the marathon
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 file from storage
file_data = await storage_service.get_file(proof_file.file_path, "bonus_proofs")
if not file_data:
raise HTTPException(status_code=404, detail="Proof file 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 len(range_match) > 1 and range_match[1] else 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",
}
)
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",
}
)
@router.post("/bonus-assignments/{bonus_id}/dispute", response_model=DisputeResponse)
async def create_bonus_dispute(
bonus_id: int,
data: DisputeCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a dispute against a bonus assignment's proof"""
from app.models import Game
# Get bonus assignment with main assignment
result = await db.execute(
select(BonusAssignment)
.options(
selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
selectinload(BonusAssignment.challenge),
selectinload(BonusAssignment.dispute),
)
.where(BonusAssignment.id == bonus_id)
)
bonus_assignment = result.scalar_one_or_none()
if not bonus_assignment:
raise HTTPException(status_code=404, detail="Bonus assignment not found")
main_assignment = bonus_assignment.main_assignment
marathon_id = main_assignment.game.marathon_id
# Check user is participant of the marathon
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 bonus_assignment.status != BonusAssignmentStatus.COMPLETED.value:
raise HTTPException(status_code=400, detail="Can only dispute completed bonus assignments")
if main_assignment.participant.user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot dispute your own bonus assignment")
# Check for active dispute (open or pending admin decision)
if bonus_assignment.dispute and bonus_assignment.dispute.status in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]:
raise HTTPException(status_code=400, detail="An active dispute already exists for this bonus assignment")
if not bonus_assignment.completed_at:
raise HTTPException(status_code=400, detail="Bonus assignment has no completion date")
time_since_completion = datetime.utcnow() - bonus_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 for bonus assignment
dispute = Dispute(
bonus_assignment_id=bonus_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:
title = f"Бонус: {bonus_assignment.challenge.title}"
await telegram_notifier.notify_dispute_raised(
db,
user_id=main_assignment.participant.user_id,
marathon_title=marathon.title,
challenge_title=title,
assignment_id=main_assignment.id
)
# 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)
2025-12-16 00:33:50 +07:00
@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 (including playthrough)"""
2025-12-16 00:33:50 +07:00
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
2025-12-16 00:33:50 +07:00
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")
# Get marathon_id based on assignment type
if assignment.is_playthrough:
marathon_id = assignment.game.marathon_id
else:
marathon_id = assignment.challenge.game.marathon_id
2025-12-16 00:33:50 +07:00
# Check user is participant of the marathon
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")
# Check for active dispute (open or pending admin decision)
if assignment.dispute and assignment.dispute.status in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]:
raise HTTPException(status_code=400, detail="An active dispute already exists for this assignment")
2025-12-16 00:33:50 +07:00
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)
2025-12-16 20:06:16 +07:00
# Send notification to assignment owner
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
# Get title based on assignment type
if assignment.is_playthrough:
title = f"Прохождение: {assignment.game.title}"
else:
title = assignment.challenge.title
2025-12-16 20:06:16 +07:00
await telegram_notifier.notify_dispute_raised(
db,
user_id=assignment.participant.user_id,
marathon_title=marathon.title,
challenge_title=title,
2025-12-16 22:43:03 +07:00
assignment_id=assignment_id
2025-12-16 20:06:16 +07:00
)
2025-12-16 00:33:50 +07:00
# 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 or bonus assignment
2025-12-16 00:33:50 +07:00
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Dispute.assignment).selectinload(Assignment.game), # For playthrough
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
2025-12-16 00:33:50 +07:00
)
.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
if dispute.bonus_assignment_id:
marathon_id = dispute.bonus_assignment.main_assignment.game.marathon_id
elif dispute.assignment.is_playthrough:
marathon_id = dispute.assignment.game.marathon_id
else:
marathon_id = dispute.assignment.challenge.game.marathon_id
2025-12-16 00:33:50 +07:00
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 or bonus assignment
2025-12-16 00:33:50 +07:00
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Dispute.assignment).selectinload(Assignment.game), # For playthrough
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
2025-12-16 00:33:50 +07:00
)
.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
if dispute.bonus_assignment_id:
marathon_id = dispute.bonus_assignment.main_assignment.game.marathon_id
elif dispute.assignment and dispute.assignment.is_playthrough:
marathon_id = dispute.assignment.game.marathon_id
else:
marathon_id = dispute.assignment.challenge.game.marathon_id
2025-12-16 00:33:50 +07:00
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.game), # For playthrough assignments
2025-12-16 00:33:50 +07:00
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()
response = []
for a in assignments:
if a.is_playthrough:
# Playthrough assignment
response.append(ReturnedAssignmentResponse(
id=a.id,
is_playthrough=True,
game_id=a.game_id,
game_title=a.game.title if a.game else None,
game_cover_url=storage_service.get_url(a.game.cover_path, "covers") if a.game else None,
original_completed_at=a.completed_at,
dispute_reason=a.dispute.reason if a.dispute else "Оспорено",
))
else:
# Challenge assignment
response.append(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"),
2026-01-03 00:12:07 +07:00
download_url=a.challenge.game.download_url,
),
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 "Оспорено",
))
return response
# ============ Bonus Assignments Endpoints ============
@router.get("/assignments/{assignment_id}/bonus", response_model=list[BonusAssignmentResponse])
async def get_bonus_assignments(
assignment_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get bonus assignments for a playthrough assignment"""
# Get assignment with bonus challenges
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.game),
selectinload(Assignment.participant),
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if not assignment.is_playthrough:
raise HTTPException(status_code=400, detail="This is not a playthrough assignment")
# Check user is the owner
if assignment.participant.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only view your own bonus assignments")
# Build response
2025-12-16 00:33:50 +07:00
return [
BonusAssignmentResponse(
id=ba.id,
2025-12-16 00:33:50 +07:00
challenge=ChallengeResponse(
id=ba.challenge.id,
title=ba.challenge.title,
description=ba.challenge.description,
type=ba.challenge.type,
difficulty=ba.challenge.difficulty,
points=ba.challenge.points,
estimated_time=ba.challenge.estimated_time,
proof_type=ba.challenge.proof_type,
proof_hint=ba.challenge.proof_hint,
2025-12-16 00:33:50 +07:00
game=GameShort(
id=assignment.game.id,
title=assignment.game.title,
cover_url=storage_service.get_url(assignment.game.cover_path, "covers") if hasattr(assignment.game, 'cover_path') else None,
2026-01-03 00:12:07 +07:00
download_url=assignment.game.download_url,
game_type=assignment.game.game_type,
2025-12-16 00:33:50 +07:00
),
is_generated=ba.challenge.is_generated,
created_at=ba.challenge.created_at,
2025-12-16 00:33:50 +07:00
),
status=ba.status,
proof_url=ba.proof_url,
proof_comment=ba.proof_comment,
points_earned=ba.points_earned,
completed_at=ba.completed_at,
2025-12-16 00:33:50 +07:00
)
for ba in assignment.bonus_assignments
2025-12-16 00:33:50 +07:00
]
@router.post("/assignments/{assignment_id}/bonus/{bonus_id}/complete", response_model=BonusCompleteResult)
async def complete_bonus_assignment(
assignment_id: int,
bonus_id: int,
current_user: CurrentUser,
db: DbSession,
proof_url: str | None = Form(None),
comment: str | None = Form(None),
2026-01-03 00:12:07 +07:00
proof_file: UploadFile | None = File(None), # Legacy single file support
proof_files: list[UploadFile] = File([]), # Multiple files support
):
"""Complete a bonus challenge for a playthrough assignment"""
from app.core.config import settings
# Get main assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if not assignment.is_playthrough:
raise HTTPException(status_code=400, detail="This is not a playthrough assignment")
# Check user is the owner
if assignment.participant.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only complete your own bonus assignments")
# Check main assignment is active or completed (completed allows re-doing bonus after bonus dispute)
if assignment.status not in [AssignmentStatus.ACTIVE.value, AssignmentStatus.COMPLETED.value]:
raise HTTPException(
status_code=400,
detail="Bonus challenges can only be completed while the main assignment is active or completed"
)
# Find the bonus assignment
bonus_assignment = None
for ba in assignment.bonus_assignments:
if ba.id == bonus_id:
bonus_assignment = ba
break
if not bonus_assignment:
raise HTTPException(status_code=404, detail="Bonus assignment not found")
if bonus_assignment.status == BonusAssignmentStatus.COMPLETED.value:
raise HTTPException(status_code=400, detail="This bonus challenge is already completed")
2026-01-03 00:12:07 +07:00
# Combine legacy single file with new multiple files
all_files = []
if proof_file:
all_files.append(proof_file)
if proof_files:
all_files.extend(proof_files)
# Validate proof (need file(s), URL, or comment)
if not all_files and not proof_url and not comment:
raise HTTPException(
status_code=400,
detail="Необходимо прикрепить файл, ссылку или комментарий"
)
2026-01-03 00:12:07 +07:00
# Handle multiple file uploads
if all_files:
from app.models import BonusAssignmentProof
for idx, file in enumerate(all_files):
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File {file.filename} too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid file type for {file.filename}. Allowed: {settings.ALLOWED_EXTENSIONS}",
)
# Determine file type (image or video)
file_type = "video" if ext in ["mp4", "webm", "mov", "avi"] else "image"
# Upload file to storage
filename = storage_service.generate_filename(f"bonus_{bonus_id}_{idx}", file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="bonus_proofs",
filename=filename,
content_type=file.content_type or "application/octet-stream",
)
2026-01-03 00:12:07 +07:00
# Create BonusAssignmentProof record
proof_record = BonusAssignmentProof(
bonus_assignment_id=bonus_id,
file_path=file_path,
file_type=file_type,
order_index=idx
)
2026-01-03 00:12:07 +07:00
db.add(proof_record)
2026-01-03 00:12:07 +07:00
# Legacy: set proof_path on first file for backward compatibility
if idx == 0:
bonus_assignment.proof_path = file_path
2026-01-03 00:12:07 +07:00
# Set proof URL if provided
if proof_url:
bonus_assignment.proof_url = proof_url
# Complete the bonus assignment
2026-01-05 23:41:22 +07:00
# NOTE: We store BASE points here. Event multiplier is applied when main assignment is completed
# This ensures multiplier is applied to the SUM (base + all bonuses), not separately
bonus_assignment.points_earned = bonus_assignment.challenge.points
bonus_assignment.status = BonusAssignmentStatus.COMPLETED.value
bonus_assignment.proof_comment = comment
bonus_assignment.completed_at = datetime.utcnow()
# If main assignment is already COMPLETED, add bonus points immediately
# This handles the case where a bonus was disputed and user is re-completing it
if assignment.status == AssignmentStatus.COMPLETED.value:
2026-01-05 23:41:22 +07:00
from app.models import EventType
from app.services.points import PointsService
# Apply event multiplier if assignment had one
points_to_add = bonus_assignment.points_earned
if assignment.event_type:
ps = PointsService()
multiplier = ps.EVENT_MULTIPLIERS.get(assignment.event_type, 1.0)
points_to_add = int(bonus_assignment.points_earned * multiplier)
# Update bonus assignment to show multiplied points
bonus_assignment.points_earned = points_to_add
participant = assignment.participant
2026-01-05 23:41:22 +07:00
participant.total_points += points_to_add
assignment.points_earned += points_to_add
# NOTE: If main is not completed yet, points will be added when main is completed
# This prevents exploiting by dropping the main assignment after getting bonus points
await db.commit()
# Calculate total bonus points for this assignment
total_bonus_points = sum(
ba.points_earned for ba in assignment.bonus_assignments
if ba.status == BonusAssignmentStatus.COMPLETED.value
)
return BonusCompleteResult(
bonus_assignment_id=bonus_assignment.id,
points_earned=bonus_assignment.points_earned,
total_bonus_points=total_bonus_points,
)