Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования - Можно повторно оспаривать после разрешённых споров - Исправлены бонусные очки при перепрохождении после оспаривания - Сброс серии при невалидном пруфе - Колесо показывает только доступные игры - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
@@ -5,7 +5,10 @@ from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
||||
from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent
|
||||
from app.models import (
|
||||
User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent,
|
||||
Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus
|
||||
)
|
||||
from app.schemas import (
|
||||
UserPublic, MessageResponse,
|
||||
AdminUserResponse, BanUserRequest, AdminResetPasswordRequest, AdminLogResponse, AdminLogsListResponse,
|
||||
@@ -837,3 +840,273 @@ async def get_dashboard(current_user: CurrentUser, db: DbSession):
|
||||
for log in recent_logs
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ============ Disputes Management ============
|
||||
|
||||
class AdminDisputeResponse(BaseModel):
|
||||
id: int
|
||||
assignment_id: int | None
|
||||
bonus_assignment_id: int | None
|
||||
marathon_id: int
|
||||
marathon_title: str
|
||||
challenge_title: str
|
||||
participant_nickname: str
|
||||
raised_by_nickname: str
|
||||
reason: str
|
||||
status: str
|
||||
votes_valid: int
|
||||
votes_invalid: int
|
||||
created_at: str
|
||||
expires_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ResolveDisputeRequest(BaseModel):
|
||||
is_valid: bool = Field(..., description="True = proof is valid, False = proof is invalid")
|
||||
|
||||
|
||||
@router.get("/disputes", response_model=list[AdminDisputeResponse])
|
||||
async def list_disputes(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
status: str = Query("pending", pattern="^(open|pending|all)$"),
|
||||
):
|
||||
"""List all disputes. Admin only.
|
||||
|
||||
Status filter:
|
||||
- pending: disputes waiting for admin decision (default)
|
||||
- open: disputes still in voting phase
|
||||
- all: all disputes
|
||||
"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
from datetime import timedelta
|
||||
DISPUTE_WINDOW_HOURS = 24
|
||||
|
||||
query = (
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.raised_by),
|
||||
selectinload(Dispute.votes),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||
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.participant).selectinload(Participant.user),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.order_by(Dispute.created_at.desc())
|
||||
)
|
||||
|
||||
if status == "pending":
|
||||
# Disputes waiting for admin decision
|
||||
query = query.where(Dispute.status == DisputeStatus.PENDING_ADMIN.value)
|
||||
elif status == "open":
|
||||
# Disputes still in voting phase
|
||||
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
|
||||
|
||||
result = await db.execute(query)
|
||||
disputes = result.scalars().all()
|
||||
|
||||
response = []
|
||||
for dispute in disputes:
|
||||
# Get info based on dispute type
|
||||
if dispute.bonus_assignment_id:
|
||||
bonus = dispute.bonus_assignment
|
||||
main_assignment = bonus.main_assignment
|
||||
participant = main_assignment.participant
|
||||
challenge_title = f"Бонус: {bonus.challenge.title}"
|
||||
marathon_id = main_assignment.game.marathon_id
|
||||
|
||||
# Get marathon title
|
||||
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = marathon_result.scalar_one_or_none()
|
||||
marathon_title = marathon.title if marathon else "Unknown"
|
||||
else:
|
||||
assignment = dispute.assignment
|
||||
participant = assignment.participant
|
||||
if assignment.is_playthrough:
|
||||
challenge_title = f"Прохождение: {assignment.game.title}"
|
||||
marathon_id = assignment.game.marathon_id
|
||||
else:
|
||||
challenge_title = assignment.challenge.title
|
||||
marathon_id = assignment.challenge.game.marathon_id
|
||||
|
||||
# Get marathon title
|
||||
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = marathon_result.scalar_one_or_none()
|
||||
marathon_title = marathon.title if marathon else "Unknown"
|
||||
|
||||
# Count votes
|
||||
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)
|
||||
|
||||
# Calculate expiry
|
||||
expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||
|
||||
response.append(AdminDisputeResponse(
|
||||
id=dispute.id,
|
||||
assignment_id=dispute.assignment_id,
|
||||
bonus_assignment_id=dispute.bonus_assignment_id,
|
||||
marathon_id=marathon_id,
|
||||
marathon_title=marathon_title,
|
||||
challenge_title=challenge_title,
|
||||
participant_nickname=participant.user.nickname,
|
||||
raised_by_nickname=dispute.raised_by.nickname,
|
||||
reason=dispute.reason,
|
||||
status=dispute.status,
|
||||
votes_valid=votes_valid,
|
||||
votes_invalid=votes_invalid,
|
||||
created_at=dispute.created_at.isoformat(),
|
||||
expires_at=expires_at.isoformat(),
|
||||
))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/disputes/{dispute_id}/resolve", response_model=MessageResponse)
|
||||
async def resolve_dispute(
|
||||
request: Request,
|
||||
dispute_id: int,
|
||||
data: ResolveDisputeRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Manually resolve a dispute. Admin only."""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Get dispute
|
||||
result = await db.execute(
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.where(Dispute.id == dispute_id)
|
||||
)
|
||||
dispute = result.scalar_one_or_none()
|
||||
|
||||
if not dispute:
|
||||
raise HTTPException(status_code=404, detail="Dispute not found")
|
||||
|
||||
# Allow resolving disputes that are either open or pending admin decision
|
||||
if dispute.status not in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]:
|
||||
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||
|
||||
# Determine result
|
||||
if data.is_valid:
|
||||
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||
action_type = AdminActionType.DISPUTE_RESOLVE_VALID.value
|
||||
else:
|
||||
result_status = DisputeStatus.RESOLVED_INVALID.value
|
||||
action_type = AdminActionType.DISPUTE_RESOLVE_INVALID.value
|
||||
|
||||
# Handle invalid proof
|
||||
if dispute.bonus_assignment_id:
|
||||
# Reset bonus assignment
|
||||
bonus = dispute.bonus_assignment
|
||||
main_assignment = bonus.main_assignment
|
||||
participant = main_assignment.participant
|
||||
|
||||
# Only subtract points if main playthrough was already completed
|
||||
# (bonus points are added only when main playthrough is completed)
|
||||
if main_assignment.status == AssignmentStatus.COMPLETED.value:
|
||||
points_to_subtract = bonus.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
# Also reduce the points_earned on the main assignment
|
||||
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
|
||||
|
||||
bonus.status = BonusAssignmentStatus.PENDING.value
|
||||
bonus.proof_path = None
|
||||
bonus.proof_url = None
|
||||
bonus.proof_comment = None
|
||||
bonus.points_earned = 0
|
||||
bonus.completed_at = None
|
||||
else:
|
||||
# Reset main assignment
|
||||
assignment = dispute.assignment
|
||||
participant = assignment.participant
|
||||
|
||||
# Subtract points
|
||||
points_to_subtract = assignment.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
|
||||
# Reset streak - the completion was invalid
|
||||
participant.current_streak = 0
|
||||
|
||||
# Reset assignment
|
||||
assignment.status = AssignmentStatus.RETURNED.value
|
||||
assignment.points_earned = 0
|
||||
|
||||
# For playthrough: reset all bonus assignments
|
||||
if assignment.is_playthrough:
|
||||
bonus_result = await db.execute(
|
||||
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
|
||||
)
|
||||
for ba in bonus_result.scalars().all():
|
||||
ba.status = BonusAssignmentStatus.PENDING.value
|
||||
ba.proof_path = None
|
||||
ba.proof_url = None
|
||||
ba.proof_comment = None
|
||||
ba.points_earned = 0
|
||||
ba.completed_at = None
|
||||
|
||||
# Update dispute
|
||||
dispute.status = result_status
|
||||
dispute.resolved_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Get details for logging
|
||||
if dispute.bonus_assignment_id:
|
||||
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
|
||||
marathon_id = dispute.bonus_assignment.main_assignment.game.marathon_id
|
||||
elif dispute.assignment.is_playthrough:
|
||||
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
|
||||
marathon_id = dispute.assignment.game.marathon_id
|
||||
else:
|
||||
challenge_title = dispute.assignment.challenge.title
|
||||
marathon_id = dispute.assignment.challenge.game.marathon_id
|
||||
|
||||
# Log action
|
||||
await log_admin_action(
|
||||
db, current_user.id, action_type,
|
||||
"dispute", dispute_id,
|
||||
{
|
||||
"challenge_title": challenge_title,
|
||||
"marathon_id": marathon_id,
|
||||
"is_valid": data.is_valid,
|
||||
},
|
||||
request.client.host if request.client else None
|
||||
)
|
||||
|
||||
# Send notification
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
if dispute.bonus_assignment_id:
|
||||
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
|
||||
else:
|
||||
participant_user_id = dispute.assignment.participant.user_id
|
||||
|
||||
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = marathon_result.scalar_one_or_none()
|
||||
|
||||
if marathon:
|
||||
await telegram_notifier.notify_dispute_resolved(
|
||||
db,
|
||||
user_id=participant_user_id,
|
||||
marathon_title=marathon.title,
|
||||
challenge_title=challenge_title,
|
||||
is_valid=data.is_valid
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -7,8 +7,12 @@ from app.api.deps import (
|
||||
require_participant, require_organizer, get_participant,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
|
||||
from app.models import (
|
||||
Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType,
|
||||
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant
|
||||
)
|
||||
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
||||
from app.schemas.assignment import AvailableGamesCount
|
||||
from app.services.storage import storage_service
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
@@ -43,6 +47,12 @@ def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
|
||||
approved_by=UserPublic.model_validate(game.approved_by) if game.approved_by else None,
|
||||
challenges_count=challenges_count,
|
||||
created_at=game.created_at,
|
||||
# Поля для типа игры
|
||||
game_type=game.game_type,
|
||||
playthrough_points=game.playthrough_points,
|
||||
playthrough_description=game.playthrough_description,
|
||||
playthrough_proof_type=game.playthrough_proof_type,
|
||||
playthrough_proof_hint=game.playthrough_proof_hint,
|
||||
)
|
||||
|
||||
|
||||
@@ -145,6 +155,12 @@ async def add_game(
|
||||
proposed_by_id=current_user.id,
|
||||
status=game_status,
|
||||
approved_by_id=current_user.id if is_organizer else None,
|
||||
# Поля для типа игры
|
||||
game_type=data.game_type.value,
|
||||
playthrough_points=data.playthrough_points,
|
||||
playthrough_description=data.playthrough_description,
|
||||
playthrough_proof_type=data.playthrough_proof_type.value if data.playthrough_proof_type else None,
|
||||
playthrough_proof_hint=data.playthrough_proof_hint,
|
||||
)
|
||||
db.add(game)
|
||||
|
||||
@@ -171,6 +187,12 @@ async def add_game(
|
||||
approved_by=UserPublic.model_validate(current_user) if is_organizer else None,
|
||||
challenges_count=0,
|
||||
created_at=game.created_at,
|
||||
# Поля для типа игры
|
||||
game_type=game.game_type,
|
||||
playthrough_points=game.playthrough_points,
|
||||
playthrough_description=game.playthrough_description,
|
||||
playthrough_proof_type=game.playthrough_proof_type,
|
||||
playthrough_proof_hint=game.playthrough_proof_hint,
|
||||
)
|
||||
|
||||
|
||||
@@ -227,6 +249,18 @@ async def update_game(
|
||||
if data.genre is not None:
|
||||
game.genre = data.genre
|
||||
|
||||
# Поля для типа игры
|
||||
if data.game_type is not None:
|
||||
game.game_type = data.game_type.value
|
||||
if data.playthrough_points is not None:
|
||||
game.playthrough_points = data.playthrough_points
|
||||
if data.playthrough_description is not None:
|
||||
game.playthrough_description = data.playthrough_description
|
||||
if data.playthrough_proof_type is not None:
|
||||
game.playthrough_proof_type = data.playthrough_proof_type.value
|
||||
if data.playthrough_proof_hint is not None:
|
||||
game.playthrough_proof_hint = data.playthrough_proof_hint
|
||||
|
||||
await db.commit()
|
||||
|
||||
return await get_game(game_id, current_user, db)
|
||||
@@ -398,3 +432,159 @@ async def upload_cover(
|
||||
await db.commit()
|
||||
|
||||
return await get_game(game_id, current_user, db)
|
||||
|
||||
|
||||
async def get_available_games_for_participant(
|
||||
db, participant: Participant, marathon_id: int
|
||||
) -> tuple[list[Game], int]:
|
||||
"""
|
||||
Получить список игр, доступных для спина участника.
|
||||
|
||||
Возвращает кортеж (доступные игры, всего игр).
|
||||
|
||||
Логика исключения:
|
||||
- playthrough: игра исключается если участник завершил ИЛИ дропнул прохождение
|
||||
- challenges: игра исключается если участник выполнил ВСЕ челленджи
|
||||
"""
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
# Получаем все одобренные игры с челленджами
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.options(selectinload(Game.challenges))
|
||||
.where(
|
||||
Game.marathon_id == marathon_id,
|
||||
Game.status == GameStatus.APPROVED.value
|
||||
)
|
||||
)
|
||||
all_games = list(result.scalars().all())
|
||||
|
||||
# Фильтруем игры с челленджами (для типа challenges)
|
||||
# или игры с заполненными playthrough полями (для типа playthrough)
|
||||
games_with_content = []
|
||||
for game in all_games:
|
||||
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||
# Для playthrough не нужны челленджи
|
||||
if game.playthrough_points and game.playthrough_description:
|
||||
games_with_content.append(game)
|
||||
else:
|
||||
# Для challenges нужны челленджи
|
||||
if game.challenges:
|
||||
games_with_content.append(game)
|
||||
|
||||
total_games = len(games_with_content)
|
||||
if total_games == 0:
|
||||
return [], 0
|
||||
|
||||
# Получаем завершённые/дропнутые assignments участника
|
||||
finished_statuses = [AssignmentStatus.COMPLETED.value, AssignmentStatus.DROPPED.value]
|
||||
|
||||
# Для playthrough: получаем game_id завершённых/дропнутых прохождений
|
||||
playthrough_result = await db.execute(
|
||||
select(Assignment.game_id)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.is_playthrough == True,
|
||||
Assignment.status.in_(finished_statuses)
|
||||
)
|
||||
)
|
||||
finished_playthrough_game_ids = set(playthrough_result.scalars().all())
|
||||
|
||||
# Для challenges: получаем challenge_id завершённых заданий
|
||||
challenges_result = await db.execute(
|
||||
select(Assignment.challenge_id)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.is_playthrough == False,
|
||||
Assignment.status == AssignmentStatus.COMPLETED.value
|
||||
)
|
||||
)
|
||||
completed_challenge_ids = set(challenges_result.scalars().all())
|
||||
|
||||
# Фильтруем доступные игры
|
||||
available_games = []
|
||||
for game in games_with_content:
|
||||
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||
# Исключаем если игра уже завершена/дропнута
|
||||
if game.id not in finished_playthrough_game_ids:
|
||||
available_games.append(game)
|
||||
else:
|
||||
# Для challenges: исключаем если все челленджи выполнены
|
||||
game_challenge_ids = {c.id for c in game.challenges}
|
||||
if not game_challenge_ids.issubset(completed_challenge_ids):
|
||||
available_games.append(game)
|
||||
|
||||
return available_games, total_games
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/available-games-count", response_model=AvailableGamesCount)
|
||||
async def get_available_games_count(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""
|
||||
Получить количество игр, доступных для спина.
|
||||
|
||||
Возвращает { available: X, total: Y } где:
|
||||
- available: количество игр, которые могут выпасть
|
||||
- total: общее количество игр в марафоне
|
||||
"""
|
||||
participant = await get_participant(db, current_user.id, marathon_id)
|
||||
if not participant:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
available_games, total_games = await get_available_games_for_participant(
|
||||
db, participant, marathon_id
|
||||
)
|
||||
|
||||
return AvailableGamesCount(
|
||||
available=len(available_games),
|
||||
total=total_games
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/available-games", response_model=list[GameResponse])
|
||||
async def get_available_games(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""
|
||||
Получить список игр, доступных для спина.
|
||||
|
||||
Возвращает только те игры, которые могут выпасть участнику:
|
||||
- Для playthrough: исключаются игры которые уже завершены/дропнуты
|
||||
- Для challenges: исключаются игры где все челленджи выполнены
|
||||
"""
|
||||
participant = await get_participant(db, current_user.id, marathon_id)
|
||||
if not participant:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
available_games, _ = await get_available_games_for_participant(
|
||||
db, participant, marathon_id
|
||||
)
|
||||
|
||||
# Convert to response with challenges count
|
||||
result = []
|
||||
for game in available_games:
|
||||
challenges_count = len(game.challenges) if game.challenges else 0
|
||||
result.append(GameResponse(
|
||||
id=game.id,
|
||||
title=game.title,
|
||||
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||
download_url=game.download_url,
|
||||
genre=game.genre,
|
||||
status=game.status,
|
||||
proposed_by=None,
|
||||
approved_by=None,
|
||||
challenges_count=challenges_count,
|
||||
created_at=game.created_at,
|
||||
game_type=game.game_type,
|
||||
playthrough_points=game.playthrough_points,
|
||||
playthrough_description=game.playthrough_description,
|
||||
playthrough_proof_type=game.playthrough_proof_type,
|
||||
playthrough_proof_hint=game.playthrough_proof_hint,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
@@ -20,6 +20,7 @@ optional_auth = HTTPBearer(auto_error=False)
|
||||
from app.models import (
|
||||
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus,
|
||||
)
|
||||
from app.schemas import (
|
||||
MarathonCreate,
|
||||
@@ -703,3 +704,260 @@ async def delete_marathon_cover(
|
||||
await db.commit()
|
||||
|
||||
return await get_marathon(marathon_id, current_user, db)
|
||||
|
||||
|
||||
# ============ Marathon Disputes (for organizers) ============
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class MarathonDisputeResponse(BaseModel):
|
||||
id: int
|
||||
assignment_id: int | None
|
||||
bonus_assignment_id: int | None
|
||||
challenge_title: str
|
||||
participant_nickname: str
|
||||
raised_by_nickname: str
|
||||
reason: str
|
||||
status: str
|
||||
votes_valid: int
|
||||
votes_invalid: int
|
||||
created_at: str
|
||||
expires_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ResolveDisputeRequest(BaseModel):
|
||||
is_valid: bool = Field(..., description="True = proof is valid, False = proof is invalid")
|
||||
|
||||
|
||||
@router.get("/{marathon_id}/disputes", response_model=list[MarathonDisputeResponse])
|
||||
async def list_marathon_disputes(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
status_filter: str = "open",
|
||||
):
|
||||
"""List disputes in a marathon. Organizers only."""
|
||||
await require_organizer(db, current_user, marathon_id)
|
||||
marathon = await get_marathon_or_404(db, marathon_id)
|
||||
|
||||
from datetime import timedelta
|
||||
DISPUTE_WINDOW_HOURS = 24
|
||||
|
||||
# Get all assignments in this marathon (through games)
|
||||
games_result = await db.execute(
|
||||
select(Game.id).where(Game.marathon_id == marathon_id)
|
||||
)
|
||||
game_ids = [g[0] for g in games_result.all()]
|
||||
|
||||
if not game_ids:
|
||||
return []
|
||||
|
||||
# Get disputes for assignments in these games
|
||||
# Using selectinload for eager loading - no explicit joins needed
|
||||
query = (
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.raised_by),
|
||||
selectinload(Dispute.votes),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.challenge),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.order_by(Dispute.created_at.desc())
|
||||
)
|
||||
|
||||
if status_filter == "open":
|
||||
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
|
||||
|
||||
result = await db.execute(query)
|
||||
all_disputes = result.scalars().unique().all()
|
||||
|
||||
# Filter disputes that belong to this marathon's games
|
||||
response = []
|
||||
for dispute in all_disputes:
|
||||
# Check if dispute belongs to this marathon
|
||||
if dispute.bonus_assignment_id:
|
||||
bonus = dispute.bonus_assignment
|
||||
if not bonus or not bonus.main_assignment:
|
||||
continue
|
||||
if bonus.main_assignment.game_id not in game_ids:
|
||||
continue
|
||||
participant = bonus.main_assignment.participant
|
||||
challenge_title = f"Бонус: {bonus.challenge.title}"
|
||||
else:
|
||||
assignment = dispute.assignment
|
||||
if not assignment:
|
||||
continue
|
||||
if assignment.is_playthrough:
|
||||
if assignment.game_id not in game_ids:
|
||||
continue
|
||||
challenge_title = f"Прохождение: {assignment.game.title}"
|
||||
else:
|
||||
if not assignment.challenge or assignment.challenge.game_id not in game_ids:
|
||||
continue
|
||||
challenge_title = assignment.challenge.title
|
||||
participant = assignment.participant
|
||||
|
||||
# Count votes
|
||||
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)
|
||||
|
||||
# Calculate expiry
|
||||
expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||
|
||||
response.append(MarathonDisputeResponse(
|
||||
id=dispute.id,
|
||||
assignment_id=dispute.assignment_id,
|
||||
bonus_assignment_id=dispute.bonus_assignment_id,
|
||||
challenge_title=challenge_title,
|
||||
participant_nickname=participant.user.nickname,
|
||||
raised_by_nickname=dispute.raised_by.nickname,
|
||||
reason=dispute.reason,
|
||||
status=dispute.status,
|
||||
votes_valid=votes_valid,
|
||||
votes_invalid=votes_invalid,
|
||||
created_at=dispute.created_at.isoformat(),
|
||||
expires_at=expires_at.isoformat(),
|
||||
))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{marathon_id}/disputes/{dispute_id}/resolve", response_model=MessageResponse)
|
||||
async def resolve_marathon_dispute(
|
||||
marathon_id: int,
|
||||
dispute_id: int,
|
||||
data: ResolveDisputeRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Manually resolve a dispute in a marathon. Organizers only."""
|
||||
await require_organizer(db, current_user, marathon_id)
|
||||
marathon = await get_marathon_or_404(db, marathon_id)
|
||||
|
||||
# Get dispute
|
||||
result = await db.execute(
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.where(Dispute.id == dispute_id)
|
||||
)
|
||||
dispute = result.scalar_one_or_none()
|
||||
|
||||
if not dispute:
|
||||
raise HTTPException(status_code=404, detail="Dispute not found")
|
||||
|
||||
# Verify dispute belongs to this marathon
|
||||
if dispute.bonus_assignment_id:
|
||||
bonus = dispute.bonus_assignment
|
||||
if bonus.main_assignment.game.marathon_id != marathon_id:
|
||||
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||
else:
|
||||
assignment = dispute.assignment
|
||||
if assignment.is_playthrough:
|
||||
if assignment.game.marathon_id != marathon_id:
|
||||
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||
else:
|
||||
if assignment.challenge.game.marathon_id != marathon_id:
|
||||
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||
|
||||
if dispute.status != DisputeStatus.OPEN.value:
|
||||
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||
|
||||
# Determine result
|
||||
if data.is_valid:
|
||||
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||
else:
|
||||
result_status = DisputeStatus.RESOLVED_INVALID.value
|
||||
|
||||
# Handle invalid proof
|
||||
if dispute.bonus_assignment_id:
|
||||
# Reset bonus assignment
|
||||
bonus = dispute.bonus_assignment
|
||||
main_assignment = bonus.main_assignment
|
||||
participant = main_assignment.participant
|
||||
|
||||
# Only subtract points if main playthrough was already completed
|
||||
# (bonus points are added only when main playthrough is completed)
|
||||
if main_assignment.status == AssignmentStatus.COMPLETED.value:
|
||||
points_to_subtract = bonus.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
# Also reduce the points_earned on the main assignment
|
||||
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
|
||||
|
||||
bonus.status = BonusAssignmentStatus.PENDING.value
|
||||
bonus.proof_path = None
|
||||
bonus.proof_url = None
|
||||
bonus.proof_comment = None
|
||||
bonus.points_earned = 0
|
||||
bonus.completed_at = None
|
||||
else:
|
||||
# Reset main assignment
|
||||
assignment = dispute.assignment
|
||||
participant = assignment.participant
|
||||
|
||||
# Subtract points
|
||||
points_to_subtract = assignment.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
|
||||
# Reset streak - the completion was invalid
|
||||
participant.current_streak = 0
|
||||
|
||||
# Reset assignment
|
||||
assignment.status = AssignmentStatus.RETURNED.value
|
||||
assignment.points_earned = 0
|
||||
|
||||
# For playthrough: reset all bonus assignments
|
||||
if assignment.is_playthrough:
|
||||
bonus_result = await db.execute(
|
||||
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
|
||||
)
|
||||
for ba in bonus_result.scalars().all():
|
||||
ba.status = BonusAssignmentStatus.PENDING.value
|
||||
ba.proof_path = None
|
||||
ba.proof_url = None
|
||||
ba.proof_comment = None
|
||||
ba.points_earned = 0
|
||||
ba.completed_at = None
|
||||
|
||||
# Update dispute
|
||||
dispute.status = result_status
|
||||
dispute.resolved_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send notification
|
||||
if dispute.bonus_assignment_id:
|
||||
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
|
||||
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
|
||||
elif dispute.assignment.is_playthrough:
|
||||
participant_user_id = dispute.assignment.participant.user_id
|
||||
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
|
||||
else:
|
||||
participant_user_id = dispute.assignment.participant.user_id
|
||||
challenge_title = dispute.assignment.challenge.title
|
||||
|
||||
await telegram_notifier.notify_dispute_resolved(
|
||||
db,
|
||||
user_id=participant_user_id,
|
||||
marathon_title=marathon.title,
|
||||
challenge_title=challenge_title,
|
||||
is_valid=data.is_valid
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
||||
)
|
||||
|
||||
@@ -9,15 +9,18 @@ from app.core.config import settings
|
||||
from app.models import (
|
||||
Marathon, MarathonStatus, Game, Challenge, Participant,
|
||||
Assignment, AssignmentStatus, Activity, ActivityType,
|
||||
EventType, Difficulty, User
|
||||
EventType, Difficulty, User, BonusAssignment, BonusAssignmentStatus, GameType,
|
||||
DisputeStatus,
|
||||
)
|
||||
from app.schemas import (
|
||||
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
||||
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
|
||||
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse,
|
||||
)
|
||||
from app.schemas.game import PlaythroughInfo
|
||||
from app.services.points import PointsService
|
||||
from app.services.events import event_service
|
||||
from app.services.storage import storage_service
|
||||
from app.api.v1.games import get_available_games_for_participant
|
||||
|
||||
router = APIRouter(tags=["wheel"])
|
||||
|
||||
@@ -48,7 +51,9 @@ async def get_active_assignment(db, participant_id: int, is_event: bool = False)
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
|
||||
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == participant_id,
|
||||
@@ -64,7 +69,9 @@ async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
|
||||
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == participant_id,
|
||||
@@ -94,7 +101,7 @@ async def activate_returned_assignment(db, returned_assignment: Assignment) -> N
|
||||
|
||||
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
|
||||
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""Spin the wheel to get a random game and challenge"""
|
||||
"""Spin the wheel to get a random game and challenge (or playthrough)"""
|
||||
# Check marathon is active
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
@@ -115,60 +122,127 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
if active:
|
||||
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
||||
|
||||
# Get available games (filtered by completion status)
|
||||
available_games, _ = await get_available_games_for_participant(db, participant, marathon_id)
|
||||
|
||||
if not available_games:
|
||||
raise HTTPException(status_code=400, detail="No games available for spin")
|
||||
|
||||
# Check active event
|
||||
active_event = await event_service.get_active_event(db, marathon_id)
|
||||
|
||||
game = None
|
||||
challenge = None
|
||||
is_playthrough = False
|
||||
|
||||
# Handle special event cases (excluding Common Enemy - it has separate flow)
|
||||
# Events only apply to challenges-type games, not playthrough
|
||||
if active_event:
|
||||
if active_event.type == EventType.JACKPOT.value:
|
||||
# Jackpot: Get hard challenge only
|
||||
# Jackpot: Get hard challenge only (from challenges-type games)
|
||||
challenge = await event_service.get_random_hard_challenge(db, marathon_id)
|
||||
if challenge:
|
||||
# Load game for challenge
|
||||
# Check if this game is available for the participant
|
||||
result = await db.execute(
|
||||
select(Game).where(Game.id == challenge.game_id)
|
||||
)
|
||||
game = result.scalar_one_or_none()
|
||||
# Consume jackpot (one-time use)
|
||||
await event_service.consume_jackpot(db, active_event.id)
|
||||
if game and game.id in [g.id for g in available_games]:
|
||||
# Consume jackpot (one-time use)
|
||||
await event_service.consume_jackpot(db, active_event.id)
|
||||
else:
|
||||
# Game not available, fall back to normal selection
|
||||
game = None
|
||||
challenge = None
|
||||
# Note: Common Enemy is handled separately via event-assignment endpoints
|
||||
|
||||
# Normal random selection if no special event handling
|
||||
if not game or not challenge:
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.options(selectinload(Game.challenges))
|
||||
.where(Game.marathon_id == marathon_id)
|
||||
if not game:
|
||||
game = random.choice(available_games)
|
||||
|
||||
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||
# Playthrough game - no challenge selection, ignore events
|
||||
is_playthrough = True
|
||||
challenge = None
|
||||
active_event = None # Ignore events for playthrough
|
||||
else:
|
||||
# Challenges game - select random challenge
|
||||
if not game.challenges:
|
||||
# Reload challenges if not loaded
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.options(selectinload(Game.challenges))
|
||||
.where(Game.id == game.id)
|
||||
)
|
||||
game = result.scalar_one()
|
||||
|
||||
# Filter out already completed challenges
|
||||
completed_result = await db.execute(
|
||||
select(Assignment.challenge_id)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.challenge_id.in_([c.id for c in game.challenges]),
|
||||
Assignment.status == AssignmentStatus.COMPLETED.value,
|
||||
)
|
||||
)
|
||||
completed_ids = set(completed_result.scalars().all())
|
||||
available_challenges = [c for c in game.challenges if c.id not in completed_ids]
|
||||
|
||||
if not available_challenges:
|
||||
raise HTTPException(status_code=400, detail="No challenges available for this game")
|
||||
|
||||
challenge = random.choice(available_challenges)
|
||||
|
||||
# Create assignment
|
||||
if is_playthrough:
|
||||
# Playthrough assignment - link to game, not challenge
|
||||
assignment = Assignment(
|
||||
participant_id=participant.id,
|
||||
game_id=game.id,
|
||||
is_playthrough=True,
|
||||
status=AssignmentStatus.ACTIVE.value,
|
||||
# No event_type for playthrough
|
||||
)
|
||||
games = [g for g in result.scalars().all() if g.challenges]
|
||||
db.add(assignment)
|
||||
await db.flush() # Get assignment.id for bonus assignments
|
||||
|
||||
if not games:
|
||||
raise HTTPException(status_code=400, detail="No games with challenges available")
|
||||
# Create bonus assignments for all challenges
|
||||
bonus_challenges = []
|
||||
if game.challenges:
|
||||
for ch in game.challenges:
|
||||
bonus = BonusAssignment(
|
||||
main_assignment_id=assignment.id,
|
||||
challenge_id=ch.id,
|
||||
)
|
||||
db.add(bonus)
|
||||
bonus_challenges.append(ch)
|
||||
|
||||
game = random.choice(games)
|
||||
challenge = random.choice(game.challenges)
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"game": game.title,
|
||||
"is_playthrough": True,
|
||||
"points": game.playthrough_points,
|
||||
"bonus_challenges_count": len(bonus_challenges),
|
||||
}
|
||||
else:
|
||||
# Regular challenge assignment
|
||||
assignment = Assignment(
|
||||
participant_id=participant.id,
|
||||
challenge_id=challenge.id,
|
||||
status=AssignmentStatus.ACTIVE.value,
|
||||
event_type=active_event.type if active_event else None,
|
||||
)
|
||||
db.add(assignment)
|
||||
|
||||
# Create assignment (store event_type for jackpot multiplier on completion)
|
||||
assignment = Assignment(
|
||||
participant_id=participant.id,
|
||||
challenge_id=challenge.id,
|
||||
status=AssignmentStatus.ACTIVE.value,
|
||||
event_type=active_event.type if active_event else None,
|
||||
)
|
||||
db.add(assignment)
|
||||
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"game": game.title,
|
||||
"challenge": challenge.title,
|
||||
"difficulty": challenge.difficulty,
|
||||
"points": challenge.points,
|
||||
}
|
||||
if active_event:
|
||||
activity_data["event_type"] = active_event.type
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"game": game.title,
|
||||
"challenge": challenge.title,
|
||||
"difficulty": challenge.difficulty,
|
||||
"points": challenge.points,
|
||||
}
|
||||
if active_event:
|
||||
activity_data["event_type"] = active_event.type
|
||||
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
@@ -181,10 +255,17 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
await db.commit()
|
||||
await db.refresh(assignment)
|
||||
|
||||
# Calculate drop penalty (considers active event for double_risk)
|
||||
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
|
||||
# Calculate drop penalty
|
||||
if is_playthrough:
|
||||
drop_penalty = points_service.calculate_drop_penalty(
|
||||
participant.drop_count, game.playthrough_points, None # No events for playthrough
|
||||
)
|
||||
else:
|
||||
drop_penalty = points_service.calculate_drop_penalty(
|
||||
participant.drop_count, challenge.points, active_event
|
||||
)
|
||||
|
||||
# Get challenges count (avoid lazy loading in async context)
|
||||
# Get challenges count
|
||||
challenges_count = 0
|
||||
if 'challenges' in game.__dict__:
|
||||
challenges_count = len(game.challenges)
|
||||
@@ -193,36 +274,80 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
|
||||
)
|
||||
|
||||
return SpinResult(
|
||||
assignment_id=assignment.id,
|
||||
game=GameResponse(
|
||||
id=game.id,
|
||||
title=game.title,
|
||||
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||
download_url=game.download_url,
|
||||
genre=game.genre,
|
||||
added_by=None,
|
||||
challenges_count=challenges_count,
|
||||
created_at=game.created_at,
|
||||
),
|
||||
challenge=ChallengeResponse(
|
||||
id=challenge.id,
|
||||
title=challenge.title,
|
||||
description=challenge.description,
|
||||
type=challenge.type,
|
||||
difficulty=challenge.difficulty,
|
||||
points=challenge.points,
|
||||
estimated_time=challenge.estimated_time,
|
||||
proof_type=challenge.proof_type,
|
||||
proof_hint=challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
),
|
||||
can_drop=True,
|
||||
drop_penalty=drop_penalty,
|
||||
# Build response
|
||||
game_response = GameResponse(
|
||||
id=game.id,
|
||||
title=game.title,
|
||||
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||
download_url=game.download_url,
|
||||
genre=game.genre,
|
||||
added_by=None,
|
||||
challenges_count=challenges_count,
|
||||
created_at=game.created_at,
|
||||
game_type=game.game_type,
|
||||
playthrough_points=game.playthrough_points,
|
||||
playthrough_description=game.playthrough_description,
|
||||
playthrough_proof_type=game.playthrough_proof_type,
|
||||
playthrough_proof_hint=game.playthrough_proof_hint,
|
||||
)
|
||||
|
||||
if is_playthrough:
|
||||
# Return playthrough result
|
||||
return SpinResult(
|
||||
assignment_id=assignment.id,
|
||||
game=game_response,
|
||||
challenge=None,
|
||||
is_playthrough=True,
|
||||
playthrough_info=PlaythroughInfo(
|
||||
description=game.playthrough_description,
|
||||
points=game.playthrough_points,
|
||||
proof_type=game.playthrough_proof_type,
|
||||
proof_hint=game.playthrough_proof_hint,
|
||||
),
|
||||
bonus_challenges=[
|
||||
ChallengeResponse(
|
||||
id=ch.id,
|
||||
title=ch.title,
|
||||
description=ch.description,
|
||||
type=ch.type,
|
||||
difficulty=ch.difficulty,
|
||||
points=ch.points,
|
||||
estimated_time=ch.estimated_time,
|
||||
proof_type=ch.proof_type,
|
||||
proof_hint=ch.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
|
||||
is_generated=ch.is_generated,
|
||||
created_at=ch.created_at,
|
||||
)
|
||||
for ch in bonus_challenges
|
||||
],
|
||||
can_drop=True,
|
||||
drop_penalty=drop_penalty,
|
||||
)
|
||||
else:
|
||||
# Return challenge result
|
||||
return SpinResult(
|
||||
assignment_id=assignment.id,
|
||||
game=game_response,
|
||||
challenge=ChallengeResponse(
|
||||
id=challenge.id,
|
||||
title=challenge.title,
|
||||
description=challenge.description,
|
||||
type=challenge.type,
|
||||
difficulty=challenge.difficulty,
|
||||
points=challenge.points,
|
||||
estimated_time=challenge.estimated_time,
|
||||
proof_type=challenge.proof_type,
|
||||
proof_hint=challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
),
|
||||
is_playthrough=False,
|
||||
can_drop=True,
|
||||
drop_penalty=drop_penalty,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
|
||||
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
@@ -230,9 +355,77 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
||||
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
||||
assignment = await get_active_assignment(db, participant.id, is_event=False)
|
||||
|
||||
# If no active assignment, check for returned assignments
|
||||
if not assignment:
|
||||
returned = await get_oldest_returned_assignment(db, participant.id)
|
||||
if returned:
|
||||
# Activate the returned assignment
|
||||
await activate_returned_assignment(db, returned)
|
||||
await db.commit()
|
||||
# Reload with all relationships
|
||||
assignment = await get_active_assignment(db, participant.id, is_event=False)
|
||||
|
||||
if not assignment:
|
||||
return None
|
||||
|
||||
# Handle playthrough assignments
|
||||
if assignment.is_playthrough:
|
||||
game = assignment.game
|
||||
active_event = None # No events for playthrough
|
||||
drop_penalty = points_service.calculate_drop_penalty(
|
||||
participant.drop_count, game.playthrough_points, None
|
||||
)
|
||||
|
||||
# Build bonus challenges response
|
||||
from app.schemas.assignment import BonusAssignmentResponse
|
||||
bonus_responses = []
|
||||
for ba in assignment.bonus_assignments:
|
||||
bonus_responses.append(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=game.id, title=game.title, cover_url=None, game_type=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,
|
||||
))
|
||||
|
||||
return AssignmentResponse(
|
||||
id=assignment.id,
|
||||
challenge=None,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
|
||||
is_playthrough=True,
|
||||
playthrough_info=PlaythroughInfo(
|
||||
description=game.playthrough_description,
|
||||
points=game.playthrough_points,
|
||||
proof_type=game.playthrough_proof_type,
|
||||
proof_hint=game.playthrough_proof_hint,
|
||||
),
|
||||
status=assignment.status,
|
||||
proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_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,
|
||||
drop_penalty=drop_penalty,
|
||||
bonus_challenges=bonus_responses,
|
||||
)
|
||||
|
||||
# Regular challenge assignment
|
||||
challenge = assignment.challenge
|
||||
game = challenge.game
|
||||
|
||||
@@ -252,7 +445,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
||||
estimated_time=challenge.estimated_time,
|
||||
proof_type=challenge.proof_type,
|
||||
proof_hint=challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
),
|
||||
@@ -277,12 +470,15 @@ async def complete_assignment(
|
||||
proof_file: UploadFile | None = File(None),
|
||||
):
|
||||
"""Complete a regular assignment with proof (not event assignments)"""
|
||||
# Get assignment
|
||||
# Get assignment with all needed relationships
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.participant),
|
||||
selectinload(Assignment.challenge),
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game), # For playthrough
|
||||
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For bonus points
|
||||
selectinload(Assignment.dispute), # To check if it was previously disputed
|
||||
)
|
||||
.where(Assignment.id == assignment_id)
|
||||
)
|
||||
@@ -301,9 +497,14 @@ async def complete_assignment(
|
||||
if assignment.is_event_assignment:
|
||||
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
|
||||
|
||||
# Need either file or URL
|
||||
if not proof_file and not proof_url:
|
||||
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
|
||||
# For playthrough: need either file or URL or comment (proof is flexible)
|
||||
# For challenges: need either file or URL
|
||||
if assignment.is_playthrough:
|
||||
if not proof_file and not proof_url and not comment:
|
||||
raise HTTPException(status_code=400, detail="Proof is required (file, URL, or comment)")
|
||||
else:
|
||||
if not proof_file and not proof_url:
|
||||
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
|
||||
|
||||
# Handle file upload
|
||||
if proof_file:
|
||||
@@ -336,27 +537,91 @@ async def complete_assignment(
|
||||
|
||||
assignment.proof_comment = comment
|
||||
|
||||
# Calculate points
|
||||
participant = assignment.participant
|
||||
challenge = assignment.challenge
|
||||
|
||||
# Get marathon_id for activity and event check
|
||||
result = await db.execute(
|
||||
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
|
||||
)
|
||||
full_challenge = result.scalar_one()
|
||||
marathon_id = full_challenge.game.marathon_id
|
||||
# Handle playthrough completion
|
||||
if assignment.is_playthrough:
|
||||
game = assignment.game
|
||||
marathon_id = game.marathon_id
|
||||
base_points = game.playthrough_points
|
||||
|
||||
# No events for playthrough
|
||||
total_points, streak_bonus, _ = points_service.calculate_completion_points(
|
||||
base_points, participant.current_streak, None
|
||||
)
|
||||
|
||||
# Calculate bonus points from completed bonus assignments
|
||||
bonus_points = sum(
|
||||
ba.points_earned for ba in assignment.bonus_assignments
|
||||
if ba.status == BonusAssignmentStatus.COMPLETED.value
|
||||
)
|
||||
total_points += bonus_points
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.COMPLETED.value
|
||||
assignment.points_earned = total_points
|
||||
assignment.streak_at_completion = participant.current_streak + 1
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
|
||||
# Update participant
|
||||
participant.total_points += total_points
|
||||
participant.current_streak += 1
|
||||
participant.drop_count = 0
|
||||
|
||||
# Check if this is a redo of a previously disputed assignment
|
||||
is_redo = (
|
||||
assignment.dispute is not None and
|
||||
assignment.dispute.status == DisputeStatus.RESOLVED_INVALID.value
|
||||
)
|
||||
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"assignment_id": assignment.id,
|
||||
"game": game.title,
|
||||
"is_playthrough": True,
|
||||
"points": total_points,
|
||||
"base_points": base_points,
|
||||
"bonus_points": bonus_points,
|
||||
"streak": participant.current_streak,
|
||||
}
|
||||
if is_redo:
|
||||
activity_data["is_redo"] = True
|
||||
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.COMPLETE.value,
|
||||
data=activity_data,
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Check for returned assignments
|
||||
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
|
||||
if returned_assignment:
|
||||
await activate_returned_assignment(db, returned_assignment)
|
||||
await db.commit()
|
||||
|
||||
return CompleteResult(
|
||||
points_earned=total_points,
|
||||
streak_bonus=streak_bonus,
|
||||
total_points=participant.total_points,
|
||||
new_streak=participant.current_streak,
|
||||
)
|
||||
|
||||
# Regular challenge completion
|
||||
challenge = assignment.challenge
|
||||
marathon_id = challenge.game.marathon_id
|
||||
|
||||
# Check active event for point multipliers
|
||||
active_event = await event_service.get_active_event(db, marathon_id)
|
||||
|
||||
# For jackpot: use the event_type stored in assignment (since event may be over)
|
||||
# For other events: use the currently active event
|
||||
effective_event = active_event
|
||||
|
||||
# Handle assignment-level event types (jackpot)
|
||||
if assignment.event_type == EventType.JACKPOT.value:
|
||||
# Create a mock event object for point calculation
|
||||
class MockEvent:
|
||||
def __init__(self, event_type):
|
||||
self.type = event_type
|
||||
@@ -386,18 +651,25 @@ async def complete_assignment(
|
||||
# Update participant
|
||||
participant.total_points += total_points
|
||||
participant.current_streak += 1
|
||||
participant.drop_count = 0 # Reset drop counter on success
|
||||
participant.drop_count = 0
|
||||
|
||||
# Check if this is a redo of a previously disputed assignment
|
||||
is_redo = (
|
||||
assignment.dispute is not None and
|
||||
assignment.dispute.status == DisputeStatus.RESOLVED_INVALID.value
|
||||
)
|
||||
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"assignment_id": assignment.id,
|
||||
"game": full_challenge.game.title,
|
||||
"game": challenge.game.title,
|
||||
"challenge": challenge.title,
|
||||
"difficulty": challenge.difficulty,
|
||||
"points": total_points,
|
||||
"streak": participant.current_streak,
|
||||
}
|
||||
# Log event info (use assignment's event_type for jackpot, active_event for others)
|
||||
if is_redo:
|
||||
activity_data["is_redo"] = True
|
||||
if assignment.event_type == EventType.JACKPOT.value:
|
||||
activity_data["event_type"] = assignment.event_type
|
||||
activity_data["event_bonus"] = event_bonus
|
||||
@@ -418,7 +690,6 @@ async def complete_assignment(
|
||||
# If common enemy event auto-closed, log the event end with winners
|
||||
if common_enemy_closed and common_enemy_winners:
|
||||
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||
# Load winner nicknames
|
||||
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
|
||||
users_result = await db.execute(
|
||||
select(User).where(User.id.in_(winner_user_ids))
|
||||
@@ -438,7 +709,7 @@ async def complete_assignment(
|
||||
|
||||
event_end_activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id, # Last completer triggers the close
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.EVENT_END.value,
|
||||
data={
|
||||
"event_type": EventType.COMMON_ENEMY.value,
|
||||
@@ -451,7 +722,7 @@ async def complete_assignment(
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Check for returned assignments and activate the oldest one
|
||||
# Check for returned assignments
|
||||
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
|
||||
if returned_assignment:
|
||||
await activate_returned_assignment(db, returned_assignment)
|
||||
@@ -469,12 +740,14 @@ async def complete_assignment(
|
||||
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
|
||||
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""Drop current assignment"""
|
||||
# Get assignment
|
||||
# Get assignment with all needed relationships
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.participant),
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game), # For playthrough
|
||||
selectinload(Assignment.bonus_assignments), # For resetting bonuses on drop
|
||||
)
|
||||
.where(Assignment.id == assignment_id)
|
||||
)
|
||||
@@ -490,6 +763,61 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
raise HTTPException(status_code=400, detail="Assignment is not active")
|
||||
|
||||
participant = assignment.participant
|
||||
|
||||
# Handle playthrough drop
|
||||
if assignment.is_playthrough:
|
||||
game = assignment.game
|
||||
marathon_id = game.marathon_id
|
||||
|
||||
# No events for playthrough
|
||||
penalty = points_service.calculate_drop_penalty(
|
||||
participant.drop_count, game.playthrough_points, None
|
||||
)
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
|
||||
# Reset all bonus assignments (lose any completed bonuses)
|
||||
completed_bonuses_count = 0
|
||||
for ba in assignment.bonus_assignments:
|
||||
if ba.status == BonusAssignmentStatus.COMPLETED.value:
|
||||
completed_bonuses_count += 1
|
||||
ba.status = BonusAssignmentStatus.PENDING.value
|
||||
ba.proof_path = None
|
||||
ba.proof_url = None
|
||||
ba.proof_comment = None
|
||||
ba.points_earned = 0
|
||||
ba.completed_at = None
|
||||
|
||||
# Update participant
|
||||
participant.total_points = max(0, participant.total_points - penalty)
|
||||
participant.current_streak = 0
|
||||
participant.drop_count += 1
|
||||
|
||||
# Log activity
|
||||
activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id,
|
||||
type=ActivityType.DROP.value,
|
||||
data={
|
||||
"game": game.title,
|
||||
"is_playthrough": True,
|
||||
"penalty": penalty,
|
||||
"lost_bonuses": completed_bonuses_count,
|
||||
},
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return DropResult(
|
||||
penalty=penalty,
|
||||
total_points=participant.total_points,
|
||||
new_drop_count=participant.drop_count,
|
||||
)
|
||||
|
||||
# Regular challenge drop
|
||||
marathon_id = assignment.challenge.game.marathon_id
|
||||
|
||||
# Check active event for free drops (double_risk)
|
||||
@@ -550,7 +878,9 @@ async def get_my_history(
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game), # For playthrough
|
||||
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
|
||||
)
|
||||
.where(Assignment.participant_id == participant.id)
|
||||
.order_by(Assignment.started_at.desc())
|
||||
@@ -559,34 +889,88 @@ async def get_my_history(
|
||||
)
|
||||
assignments = result.scalars().all()
|
||||
|
||||
return [
|
||||
AssignmentResponse(
|
||||
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=None
|
||||
responses = []
|
||||
for a in assignments:
|
||||
if a.is_playthrough:
|
||||
# Playthrough assignment
|
||||
game = a.game
|
||||
from app.schemas.assignment import BonusAssignmentResponse
|
||||
bonus_responses = [
|
||||
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=game.id, title=game.title, cover_url=None, game_type=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 a.bonus_assignments
|
||||
]
|
||||
|
||||
responses.append(AssignmentResponse(
|
||||
id=a.id,
|
||||
challenge=None,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
|
||||
is_playthrough=True,
|
||||
playthrough_info=PlaythroughInfo(
|
||||
description=game.playthrough_description,
|
||||
points=game.playthrough_points,
|
||||
proof_type=game.playthrough_proof_type,
|
||||
proof_hint=game.playthrough_proof_hint,
|
||||
),
|
||||
is_generated=a.challenge.is_generated,
|
||||
created_at=a.challenge.created_at,
|
||||
),
|
||||
status=a.status,
|
||||
proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url,
|
||||
proof_comment=a.proof_comment,
|
||||
points_earned=a.points_earned,
|
||||
streak_at_completion=a.streak_at_completion,
|
||||
started_at=a.started_at,
|
||||
completed_at=a.completed_at,
|
||||
)
|
||||
for a in assignments
|
||||
]
|
||||
status=a.status,
|
||||
proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url,
|
||||
proof_comment=a.proof_comment,
|
||||
points_earned=a.points_earned,
|
||||
streak_at_completion=a.streak_at_completion,
|
||||
started_at=a.started_at,
|
||||
completed_at=a.completed_at,
|
||||
bonus_challenges=bonus_responses,
|
||||
))
|
||||
else:
|
||||
# Regular challenge assignment
|
||||
responses.append(AssignmentResponse(
|
||||
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=None,
|
||||
game_type=a.challenge.game.game_type,
|
||||
),
|
||||
is_generated=a.challenge.is_generated,
|
||||
created_at=a.challenge.created_at,
|
||||
),
|
||||
status=a.status,
|
||||
proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url,
|
||||
proof_comment=a.proof_comment,
|
||||
points_earned=a.points_earned,
|
||||
streak_at_completion=a.streak_at_completion,
|
||||
started_at=a.started_at,
|
||||
completed_at=a.completed_at,
|
||||
))
|
||||
|
||||
return responses
|
||||
|
||||
Reference in New Issue
Block a user