1007 lines
36 KiB
Python
1007 lines
36 KiB
Python
from datetime import timedelta
|
||
import secrets
|
||
import string
|
||
from fastapi import APIRouter, HTTPException, status, Depends, UploadFile, File, Response
|
||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||
from sqlalchemy import select, func
|
||
from sqlalchemy.orm import selectinload
|
||
|
||
from app.api.deps import (
|
||
DbSession, CurrentUser,
|
||
require_participant, require_organizer, require_creator,
|
||
get_participant,
|
||
)
|
||
from app.core.config import settings
|
||
from app.core.security import decode_access_token
|
||
from app.services.storage import storage_service
|
||
|
||
# Optional auth for endpoints that need it conditionally
|
||
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, User,
|
||
)
|
||
from app.schemas import (
|
||
MarathonCreate,
|
||
MarathonUpdate,
|
||
MarathonResponse,
|
||
MarathonListItem,
|
||
MarathonPublicInfo,
|
||
JoinMarathon,
|
||
ParticipantInfo,
|
||
ParticipantWithUser,
|
||
LeaderboardEntry,
|
||
MessageResponse,
|
||
UserPublic,
|
||
SetParticipantRole,
|
||
)
|
||
from app.services.telegram_notifier import telegram_notifier
|
||
|
||
router = APIRouter(prefix="/marathons", tags=["marathons"])
|
||
|
||
|
||
# Public endpoint (no auth required)
|
||
@router.get("/by-code/{invite_code}", response_model=MarathonPublicInfo)
|
||
async def get_marathon_by_code(invite_code: str, db: DbSession):
|
||
"""Get public marathon info by invite code. No authentication required."""
|
||
result = await db.execute(
|
||
select(Marathon, func.count(Participant.id).label("participants_count"))
|
||
.outerjoin(Participant)
|
||
.options(selectinload(Marathon.creator))
|
||
.where(func.upper(Marathon.invite_code) == invite_code.upper())
|
||
.group_by(Marathon.id)
|
||
)
|
||
row = result.first()
|
||
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||
|
||
marathon = row[0]
|
||
participants_count = row[1]
|
||
|
||
return MarathonPublicInfo(
|
||
id=marathon.id,
|
||
title=marathon.title,
|
||
description=marathon.description,
|
||
status=marathon.status,
|
||
cover_url=marathon.cover_url,
|
||
participants_count=participants_count,
|
||
creator_nickname=marathon.creator.nickname,
|
||
)
|
||
|
||
|
||
def generate_invite_code() -> str:
|
||
"""Generate a clean 8-character uppercase alphanumeric code."""
|
||
alphabet = string.ascii_uppercase + string.digits
|
||
return ''.join(secrets.choice(alphabet) for _ in range(8))
|
||
|
||
|
||
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
|
||
result = await db.execute(
|
||
select(Marathon)
|
||
.options(
|
||
selectinload(Marathon.creator).selectinload(User.equipped_frame),
|
||
selectinload(Marathon.creator).selectinload(User.equipped_title),
|
||
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
|
||
selectinload(Marathon.creator).selectinload(User.equipped_background),
|
||
)
|
||
.where(Marathon.id == marathon_id)
|
||
)
|
||
marathon = result.scalar_one_or_none()
|
||
if not marathon:
|
||
raise HTTPException(status_code=404, detail="Marathon not found")
|
||
return marathon
|
||
|
||
|
||
async def get_participation(db, user_id: int, marathon_id: int) -> Participant | None:
|
||
result = await db.execute(
|
||
select(Participant).where(
|
||
Participant.user_id == user_id,
|
||
Participant.marathon_id == marathon_id,
|
||
)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
|
||
@router.get("", response_model=list[MarathonListItem])
|
||
async def list_marathons(current_user: CurrentUser, db: DbSession):
|
||
"""Get all marathons where user is participant, creator, or public marathons"""
|
||
# Admin can see all marathons
|
||
if current_user.is_admin:
|
||
result = await db.execute(
|
||
select(Marathon, func.count(Participant.id).label("participants_count"))
|
||
.outerjoin(Participant)
|
||
.group_by(Marathon.id)
|
||
.order_by(Marathon.created_at.desc())
|
||
)
|
||
else:
|
||
# User can see: own marathons, participated marathons, and public marathons
|
||
result = await db.execute(
|
||
select(Marathon, func.count(Participant.id).label("participants_count"))
|
||
.outerjoin(Participant)
|
||
.where(
|
||
(Marathon.creator_id == current_user.id) |
|
||
(Participant.user_id == current_user.id) |
|
||
(Marathon.is_public == True)
|
||
)
|
||
.group_by(Marathon.id)
|
||
.order_by(Marathon.created_at.desc())
|
||
)
|
||
|
||
marathons = []
|
||
for row in result.all():
|
||
marathon = row[0]
|
||
marathons.append(MarathonListItem(
|
||
id=marathon.id,
|
||
title=marathon.title,
|
||
status=marathon.status,
|
||
is_public=marathon.is_public,
|
||
cover_url=marathon.cover_url,
|
||
participants_count=row[1],
|
||
start_date=marathon.start_date,
|
||
end_date=marathon.end_date,
|
||
))
|
||
|
||
return marathons
|
||
|
||
|
||
@router.post("", response_model=MarathonResponse)
|
||
async def create_marathon(
|
||
data: MarathonCreate,
|
||
current_user: CurrentUser,
|
||
db: DbSession,
|
||
):
|
||
# Strip timezone info for naive datetime columns
|
||
start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date
|
||
end_date = start_date + timedelta(days=data.duration_days)
|
||
|
||
marathon = Marathon(
|
||
title=data.title,
|
||
description=data.description,
|
||
creator_id=current_user.id,
|
||
invite_code=generate_invite_code(),
|
||
is_public=data.is_public,
|
||
game_proposal_mode=data.game_proposal_mode,
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
)
|
||
db.add(marathon)
|
||
await db.flush()
|
||
|
||
# Auto-add creator as organizer participant
|
||
participant = Participant(
|
||
user_id=current_user.id,
|
||
marathon_id=marathon.id,
|
||
role=ParticipantRole.ORGANIZER.value, # Creator is organizer
|
||
)
|
||
db.add(participant)
|
||
|
||
await db.commit()
|
||
await db.refresh(marathon)
|
||
|
||
return MarathonResponse(
|
||
id=marathon.id,
|
||
title=marathon.title,
|
||
description=marathon.description,
|
||
creator=UserPublic.model_validate(current_user),
|
||
status=marathon.status,
|
||
invite_code=marathon.invite_code,
|
||
is_public=marathon.is_public,
|
||
game_proposal_mode=marathon.game_proposal_mode,
|
||
auto_events_enabled=marathon.auto_events_enabled,
|
||
cover_url=marathon.cover_url,
|
||
start_date=marathon.start_date,
|
||
end_date=marathon.end_date,
|
||
participants_count=1,
|
||
games_count=0,
|
||
created_at=marathon.created_at,
|
||
my_participation=ParticipantInfo.model_validate(participant),
|
||
)
|
||
|
||
|
||
@router.get("/{marathon_id}", response_model=MarathonResponse)
|
||
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||
marathon = await get_marathon_or_404(db, marathon_id)
|
||
|
||
# For private marathons, require participation (or admin/creator)
|
||
if not marathon.is_public and not current_user.is_admin:
|
||
participation = await get_participation(db, current_user.id, marathon_id)
|
||
if not participation:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="You are not a participant of this private marathon",
|
||
)
|
||
|
||
# Count participants and approved games
|
||
participants_count = await db.scalar(
|
||
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
|
||
)
|
||
games_count = await db.scalar(
|
||
select(func.count()).select_from(Game).where(
|
||
Game.marathon_id == marathon_id,
|
||
Game.status == GameStatus.APPROVED.value,
|
||
)
|
||
)
|
||
|
||
# Get user's participation
|
||
participation = await get_participation(db, current_user.id, marathon_id)
|
||
|
||
return MarathonResponse(
|
||
id=marathon.id,
|
||
title=marathon.title,
|
||
description=marathon.description,
|
||
creator=UserPublic.model_validate(marathon.creator),
|
||
status=marathon.status,
|
||
invite_code=marathon.invite_code,
|
||
is_public=marathon.is_public,
|
||
game_proposal_mode=marathon.game_proposal_mode,
|
||
auto_events_enabled=marathon.auto_events_enabled,
|
||
cover_url=marathon.cover_url,
|
||
start_date=marathon.start_date,
|
||
end_date=marathon.end_date,
|
||
participants_count=participants_count,
|
||
games_count=games_count,
|
||
created_at=marathon.created_at,
|
||
my_participation=ParticipantInfo.model_validate(participation) if participation else None,
|
||
)
|
||
|
||
|
||
@router.patch("/{marathon_id}", response_model=MarathonResponse)
|
||
async def update_marathon(
|
||
marathon_id: int,
|
||
data: MarathonUpdate,
|
||
current_user: CurrentUser,
|
||
db: DbSession,
|
||
):
|
||
# Require organizer role
|
||
await require_organizer(db, current_user, marathon_id)
|
||
marathon = await get_marathon_or_404(db, marathon_id)
|
||
|
||
if marathon.status != MarathonStatus.PREPARING.value:
|
||
raise HTTPException(status_code=400, detail="Cannot update active or finished marathon")
|
||
|
||
if data.title is not None:
|
||
marathon.title = data.title
|
||
if data.description is not None:
|
||
marathon.description = data.description
|
||
if data.start_date is not None:
|
||
# Strip timezone info for naive datetime columns
|
||
marathon.start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date
|
||
if data.is_public is not None:
|
||
marathon.is_public = data.is_public
|
||
if data.game_proposal_mode is not None:
|
||
marathon.game_proposal_mode = data.game_proposal_mode
|
||
if data.auto_events_enabled is not None:
|
||
marathon.auto_events_enabled = data.auto_events_enabled
|
||
|
||
await db.commit()
|
||
|
||
return await get_marathon(marathon_id, current_user, db)
|
||
|
||
|
||
@router.delete("/{marathon_id}", response_model=MessageResponse)
|
||
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||
# Only creator or admin can delete
|
||
await require_creator(db, current_user, marathon_id)
|
||
marathon = await get_marathon_or_404(db, marathon_id)
|
||
|
||
await db.delete(marathon)
|
||
await db.commit()
|
||
|
||
return MessageResponse(message="Marathon deleted")
|
||
|
||
|
||
@router.post("/{marathon_id}/start", response_model=MarathonResponse)
|
||
async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||
# Require organizer role
|
||
await require_organizer(db, current_user, marathon_id)
|
||
marathon = await get_marathon_or_404(db, marathon_id)
|
||
|
||
if marathon.status != MarathonStatus.PREPARING.value:
|
||
raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
|
||
|
||
# Check if there are approved games
|
||
games_result = await db.execute(
|
||
select(Game).where(
|
||
Game.marathon_id == marathon_id,
|
||
Game.status == GameStatus.APPROVED.value,
|
||
)
|
||
)
|
||
approved_games = games_result.scalars().all()
|
||
|
||
if len(approved_games) == 0:
|
||
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
|
||
|
||
# Check that all approved challenge-based games have at least one challenge
|
||
# Playthrough games don't need challenges
|
||
games_without_challenges = []
|
||
for game in approved_games:
|
||
if game.is_playthrough:
|
||
continue # Игры типа "Прохождение" не требуют челленджей
|
||
challenge_count = await db.scalar(
|
||
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
|
||
)
|
||
if challenge_count == 0:
|
||
games_without_challenges.append(game.title)
|
||
|
||
if games_without_challenges:
|
||
games_list = ", ".join(games_without_challenges)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"У следующих игр нет челленджей: {games_list}"
|
||
)
|
||
|
||
marathon.status = MarathonStatus.ACTIVE.value
|
||
|
||
# Log activity
|
||
activity = Activity(
|
||
marathon_id=marathon_id,
|
||
user_id=current_user.id,
|
||
type=ActivityType.START_MARATHON.value,
|
||
data={"title": marathon.title},
|
||
)
|
||
db.add(activity)
|
||
|
||
await db.commit()
|
||
|
||
# Send Telegram notifications
|
||
await telegram_notifier.notify_marathon_start(db, marathon_id, marathon.title)
|
||
|
||
return await get_marathon(marathon_id, current_user, db)
|
||
|
||
|
||
@router.post("/{marathon_id}/finish", response_model=MarathonResponse)
|
||
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||
from app.services.coins import coins_service
|
||
|
||
# Require organizer role
|
||
await require_organizer(db, current_user, marathon_id)
|
||
marathon = await get_marathon_or_404(db, marathon_id)
|
||
|
||
if marathon.status != MarathonStatus.ACTIVE.value:
|
||
raise HTTPException(status_code=400, detail="Marathon is not active")
|
||
|
||
marathon.status = MarathonStatus.FINISHED.value
|
||
|
||
# Award coins for top 3 places (only in certified marathons)
|
||
if marathon.is_certified:
|
||
# Get top 3 participants by total_points
|
||
top_result = await db.execute(
|
||
select(Participant)
|
||
.options(selectinload(Participant.user))
|
||
.where(Participant.marathon_id == marathon_id)
|
||
.order_by(Participant.total_points.desc())
|
||
.limit(3)
|
||
)
|
||
top_participants = top_result.scalars().all()
|
||
|
||
for place, participant in enumerate(top_participants, start=1):
|
||
if participant.total_points > 0: # Only award if they have points
|
||
await coins_service.award_marathon_place(
|
||
db, participant.user, marathon, place
|
||
)
|
||
|
||
# Log activity
|
||
activity = Activity(
|
||
marathon_id=marathon_id,
|
||
user_id=current_user.id,
|
||
type=ActivityType.FINISH_MARATHON.value,
|
||
data={"title": marathon.title},
|
||
)
|
||
db.add(activity)
|
||
|
||
await db.commit()
|
||
|
||
# Send Telegram notifications
|
||
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
|
||
|
||
return await get_marathon(marathon_id, current_user, db)
|
||
|
||
|
||
@router.post("/join", response_model=MarathonResponse)
|
||
async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession):
|
||
result = await db.execute(
|
||
select(Marathon).where(func.upper(Marathon.invite_code) == data.invite_code.upper())
|
||
)
|
||
marathon = result.scalar_one_or_none()
|
||
|
||
if not marathon:
|
||
raise HTTPException(status_code=404, detail="Invalid invite code")
|
||
|
||
if marathon.status == MarathonStatus.FINISHED.value:
|
||
raise HTTPException(status_code=400, detail="Marathon has already finished")
|
||
|
||
# Check if already participant
|
||
existing = await get_participation(db, current_user.id, marathon.id)
|
||
if existing:
|
||
raise HTTPException(status_code=400, detail="Already joined this marathon")
|
||
|
||
participant = Participant(
|
||
user_id=current_user.id,
|
||
marathon_id=marathon.id,
|
||
role=ParticipantRole.PARTICIPANT.value, # Regular participant
|
||
)
|
||
db.add(participant)
|
||
|
||
# Log activity
|
||
activity = Activity(
|
||
marathon_id=marathon.id,
|
||
user_id=current_user.id,
|
||
type=ActivityType.JOIN.value,
|
||
data={"nickname": current_user.nickname},
|
||
)
|
||
db.add(activity)
|
||
|
||
await db.commit()
|
||
|
||
return await get_marathon(marathon.id, current_user, db)
|
||
|
||
|
||
@router.post("/{marathon_id}/join", response_model=MarathonResponse)
|
||
async def join_public_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||
"""Join a public marathon without invite code"""
|
||
marathon = await get_marathon_or_404(db, marathon_id)
|
||
|
||
if not marathon.is_public:
|
||
raise HTTPException(status_code=403, detail="This marathon is private. Use invite code to join.")
|
||
|
||
if marathon.status == MarathonStatus.FINISHED.value:
|
||
raise HTTPException(status_code=400, detail="Marathon has already finished")
|
||
|
||
# Check if already participant
|
||
existing = await get_participation(db, current_user.id, marathon.id)
|
||
if existing:
|
||
raise HTTPException(status_code=400, detail="Already joined this marathon")
|
||
|
||
participant = Participant(
|
||
user_id=current_user.id,
|
||
marathon_id=marathon.id,
|
||
role=ParticipantRole.PARTICIPANT.value,
|
||
)
|
||
db.add(participant)
|
||
|
||
# Log activity
|
||
activity = Activity(
|
||
marathon_id=marathon.id,
|
||
user_id=current_user.id,
|
||
type=ActivityType.JOIN.value,
|
||
data={"nickname": current_user.nickname},
|
||
)
|
||
db.add(activity)
|
||
|
||
await db.commit()
|
||
|
||
return await get_marathon(marathon.id, current_user, db)
|
||
|
||
|
||
@router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser])
|
||
async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||
marathon = await get_marathon_or_404(db, marathon_id)
|
||
|
||
# For private marathons, require participation (or admin)
|
||
if not marathon.is_public and not current_user.is_admin:
|
||
participation = await get_participation(db, current_user.id, marathon_id)
|
||
if not participation:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="You are not a participant of this private marathon",
|
||
)
|
||
|
||
result = await db.execute(
|
||
select(Participant)
|
||
.options(
|
||
selectinload(Participant.user).selectinload(User.equipped_frame),
|
||
selectinload(Participant.user).selectinload(User.equipped_title),
|
||
selectinload(Participant.user).selectinload(User.equipped_name_color),
|
||
selectinload(Participant.user).selectinload(User.equipped_background),
|
||
)
|
||
.where(Participant.marathon_id == marathon_id)
|
||
.order_by(Participant.joined_at)
|
||
)
|
||
participants = result.scalars().all()
|
||
|
||
return [
|
||
ParticipantWithUser(
|
||
id=p.id,
|
||
role=p.role,
|
||
total_points=p.total_points,
|
||
current_streak=p.current_streak,
|
||
drop_count=p.drop_count,
|
||
joined_at=p.joined_at,
|
||
user=UserPublic.model_validate(p.user),
|
||
)
|
||
for p in participants
|
||
]
|
||
|
||
|
||
@router.patch("/{marathon_id}/participants/{user_id}/role", response_model=ParticipantWithUser)
|
||
async def set_participant_role(
|
||
marathon_id: int,
|
||
user_id: int,
|
||
data: SetParticipantRole,
|
||
current_user: CurrentUser,
|
||
db: DbSession,
|
||
):
|
||
"""Set participant's role (only creator can do this)"""
|
||
# Only creator can change roles
|
||
marathon = await require_creator(db, current_user, marathon_id)
|
||
|
||
# Cannot change creator's role
|
||
if user_id == marathon.creator_id:
|
||
raise HTTPException(status_code=400, detail="Cannot change creator's role")
|
||
|
||
# Get participant
|
||
result = await db.execute(
|
||
select(Participant)
|
||
.options(
|
||
selectinload(Participant.user).selectinload(User.equipped_frame),
|
||
selectinload(Participant.user).selectinload(User.equipped_title),
|
||
selectinload(Participant.user).selectinload(User.equipped_name_color),
|
||
selectinload(Participant.user).selectinload(User.equipped_background),
|
||
)
|
||
.where(
|
||
Participant.marathon_id == marathon_id,
|
||
Participant.user_id == user_id,
|
||
)
|
||
)
|
||
participant = result.scalar_one_or_none()
|
||
if not participant:
|
||
raise HTTPException(status_code=404, detail="Participant not found")
|
||
|
||
participant.role = data.role
|
||
await db.commit()
|
||
await db.refresh(participant)
|
||
|
||
return ParticipantWithUser(
|
||
id=participant.id,
|
||
role=participant.role,
|
||
total_points=participant.total_points,
|
||
current_streak=participant.current_streak,
|
||
drop_count=participant.drop_count,
|
||
joined_at=participant.joined_at,
|
||
user=UserPublic.model_validate(participant.user),
|
||
)
|
||
|
||
|
||
@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
|
||
async def get_leaderboard(
|
||
marathon_id: int,
|
||
db: DbSession,
|
||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_auth),
|
||
):
|
||
"""
|
||
Get marathon leaderboard.
|
||
Public marathons: no auth required.
|
||
Private marathons: requires auth + participation check.
|
||
"""
|
||
marathon = await get_marathon_or_404(db, marathon_id)
|
||
|
||
# For private marathons, require authentication and participation
|
||
if not marathon.is_public:
|
||
if not credentials:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Authentication required for private marathon leaderboard",
|
||
headers={"WWW-Authenticate": "Bearer"},
|
||
)
|
||
|
||
payload = decode_access_token(credentials.credentials)
|
||
if not payload:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Invalid or expired token",
|
||
headers={"WWW-Authenticate": "Bearer"},
|
||
)
|
||
|
||
user_id = int(payload.get("sub"))
|
||
participant = await get_participant(db, user_id, marathon_id)
|
||
if not participant:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="You are not a participant of this marathon",
|
||
)
|
||
|
||
result = await db.execute(
|
||
select(Participant)
|
||
.options(
|
||
selectinload(Participant.user).selectinload(User.equipped_frame),
|
||
selectinload(Participant.user).selectinload(User.equipped_title),
|
||
selectinload(Participant.user).selectinload(User.equipped_name_color),
|
||
selectinload(Participant.user).selectinload(User.equipped_background),
|
||
)
|
||
.where(Participant.marathon_id == marathon_id)
|
||
.order_by(Participant.total_points.desc())
|
||
)
|
||
participants = result.scalars().all()
|
||
|
||
leaderboard = []
|
||
for rank, p in enumerate(participants, 1):
|
||
# Count completed and dropped assignments
|
||
completed = await db.scalar(
|
||
select(func.count()).select_from(Assignment).where(
|
||
Assignment.participant_id == p.id,
|
||
Assignment.status == AssignmentStatus.COMPLETED.value,
|
||
)
|
||
)
|
||
dropped = await db.scalar(
|
||
select(func.count()).select_from(Assignment).where(
|
||
Assignment.participant_id == p.id,
|
||
Assignment.status == AssignmentStatus.DROPPED.value,
|
||
)
|
||
)
|
||
|
||
leaderboard.append(LeaderboardEntry(
|
||
rank=rank,
|
||
user=UserPublic.model_validate(p.user),
|
||
total_points=p.total_points,
|
||
current_streak=p.current_streak,
|
||
completed_count=completed,
|
||
dropped_count=dropped,
|
||
))
|
||
|
||
return leaderboard
|
||
|
||
|
||
@router.get("/{marathon_id}/cover")
|
||
async def get_marathon_cover(marathon_id: int, db: DbSession):
|
||
"""Get marathon cover image"""
|
||
marathon = await get_marathon_or_404(db, marathon_id)
|
||
|
||
if not marathon.cover_path:
|
||
raise HTTPException(status_code=404, detail="Marathon has no cover")
|
||
|
||
file_data = await storage_service.get_file(marathon.cover_path, "covers")
|
||
if not file_data:
|
||
raise HTTPException(status_code=404, detail="Cover not found in storage")
|
||
|
||
content, content_type = file_data
|
||
|
||
return Response(
|
||
content=content,
|
||
media_type=content_type,
|
||
headers={
|
||
"Cache-Control": "public, max-age=3600",
|
||
}
|
||
)
|
||
|
||
|
||
@router.post("/{marathon_id}/cover", response_model=MarathonResponse)
|
||
async def upload_marathon_cover(
|
||
marathon_id: int,
|
||
current_user: CurrentUser,
|
||
db: DbSession,
|
||
file: UploadFile = File(...),
|
||
):
|
||
"""Upload marathon cover image (organizers only, preparing status)"""
|
||
await require_organizer(db, current_user, marathon_id)
|
||
marathon = await get_marathon_or_404(db, marathon_id)
|
||
|
||
if marathon.status != MarathonStatus.PREPARING.value:
|
||
raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon")
|
||
|
||
# Validate file
|
||
if not file.content_type or not file.content_type.startswith("image/"):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="File must be an image",
|
||
)
|
||
|
||
contents = await file.read()
|
||
if len(contents) > settings.MAX_UPLOAD_SIZE:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
|
||
)
|
||
|
||
# Get file extension
|
||
ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
|
||
if ext not in settings.ALLOWED_IMAGE_EXTENSIONS:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
|
||
)
|
||
|
||
# Delete old cover if exists
|
||
if marathon.cover_path:
|
||
await storage_service.delete_file(marathon.cover_path)
|
||
|
||
# Upload file
|
||
filename = storage_service.generate_filename(marathon_id, file.filename)
|
||
file_path = await storage_service.upload_file(
|
||
content=contents,
|
||
folder="covers",
|
||
filename=filename,
|
||
content_type=file.content_type or "image/jpeg",
|
||
)
|
||
|
||
# Update marathon with cover path and URL
|
||
marathon.cover_path = file_path
|
||
marathon.cover_url = f"/api/v1/marathons/{marathon_id}/cover"
|
||
await db.commit()
|
||
|
||
return await get_marathon(marathon_id, current_user, db)
|
||
|
||
|
||
@router.delete("/{marathon_id}/cover", response_model=MarathonResponse)
|
||
async def delete_marathon_cover(
|
||
marathon_id: int,
|
||
current_user: CurrentUser,
|
||
db: DbSession,
|
||
):
|
||
"""Delete marathon cover image (organizers only, preparing status)"""
|
||
await require_organizer(db, current_user, marathon_id)
|
||
marathon = await get_marathon_or_404(db, marathon_id)
|
||
|
||
if marathon.status != MarathonStatus.PREPARING.value:
|
||
raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon")
|
||
|
||
if not marathon.cover_path:
|
||
raise HTTPException(status_code=400, detail="Marathon has no cover")
|
||
|
||
# Delete file from storage
|
||
await storage_service.delete_file(marathon.cover_path)
|
||
|
||
marathon.cover_path = None
|
||
marathon.cover_url = None
|
||
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'}"
|
||
)
|