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
|
2025-12-29 22:23:34 +03:00
|
|
|
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form
|
2025-12-16 02:01:03 +07:00
|
|
|
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,
|
2025-12-29 22:23:34 +03:00
|
|
|
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,
|
2025-12-29 22:23:34 +03:00
|
|
|
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"""
|
2025-12-29 22:23:34 +03:00
|
|
|
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),
|
2025-12-29 22:23:34 +03:00
|
|
|
selectinload(Assignment.game), # For playthrough
|
2026-01-03 00:12:07 +07:00
|
|
|
selectinload(Assignment.proof_files), # Load multiple proof files
|
2025-12-29 22:23:34 +03:00
|
|
|
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
|
2025-12-29 22:23:34 +03:00
|
|
|
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")
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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
|
2025-12-29 22:23:34 +03:00
|
|
|
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
|
|
|
|
2025-12-29 22:23:34 +03: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
|
|
|
|
|
]
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
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,
|
2025-12-29 22:23:34 +03:00
|
|
|
"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
|
|
|
|
|
]
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
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,
|
2025-12-29 22:23:34 +03:00
|
|
|
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,
|
2025-12-29 22:23:34 +03: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,
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-12-16 02:01:03 +07:00
|
|
|
@router.get("/assignments/{assignment_id}/proof-media")
|
|
|
|
|
async def get_assignment_proof_media(
|
2025-12-16 01:25:21 +07:00
|
|
|
assignment_id: int,
|
2025-12-16 02:01:03 +07:00
|
|
|
request: Request,
|
2025-12-16 01:25:21 +07:00
|
|
|
current_user: CurrentUser,
|
|
|
|
|
db: DbSession,
|
|
|
|
|
):
|
2025-12-16 02:01:03 +07:00
|
|
|
"""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),
|
2025-12-29 22:23:34 +03:00
|
|
|
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")
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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:
|
2025-12-16 02:01:03 +07:00
|
|
|
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:
|
2025-12-16 02:01:03 +07:00
|
|
|
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
|
2025-12-16 02:01:03 +07:00
|
|
|
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
|
|
|
|
2025-12-16 02:01:03 +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",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-12-16 02:01:03 +07:00
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
@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",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
@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,
|
|
|
|
|
):
|
2025-12-29 22:23:34 +03:00
|
|
|
"""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),
|
2025-12-29 22:23:34 +03:00
|
|
|
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")
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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")
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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:
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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,
|
2025-12-29 22:23:34 +03:00
|
|
|
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"""
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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),
|
2025-12-29 22:23:34 +03:00
|
|
|
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
|
2025-12-29 22:23:34 +03:00
|
|
|
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)"""
|
2025-12-29 22:23:34 +03:00
|
|
|
# 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),
|
2025-12-29 22:23:34 +03:00
|
|
|
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
|
2025-12-29 22:23:34 +03:00
|
|
|
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),
|
2025-12-29 22:23:34 +03:00
|
|
|
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()
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
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,
|
2025-12-29 22:23:34 +03:00
|
|
|
),
|
|
|
|
|
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 [
|
2025-12-29 22:23:34 +03:00
|
|
|
BonusAssignmentResponse(
|
|
|
|
|
id=ba.id,
|
2025-12-16 00:33:50 +07:00
|
|
|
challenge=ChallengeResponse(
|
2025-12-29 22:23:34 +03:00
|
|
|
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(
|
2025-12-29 22:23:34 +03:00
|
|
|
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,
|
2025-12-29 22:23:34 +03:00
|
|
|
game_type=assignment.game.game_type,
|
2025-12-16 00:33:50 +07:00
|
|
|
),
|
2025-12-29 22:23:34 +03:00
|
|
|
is_generated=ba.challenge.is_generated,
|
|
|
|
|
created_at=ba.challenge.created_at,
|
2025-12-16 00:33:50 +07:00
|
|
|
),
|
2025-12-29 22:23:34 +03: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
|
|
|
)
|
2025-12-29 22:23:34 +03:00
|
|
|
for ba in assignment.bonus_assignments
|
2025-12-16 00:33:50 +07:00
|
|
|
]
|
2025-12-29 22:23:34 +03: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
|
2025-12-29 22:23:34 +03:00
|
|
|
):
|
|
|
|
|
"""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:
|
2025-12-29 22:23:34 +03:00
|
|
|
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",
|
2025-12-29 22:23:34 +03:00
|
|
|
)
|
|
|
|
|
|
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
|
2025-12-29 22:23:34 +03:00
|
|
|
)
|
2026-01-03 00:12:07 +07:00
|
|
|
db.add(proof_record)
|
2025-12-29 22:23:34 +03:00
|
|
|
|
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
|
2025-12-29 22:23:34 +03:00
|
|
|
|
2026-01-03 00:12:07 +07:00
|
|
|
# Set proof URL if provided
|
|
|
|
|
if proof_url:
|
2025-12-29 22:23:34 +03:00
|
|
|
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
|
2025-12-29 22:23:34 +03:00
|
|
|
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
|
|
|
|
|
|
2025-12-29 22:23:34 +03:00
|
|
|
participant = assignment.participant
|
2026-01-05 23:41:22 +07:00
|
|
|
participant.total_points += points_to_add
|
|
|
|
|
assignment.points_earned += points_to_add
|
2025-12-29 22:23:34 +03:00
|
|
|
|
|
|
|
|
# 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,
|
|
|
|
|
)
|