Улучшение системы оспариваний и исправления

- Оспаривания теперь требуют решения админа после 24ч голосования
  - Можно повторно оспаривать после разрешённых споров
  - Исправлены бонусные очки при перепрохождении после оспаривания
  - Сброс серии при невалидном пруфе
  - Колесо показывает только доступные игры
  - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
2025-12-29 22:23:34 +03:00
parent 1cedfeb3ee
commit 89dbe2c018
42 changed files with 5426 additions and 313 deletions

View File

@@ -2,7 +2,7 @@
Assignment details and dispute system endpoints.
"""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form
from fastapi.responses import Response, StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@@ -10,12 +10,13 @@ from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import (
Assignment, AssignmentStatus, Participant, Challenge, User, Marathon,
Dispute, DisputeStatus, DisputeComment, DisputeVote,
Dispute, DisputeStatus, DisputeComment, DisputeVote, BonusAssignment, BonusAssignmentStatus,
)
from app.schemas import (
AssignmentDetailResponse, DisputeCreate, DisputeResponse,
DisputeCommentCreate, DisputeCommentResponse, DisputeVoteCreate,
MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse,
BonusAssignmentResponse, CompleteBonusAssignment, BonusCompleteResult,
)
from app.schemas.user import UserPublic
from app.services.storage import storage_service
@@ -92,11 +93,18 @@ async def get_assignment_detail(
db: DbSession,
):
"""Get detailed information about an assignment including proofs and dispute"""
from app.models import Game
# Get assignment with all relationships
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough
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),
selectinload(Assignment.participant).selectinload(Participant.user),
selectinload(Assignment.dispute).selectinload(Dispute.raised_by),
selectinload(Assignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user),
@@ -109,8 +117,13 @@ async def get_assignment_detail(
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
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
@@ -121,18 +134,20 @@ async def get_assignment_detail(
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Build response
challenge = assignment.challenge
game = challenge.game
owner_user = assignment.participant.user
# Determine if user can dispute
# 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]
)
can_dispute = False
if (
assignment.status == AssignmentStatus.COMPLETED.value
and assignment.completed_at
and assignment.participant.user_id != current_user.id
and assignment.dispute is None
and not has_active_dispute
):
time_since_completion = datetime.utcnow() - assignment.completed_at
can_dispute = time_since_completion < timedelta(hours=DISPUTE_WINDOW_HOURS)
@@ -140,6 +155,81 @@ async def get_assignment_detail(
# Build proof URLs
proof_image_url = storage_service.get_url(assignment.proof_path, "proofs")
# 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)
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,
"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,
})
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"),
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,
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
return AssignmentDetailResponse(
id=assignment.id,
challenge=ChallengeResponse(
@@ -187,6 +277,7 @@ async def get_assignment_proof_media(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
)
.where(Assignment.id == assignment_id)
)
@@ -195,8 +286,13 @@ async def get_assignment_proof_media(
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
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
@@ -283,6 +379,214 @@ async def get_assignment_proof_image(
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",
}
)
@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)
@router.post("/assignments/{assignment_id}/dispute", response_model=DisputeResponse)
async def create_dispute(
assignment_id: int,
@@ -290,12 +594,13 @@ async def create_dispute(
current_user: CurrentUser,
db: DbSession,
):
"""Create a dispute against an assignment's proof"""
"""Create a dispute against an assignment's proof (including playthrough)"""
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.participant),
selectinload(Assignment.dispute),
)
@@ -306,8 +611,13 @@ async def create_dispute(
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
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
@@ -325,8 +635,9 @@ async def create_dispute(
if assignment.participant.user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot dispute your own assignment")
if assignment.dispute:
raise HTTPException(status_code=400, detail="A dispute already exists for this assignment")
# 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")
if not assignment.completed_at:
raise HTTPException(status_code=400, detail="Assignment has no completion date")
@@ -350,11 +661,17 @@ async def create_dispute(
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
await telegram_notifier.notify_dispute_raised(
db,
user_id=assignment.participant.user_id,
marathon_title=marathon.title,
challenge_title=assignment.challenge.title,
challenge_title=title,
assignment_id=assignment_id
)
@@ -381,11 +698,13 @@ async def add_dispute_comment(
db: DbSession,
):
"""Add a comment to a dispute discussion"""
# Get dispute with assignment
# Get dispute with assignment or bonus assignment
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),
)
.where(Dispute.id == dispute_id)
)
@@ -398,7 +717,12 @@ async def add_dispute_comment(
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Check user is participant of the marathon
marathon_id = dispute.assignment.challenge.game.marathon_id
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
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
@@ -439,11 +763,13 @@ async def vote_on_dispute(
db: DbSession,
):
"""Vote on a dispute (True = valid/proof is OK, False = invalid/proof is not OK)"""
# Get dispute with assignment
# Get dispute with assignment or bonus assignment
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),
)
.where(Dispute.id == dispute_id)
)
@@ -456,7 +782,12 @@ async def vote_on_dispute(
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Check user is participant of the marathon
marathon_id = dispute.assignment.challenge.game.marathon_id
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
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
@@ -518,6 +849,7 @@ async def get_returned_assignments(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough assignments
selectinload(Assignment.dispute),
)
.where(
@@ -528,29 +860,228 @@ async def get_returned_assignments(
)
assignments = result.scalars().all()
return [
ReturnedAssignmentResponse(
id=a.id,
challenge=ChallengeResponse(
id=a.challenge.id,
title=a.challenge.title,
description=a.challenge.description,
type=a.challenge.type,
difficulty=a.challenge.difficulty,
points=a.challenge.points,
estimated_time=a.challenge.estimated_time,
proof_type=a.challenge.proof_type,
proof_hint=a.challenge.proof_hint,
game=GameShort(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=storage_service.get_url(a.challenge.game.cover_path, "covers"),
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"),
),
is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at,
),
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 "Оспорено",
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),
)
for a in 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")
# 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
return [
BonusAssignmentResponse(
id=ba.id,
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,
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,
game_type=assignment.game.game_type,
),
is_generated=ba.challenge.is_generated,
created_at=ba.challenge.created_at,
),
status=ba.status,
proof_url=ba.proof_url,
proof_comment=ba.proof_comment,
points_earned=ba.points_earned,
completed_at=ba.completed_at,
)
for ba in assignment.bonus_assignments
]
@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),
proof_file: UploadFile | None = File(None),
):
"""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")
# Validate proof (need file, URL, or comment)
if not proof_file and not proof_url and not comment:
raise HTTPException(
status_code=400,
detail="Необходимо прикрепить файл, ссылку или комментарий"
)
# Handle file upload
if proof_file:
contents = await proof_file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
)
# Upload file to storage
filename = storage_service.generate_filename(bonus_id, proof_file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="bonus_proofs",
filename=filename,
content_type=proof_file.content_type or "application/octet-stream",
)
bonus_assignment.proof_path = file_path
else:
bonus_assignment.proof_url = proof_url
# Complete the bonus assignment
bonus_assignment.status = BonusAssignmentStatus.COMPLETED.value
bonus_assignment.proof_comment = comment
bonus_assignment.points_earned = bonus_assignment.challenge.points
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:
participant = assignment.participant
participant.total_points += bonus_assignment.points_earned
assignment.points_earned += bonus_assignment.points_earned
# 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,
)