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

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

View File

@@ -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'}"
)

View File

@@ -2,7 +2,7 @@
Assignment details and dispute system endpoints.
"""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form
from fastapi.responses import Response, StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@@ -10,12 +10,13 @@ from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import (
Assignment, AssignmentStatus, Participant, Challenge, User, Marathon,
Dispute, DisputeStatus, DisputeComment, DisputeVote,
Dispute, DisputeStatus, DisputeComment, DisputeVote, BonusAssignment, BonusAssignmentStatus,
)
from app.schemas import (
AssignmentDetailResponse, DisputeCreate, DisputeResponse,
DisputeCommentCreate, DisputeCommentResponse, DisputeVoteCreate,
MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse,
BonusAssignmentResponse, CompleteBonusAssignment, BonusCompleteResult,
)
from app.schemas.user import UserPublic
from app.services.storage import storage_service
@@ -92,11 +93,18 @@ async def get_assignment_detail(
db: DbSession,
):
"""Get detailed information about an assignment including proofs and dispute"""
from app.models import Game
# Get assignment with all relationships
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.raised_by), # Bonus disputes
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user),
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.votes).selectinload(DisputeVote.user),
selectinload(Assignment.participant).selectinload(Participant.user),
selectinload(Assignment.dispute).selectinload(Dispute.raised_by),
selectinload(Assignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user),
@@ -109,8 +117,13 @@ async def get_assignment_detail(
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Get marathon_id based on assignment type
if assignment.is_playthrough:
marathon_id = assignment.game.marathon_id
else:
marathon_id = assignment.challenge.game.marathon_id
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
@@ -121,18 +134,20 @@ async def get_assignment_detail(
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Build response
challenge = assignment.challenge
game = challenge.game
owner_user = assignment.participant.user
# Determine if user can dispute
# Determine if user can dispute (including playthrough)
# Allow disputing if no active dispute exists (resolved disputes don't block new ones)
has_active_dispute = (
assignment.dispute is not None and
assignment.dispute.status in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]
)
can_dispute = False
if (
assignment.status == AssignmentStatus.COMPLETED.value
and assignment.completed_at
and assignment.participant.user_id != current_user.id
and assignment.dispute is None
and not has_active_dispute
):
time_since_completion = datetime.utcnow() - assignment.completed_at
can_dispute = time_since_completion < timedelta(hours=DISPUTE_WINDOW_HOURS)
@@ -140,6 +155,81 @@ async def get_assignment_detail(
# Build proof URLs
proof_image_url = storage_service.get_url(assignment.proof_path, "proofs")
# Handle playthrough assignments
if assignment.is_playthrough:
game = assignment.game
bonus_challenges = []
for ba in assignment.bonus_assignments:
# Determine if user can dispute this bonus
# Allow disputing if no active dispute exists
bonus_has_active_dispute = (
ba.dispute is not None and
ba.dispute.status in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]
)
bonus_can_dispute = False
if (
ba.status == BonusAssignmentStatus.COMPLETED.value
and ba.completed_at
and assignment.participant.user_id != current_user.id
and not bonus_has_active_dispute
):
time_since_bonus_completion = datetime.utcnow() - ba.completed_at
bonus_can_dispute = time_since_bonus_completion < timedelta(hours=DISPUTE_WINDOW_HOURS)
bonus_challenges.append({
"id": ba.id,
"challenge": {
"id": ba.challenge.id,
"title": ba.challenge.title,
"description": ba.challenge.description,
"points": ba.challenge.points,
"difficulty": ba.challenge.difficulty,
"proof_hint": ba.challenge.proof_hint,
},
"status": ba.status,
"proof_url": ba.proof_url,
"proof_image_url": storage_service.get_url(ba.proof_path, "bonus_proofs") if ba.proof_path else None,
"proof_comment": ba.proof_comment,
"points_earned": ba.points_earned,
"completed_at": ba.completed_at.isoformat() if ba.completed_at else None,
"can_dispute": bonus_can_dispute,
"dispute": build_dispute_response(ba.dispute, current_user.id) if ba.dispute else None,
})
return AssignmentDetailResponse(
id=assignment.id,
challenge=None,
game=GameShort(
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
game_type=game.game_type,
),
is_playthrough=True,
playthrough_info={
"description": game.playthrough_description,
"points": game.playthrough_points,
"proof_type": game.playthrough_proof_type,
"proof_hint": game.playthrough_proof_hint,
},
participant=user_to_public(owner_user),
status=assignment.status,
proof_url=assignment.proof_url,
proof_image_url=proof_image_url,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
can_dispute=can_dispute,
dispute=build_dispute_response(assignment.dispute, current_user.id) if assignment.dispute else None,
bonus_challenges=bonus_challenges,
)
# Regular challenge assignment
challenge = assignment.challenge
game = challenge.game
return AssignmentDetailResponse(
id=assignment.id,
challenge=ChallengeResponse(
@@ -187,6 +277,7 @@ async def get_assignment_proof_media(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
)
.where(Assignment.id == assignment_id)
)
@@ -195,8 +286,13 @@ async def get_assignment_proof_media(
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Get marathon_id based on assignment type
if assignment.is_playthrough:
marathon_id = assignment.game.marathon_id
else:
marathon_id = assignment.challenge.game.marathon_id
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
@@ -283,6 +379,214 @@ async def get_assignment_proof_image(
return await get_assignment_proof_media(assignment_id, request, current_user, db)
@router.get("/assignments/{assignment_id}/bonus/{bonus_id}/proof-media")
async def get_bonus_proof_media(
assignment_id: int,
bonus_id: int,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Stream the proof media (image or video) for a bonus assignment"""
# Get assignment with bonus assignments
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.game),
selectinload(Assignment.bonus_assignments),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if not assignment.is_playthrough:
raise HTTPException(status_code=400, detail="This is not a playthrough assignment")
# Get marathon_id
marathon_id = assignment.game.marathon_id
# Check user is participant of the marathon
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Find the bonus assignment
bonus_assignment = None
for ba in assignment.bonus_assignments:
if ba.id == bonus_id:
bonus_assignment = ba
break
if not bonus_assignment:
raise HTTPException(status_code=404, detail="Bonus assignment not found")
# Check if proof exists
if not bonus_assignment.proof_path:
raise HTTPException(status_code=404, detail="No proof media for this bonus assignment")
# Get file from storage
file_data = await storage_service.get_file(bonus_assignment.proof_path, "bonus_proofs")
if not file_data:
raise HTTPException(status_code=404, detail="Proof media not found in storage")
content, content_type = file_data
file_size = len(content)
# Check if it's a video and handle Range requests
is_video = content_type.startswith("video/")
if is_video:
range_header = request.headers.get("range")
if range_header:
range_match = range_header.replace("bytes=", "").split("-")
start = int(range_match[0]) if range_match[0] else 0
end = int(range_match[1]) if range_match[1] else file_size - 1
if start >= file_size:
raise HTTPException(status_code=416, detail="Range not satisfiable")
end = min(end, file_size - 1)
chunk = content[start:end + 1]
return Response(
content=chunk,
status_code=206,
media_type=content_type,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(len(chunk)),
"Cache-Control": "public, max-age=31536000",
}
)
return Response(
content=content,
media_type=content_type,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
"Cache-Control": "public, max-age=31536000",
}
)
# For images, just return the content
return Response(
content=content,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=31536000",
}
)
@router.post("/bonus-assignments/{bonus_id}/dispute", response_model=DisputeResponse)
async def create_bonus_dispute(
bonus_id: int,
data: DisputeCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a dispute against a bonus assignment's proof"""
from app.models import Game
# Get bonus assignment with main assignment
result = await db.execute(
select(BonusAssignment)
.options(
selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
selectinload(BonusAssignment.challenge),
selectinload(BonusAssignment.dispute),
)
.where(BonusAssignment.id == bonus_id)
)
bonus_assignment = result.scalar_one_or_none()
if not bonus_assignment:
raise HTTPException(status_code=404, detail="Bonus assignment not found")
main_assignment = bonus_assignment.main_assignment
marathon_id = main_assignment.game.marathon_id
# Check user is participant of the marathon
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Validate
if bonus_assignment.status != BonusAssignmentStatus.COMPLETED.value:
raise HTTPException(status_code=400, detail="Can only dispute completed bonus assignments")
if main_assignment.participant.user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot dispute your own bonus assignment")
# Check for active dispute (open or pending admin decision)
if bonus_assignment.dispute and bonus_assignment.dispute.status in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]:
raise HTTPException(status_code=400, detail="An active dispute already exists for this bonus assignment")
if not bonus_assignment.completed_at:
raise HTTPException(status_code=400, detail="Bonus assignment has no completion date")
time_since_completion = datetime.utcnow() - bonus_assignment.completed_at
if time_since_completion >= timedelta(hours=DISPUTE_WINDOW_HOURS):
raise HTTPException(status_code=400, detail="Dispute window has expired (24 hours)")
# Create dispute for bonus assignment
dispute = Dispute(
bonus_assignment_id=bonus_id,
raised_by_id=current_user.id,
reason=data.reason,
status=DisputeStatus.OPEN.value,
)
db.add(dispute)
await db.commit()
await db.refresh(dispute)
# Send notification to assignment owner
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
title = f"Бонус: {bonus_assignment.challenge.title}"
await telegram_notifier.notify_dispute_raised(
db,
user_id=main_assignment.participant.user_id,
marathon_title=marathon.title,
challenge_title=title,
assignment_id=main_assignment.id
)
# Load relationships for response
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.raised_by),
selectinload(Dispute.comments).selectinload(DisputeComment.user),
selectinload(Dispute.votes).selectinload(DisputeVote.user),
)
.where(Dispute.id == dispute.id)
)
dispute = result.scalar_one()
return build_dispute_response(dispute, current_user.id)
@router.post("/assignments/{assignment_id}/dispute", response_model=DisputeResponse)
async def create_dispute(
assignment_id: int,
@@ -290,12 +594,13 @@ async def create_dispute(
current_user: CurrentUser,
db: DbSession,
):
"""Create a dispute against an assignment's proof"""
"""Create a dispute against an assignment's proof (including playthrough)"""
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.participant),
selectinload(Assignment.dispute),
)
@@ -306,8 +611,13 @@ async def create_dispute(
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Get marathon_id based on assignment type
if assignment.is_playthrough:
marathon_id = assignment.game.marathon_id
else:
marathon_id = assignment.challenge.game.marathon_id
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
@@ -325,8 +635,9 @@ async def create_dispute(
if assignment.participant.user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot dispute your own assignment")
if assignment.dispute:
raise HTTPException(status_code=400, detail="A dispute already exists for this assignment")
# Check for active dispute (open or pending admin decision)
if assignment.dispute and assignment.dispute.status in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]:
raise HTTPException(status_code=400, detail="An active dispute already exists for this assignment")
if not assignment.completed_at:
raise HTTPException(status_code=400, detail="Assignment has no completion date")
@@ -350,11 +661,17 @@ async def create_dispute(
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
# Get title based on assignment type
if assignment.is_playthrough:
title = f"Прохождение: {assignment.game.title}"
else:
title = assignment.challenge.title
await telegram_notifier.notify_dispute_raised(
db,
user_id=assignment.participant.user_id,
marathon_title=marathon.title,
challenge_title=assignment.challenge.title,
challenge_title=title,
assignment_id=assignment_id
)
@@ -381,11 +698,13 @@ async def add_dispute_comment(
db: DbSession,
):
"""Add a comment to a dispute discussion"""
# Get dispute with assignment
# Get dispute with assignment or bonus assignment
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Dispute.assignment).selectinload(Assignment.game), # For playthrough
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
)
.where(Dispute.id == dispute_id)
)
@@ -398,7 +717,12 @@ async def add_dispute_comment(
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Check user is participant of the marathon
marathon_id = dispute.assignment.challenge.game.marathon_id
if dispute.bonus_assignment_id:
marathon_id = dispute.bonus_assignment.main_assignment.game.marathon_id
elif dispute.assignment.is_playthrough:
marathon_id = dispute.assignment.game.marathon_id
else:
marathon_id = dispute.assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
@@ -439,11 +763,13 @@ async def vote_on_dispute(
db: DbSession,
):
"""Vote on a dispute (True = valid/proof is OK, False = invalid/proof is not OK)"""
# Get dispute with assignment
# Get dispute with assignment or bonus assignment
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Dispute.assignment).selectinload(Assignment.game), # For playthrough
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
)
.where(Dispute.id == dispute_id)
)
@@ -456,7 +782,12 @@ async def vote_on_dispute(
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Check user is participant of the marathon
marathon_id = dispute.assignment.challenge.game.marathon_id
if dispute.bonus_assignment_id:
marathon_id = dispute.bonus_assignment.main_assignment.game.marathon_id
elif dispute.assignment and dispute.assignment.is_playthrough:
marathon_id = dispute.assignment.game.marathon_id
else:
marathon_id = dispute.assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
@@ -518,6 +849,7 @@ async def get_returned_assignments(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough assignments
selectinload(Assignment.dispute),
)
.where(
@@ -528,29 +860,228 @@ async def get_returned_assignments(
)
assignments = result.scalars().all()
return [
ReturnedAssignmentResponse(
id=a.id,
challenge=ChallengeResponse(
id=a.challenge.id,
title=a.challenge.title,
description=a.challenge.description,
type=a.challenge.type,
difficulty=a.challenge.difficulty,
points=a.challenge.points,
estimated_time=a.challenge.estimated_time,
proof_type=a.challenge.proof_type,
proof_hint=a.challenge.proof_hint,
game=GameShort(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=storage_service.get_url(a.challenge.game.cover_path, "covers"),
response = []
for a in assignments:
if a.is_playthrough:
# Playthrough assignment
response.append(ReturnedAssignmentResponse(
id=a.id,
is_playthrough=True,
game_id=a.game_id,
game_title=a.game.title if a.game else None,
game_cover_url=storage_service.get_url(a.game.cover_path, "covers") if a.game else None,
original_completed_at=a.completed_at,
dispute_reason=a.dispute.reason if a.dispute else "Оспорено",
))
else:
# Challenge assignment
response.append(ReturnedAssignmentResponse(
id=a.id,
challenge=ChallengeResponse(
id=a.challenge.id,
title=a.challenge.title,
description=a.challenge.description,
type=a.challenge.type,
difficulty=a.challenge.difficulty,
points=a.challenge.points,
estimated_time=a.challenge.estimated_time,
proof_type=a.challenge.proof_type,
proof_hint=a.challenge.proof_hint,
game=GameShort(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=storage_service.get_url(a.challenge.game.cover_path, "covers"),
),
is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at,
),
is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at,
),
original_completed_at=a.completed_at,
dispute_reason=a.dispute.reason if a.dispute else "Оспорено",
original_completed_at=a.completed_at,
dispute_reason=a.dispute.reason if a.dispute else "Оспорено",
))
return response
# ============ Bonus Assignments Endpoints ============
@router.get("/assignments/{assignment_id}/bonus", response_model=list[BonusAssignmentResponse])
async def get_bonus_assignments(
assignment_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get bonus assignments for a playthrough assignment"""
# Get assignment with bonus challenges
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.game),
selectinload(Assignment.participant),
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge),
)
for a in assignments
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if not assignment.is_playthrough:
raise HTTPException(status_code=400, detail="This is not a playthrough assignment")
# Check user is the owner
if assignment.participant.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only view your own bonus assignments")
# Build response
return [
BonusAssignmentResponse(
id=ba.id,
challenge=ChallengeResponse(
id=ba.challenge.id,
title=ba.challenge.title,
description=ba.challenge.description,
type=ba.challenge.type,
difficulty=ba.challenge.difficulty,
points=ba.challenge.points,
estimated_time=ba.challenge.estimated_time,
proof_type=ba.challenge.proof_type,
proof_hint=ba.challenge.proof_hint,
game=GameShort(
id=assignment.game.id,
title=assignment.game.title,
cover_url=storage_service.get_url(assignment.game.cover_path, "covers") if hasattr(assignment.game, 'cover_path') else None,
game_type=assignment.game.game_type,
),
is_generated=ba.challenge.is_generated,
created_at=ba.challenge.created_at,
),
status=ba.status,
proof_url=ba.proof_url,
proof_comment=ba.proof_comment,
points_earned=ba.points_earned,
completed_at=ba.completed_at,
)
for ba in assignment.bonus_assignments
]
@router.post("/assignments/{assignment_id}/bonus/{bonus_id}/complete", response_model=BonusCompleteResult)
async def complete_bonus_assignment(
assignment_id: int,
bonus_id: int,
current_user: CurrentUser,
db: DbSession,
proof_url: str | None = Form(None),
comment: str | None = Form(None),
proof_file: UploadFile | None = File(None),
):
"""Complete a bonus challenge for a playthrough assignment"""
from app.core.config import settings
# Get main assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if not assignment.is_playthrough:
raise HTTPException(status_code=400, detail="This is not a playthrough assignment")
# Check user is the owner
if assignment.participant.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only complete your own bonus assignments")
# Check main assignment is active or completed (completed allows re-doing bonus after bonus dispute)
if assignment.status not in [AssignmentStatus.ACTIVE.value, AssignmentStatus.COMPLETED.value]:
raise HTTPException(
status_code=400,
detail="Bonus challenges can only be completed while the main assignment is active or completed"
)
# Find the bonus assignment
bonus_assignment = None
for ba in assignment.bonus_assignments:
if ba.id == bonus_id:
bonus_assignment = ba
break
if not bonus_assignment:
raise HTTPException(status_code=404, detail="Bonus assignment not found")
if bonus_assignment.status == BonusAssignmentStatus.COMPLETED.value:
raise HTTPException(status_code=400, detail="This bonus challenge is already completed")
# Validate proof (need file, URL, or comment)
if not proof_file and not proof_url and not comment:
raise HTTPException(
status_code=400,
detail="Необходимо прикрепить файл, ссылку или комментарий"
)
# Handle file upload
if proof_file:
contents = await proof_file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
)
# Upload file to storage
filename = storage_service.generate_filename(bonus_id, proof_file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="bonus_proofs",
filename=filename,
content_type=proof_file.content_type or "application/octet-stream",
)
bonus_assignment.proof_path = file_path
else:
bonus_assignment.proof_url = proof_url
# Complete the bonus assignment
bonus_assignment.status = BonusAssignmentStatus.COMPLETED.value
bonus_assignment.proof_comment = comment
bonus_assignment.points_earned = bonus_assignment.challenge.points
bonus_assignment.completed_at = datetime.utcnow()
# If main assignment is already COMPLETED, add bonus points immediately
# This handles the case where a bonus was disputed and user is re-completing it
if assignment.status == AssignmentStatus.COMPLETED.value:
participant = assignment.participant
participant.total_points += bonus_assignment.points_earned
assignment.points_earned += bonus_assignment.points_earned
# NOTE: If main is not completed yet, points will be added when main is completed
# This prevents exploiting by dropping the main assignment after getting bonus points
await db.commit()
# Calculate total bonus points for this assignment
total_bonus_points = sum(
ba.points_earned for ba in assignment.bonus_assignments
if ba.status == BonusAssignmentStatus.COMPLETED.value
)
return BonusCompleteResult(
bonus_assignment_id=bonus_assignment.id,
points_earned=bonus_assignment.points_earned,
total_bonus_points=total_bonus_points,
)

View File

@@ -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

View File

@@ -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'}"
)

View File

@@ -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