Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования - Можно повторно оспаривать после разрешённых споров - Исправлены бонусные очки при перепрохождении после оспаривания - Сброс серии при невалидном пруфе - Колесо показывает только доступные игры - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user