diff --git a/.env.example b/.env.example index af8ba84..68cdca4 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,5 @@ PUBLIC_URL=https://your-domain.com # Frontend (for build) VITE_API_URL=/api/v1 + +RATE_LIMIT_ENABLED=false \ No newline at end of file diff --git a/backend/alembic/versions/003_create_admin_user.py b/backend/alembic/versions/003_create_admin_user.py index dabe4c9..2ff54c2 100644 --- a/backend/alembic/versions/003_create_admin_user.py +++ b/backend/alembic/versions/003_create_admin_user.py @@ -26,8 +26,8 @@ def upgrade() -> None: # Insert admin user (ignore if already exists) op.execute(f""" - INSERT INTO users (login, password_hash, nickname, role, created_at) - VALUES ('admin', '{password_hash}', 'Admin', 'admin', NOW()) + INSERT INTO users (login, password_hash, nickname, role, is_banned, created_at) + VALUES ('admin', '{password_hash}', 'Admin', 'admin', false, NOW()) ON CONFLICT (login) DO UPDATE SET password_hash = '{password_hash}', role = 'admin' diff --git a/backend/alembic/versions/020_add_game_types.py b/backend/alembic/versions/020_add_game_types.py new file mode 100644 index 0000000..59e54c1 --- /dev/null +++ b/backend/alembic/versions/020_add_game_types.py @@ -0,0 +1,156 @@ +"""Add game types (playthrough/challenges) and bonus assignments + +Revision ID: 020_add_game_types +Revises: 019_add_marathon_cover +Create Date: 2024-12-26 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision: str = '020_add_game_types' +down_revision: Union[str, None] = '019_add_marathon_cover' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def column_exists(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + columns = [col['name'] for col in inspector.get_columns(table_name)] + return column_name in columns + + +def table_exists(table_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + return table_name in inspector.get_table_names() + + +def upgrade() -> None: + # === Games table: добавляем поля для типа игры === + + # game_type - тип игры (playthrough/challenges) + if not column_exists('games', 'game_type'): + op.add_column('games', sa.Column( + 'game_type', + sa.String(20), + nullable=False, + server_default='challenges' + )) + + # playthrough_points - очки за прохождение + if not column_exists('games', 'playthrough_points'): + op.add_column('games', sa.Column( + 'playthrough_points', + sa.Integer(), + nullable=True + )) + + # playthrough_description - описание прохождения + if not column_exists('games', 'playthrough_description'): + op.add_column('games', sa.Column( + 'playthrough_description', + sa.Text(), + nullable=True + )) + + # playthrough_proof_type - тип пруфа для прохождения + if not column_exists('games', 'playthrough_proof_type'): + op.add_column('games', sa.Column( + 'playthrough_proof_type', + sa.String(20), + nullable=True + )) + + # playthrough_proof_hint - подсказка для пруфа + if not column_exists('games', 'playthrough_proof_hint'): + op.add_column('games', sa.Column( + 'playthrough_proof_hint', + sa.Text(), + nullable=True + )) + + # === Assignments table: добавляем поля для прохождений === + + # game_id - ссылка на игру (для playthrough) + if not column_exists('assignments', 'game_id'): + op.add_column('assignments', sa.Column( + 'game_id', + sa.Integer(), + sa.ForeignKey('games.id', ondelete='CASCADE'), + nullable=True + )) + op.create_index('ix_assignments_game_id', 'assignments', ['game_id']) + + # is_playthrough - флаг прохождения + if not column_exists('assignments', 'is_playthrough'): + op.add_column('assignments', sa.Column( + 'is_playthrough', + sa.Boolean(), + nullable=False, + server_default='false' + )) + + # Делаем challenge_id nullable (для playthrough заданий) + # SQLite не поддерживает ALTER COLUMN, поэтому проверяем dialect + bind = op.get_bind() + if bind.dialect.name != 'sqlite': + op.alter_column('assignments', 'challenge_id', nullable=True) + + # === Создаём таблицу bonus_assignments === + + if not table_exists('bonus_assignments'): + op.create_table( + 'bonus_assignments', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('main_assignment_id', sa.Integer(), + sa.ForeignKey('assignments.id', ondelete='CASCADE'), + nullable=False, index=True), + sa.Column('challenge_id', sa.Integer(), + sa.ForeignKey('challenges.id', ondelete='CASCADE'), + nullable=False, index=True), + sa.Column('status', sa.String(20), nullable=False, server_default='pending'), + sa.Column('proof_path', sa.String(500), nullable=True), + sa.Column('proof_url', sa.Text(), nullable=True), + sa.Column('proof_comment', sa.Text(), nullable=True), + sa.Column('points_earned', sa.Integer(), nullable=False, server_default='0'), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, + server_default=sa.func.now()), + ) + + +def downgrade() -> None: + # Удаляем таблицу bonus_assignments + if table_exists('bonus_assignments'): + op.drop_table('bonus_assignments') + + # Удаляем поля из assignments + if column_exists('assignments', 'is_playthrough'): + op.drop_column('assignments', 'is_playthrough') + + if column_exists('assignments', 'game_id'): + op.drop_index('ix_assignments_game_id', 'assignments') + op.drop_column('assignments', 'game_id') + + # Удаляем поля из games + if column_exists('games', 'playthrough_proof_hint'): + op.drop_column('games', 'playthrough_proof_hint') + + if column_exists('games', 'playthrough_proof_type'): + op.drop_column('games', 'playthrough_proof_type') + + if column_exists('games', 'playthrough_description'): + op.drop_column('games', 'playthrough_description') + + if column_exists('games', 'playthrough_points'): + op.drop_column('games', 'playthrough_points') + + if column_exists('games', 'game_type'): + op.drop_column('games', 'game_type') diff --git a/backend/alembic/versions/021_add_bonus_disputes.py b/backend/alembic/versions/021_add_bonus_disputes.py new file mode 100644 index 0000000..a9c25b7 --- /dev/null +++ b/backend/alembic/versions/021_add_bonus_disputes.py @@ -0,0 +1,100 @@ +"""Add bonus assignment disputes support + +Revision ID: 021_add_bonus_disputes +Revises: 020_add_game_types +Create Date: 2024-12-29 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision: str = '021_add_bonus_disputes' +down_revision: Union[str, None] = '020_add_game_types' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def column_exists(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + columns = [col['name'] for col in inspector.get_columns(table_name)] + return column_name in columns + + +def constraint_exists(table_name: str, constraint_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + constraints = inspector.get_unique_constraints(table_name) + return any(c['name'] == constraint_name for c in constraints) + + +def upgrade() -> None: + bind = op.get_bind() + + # Add bonus_assignment_id column to disputes + if not column_exists('disputes', 'bonus_assignment_id'): + op.add_column('disputes', sa.Column( + 'bonus_assignment_id', + sa.Integer(), + nullable=True + )) + op.create_foreign_key( + 'fk_disputes_bonus_assignment_id', + 'disputes', + 'bonus_assignments', + ['bonus_assignment_id'], + ['id'], + ondelete='CASCADE' + ) + op.create_index('ix_disputes_bonus_assignment_id', 'disputes', ['bonus_assignment_id']) + + # Drop the unique index on assignment_id first (required before making nullable) + if bind.dialect.name != 'sqlite': + try: + op.drop_index('ix_disputes_assignment_id', 'disputes') + except Exception: + pass # Index might not exist + + # Make assignment_id nullable (PostgreSQL only, SQLite doesn't support ALTER COLUMN) + if bind.dialect.name != 'sqlite': + op.alter_column('disputes', 'assignment_id', nullable=True) + + # Create a non-unique index on assignment_id + try: + op.create_index('ix_disputes_assignment_id_non_unique', 'disputes', ['assignment_id']) + except Exception: + pass # Index might already exist + + +def downgrade() -> None: + bind = op.get_bind() + + # Remove non-unique index + try: + op.drop_index('ix_disputes_assignment_id_non_unique', table_name='disputes') + except Exception: + pass + + # Make assignment_id not nullable again + if bind.dialect.name != 'sqlite': + op.alter_column('disputes', 'assignment_id', nullable=False) + + # Recreate unique index + try: + op.create_index('ix_disputes_assignment_id', 'disputes', ['assignment_id'], unique=True) + except Exception: + pass + + # Remove foreign key, index and column + if column_exists('disputes', 'bonus_assignment_id'): + try: + op.drop_constraint('fk_disputes_bonus_assignment_id', 'disputes', type_='foreignkey') + except Exception: + pass + op.drop_index('ix_disputes_bonus_assignment_id', table_name='disputes') + op.drop_column('disputes', 'bonus_assignment_id') diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index 130e045..b8671b6 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -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'}" + ) diff --git a/backend/app/api/v1/assignments.py b/backend/app/api/v1/assignments.py index 089c545..3ef1651 100644 --- a/backend/app/api/v1/assignments.py +++ b/backend/app/api/v1/assignments.py @@ -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, + ) diff --git a/backend/app/api/v1/games.py b/backend/app/api/v1/games.py index aa4fded..addfdaa 100644 --- a/backend/app/api/v1/games.py +++ b/backend/app/api/v1/games.py @@ -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 diff --git a/backend/app/api/v1/marathons.py b/backend/app/api/v1/marathons.py index f93186c..b82c1d6 100644 --- a/backend/app/api/v1/marathons.py +++ b/backend/app/api/v1/marathons.py @@ -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'}" + ) diff --git a/backend/app/api/v1/wheel.py b/backend/app/api/v1/wheel.py index 012f99f..88eed1f 100644 --- a/backend/app/api/v1/wheel.py +++ b/backend/app/api/v1/wheel.py @@ -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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 1a12cb4..b3e5ce3 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -6,6 +6,7 @@ class Settings(BaseSettings): # App APP_NAME: str = "Game Marathon" DEBUG: bool = False + RATE_LIMIT_ENABLED: bool = True # Set to False to disable rate limiting # Database DATABASE_URL: str = "postgresql+asyncpg://marathon:marathon@localhost:5432/marathon" diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py index 8bf13d7..4ea7ea2 100644 --- a/backend/app/core/rate_limit.py +++ b/backend/app/core/rate_limit.py @@ -1,5 +1,10 @@ from slowapi import Limiter from slowapi.util import get_remote_address +from app.core.config import settings # Rate limiter using client IP address as key -limiter = Limiter(key_func=get_remote_address) +# Can be disabled via RATE_LIMIT_ENABLED=false in .env +limiter = Limiter( + key_func=get_remote_address, + enabled=settings.RATE_LIMIT_ENABLED +) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 89456cc..6dcf494 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,9 +1,10 @@ from app.models.user import User, UserRole from app.models.marathon import Marathon, MarathonStatus, GameProposalMode from app.models.participant import Participant, ParticipantRole -from app.models.game import Game, GameStatus +from app.models.game import Game, GameStatus, GameType from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType from app.models.assignment import Assignment, AssignmentStatus +from app.models.bonus_assignment import BonusAssignment, BonusAssignmentStatus from app.models.activity import Activity, ActivityType from app.models.event import Event, EventType from app.models.swap_request import SwapRequest, SwapRequestStatus @@ -22,12 +23,15 @@ __all__ = [ "ParticipantRole", "Game", "GameStatus", + "GameType", "Challenge", "ChallengeType", "Difficulty", "ProofType", "Assignment", "AssignmentStatus", + "BonusAssignment", + "BonusAssignmentStatus", "Activity", "ActivityType", "Event", diff --git a/backend/app/models/admin_log.py b/backend/app/models/admin_log.py index b7fae4e..b1a1b0a 100644 --- a/backend/app/models/admin_log.py +++ b/backend/app/models/admin_log.py @@ -30,6 +30,10 @@ class AdminActionType(str, Enum): ADMIN_2FA_SUCCESS = "admin_2fa_success" ADMIN_2FA_FAIL = "admin_2fa_fail" + # Dispute actions + DISPUTE_RESOLVE_VALID = "dispute_resolve_valid" + DISPUTE_RESOLVE_INVALID = "dispute_resolve_invalid" + class AdminLog(Base): __tablename__ = "admin_logs" diff --git a/backend/app/models/assignment.py b/backend/app/models/assignment.py index 798610b..f12b381 100644 --- a/backend/app/models/assignment.py +++ b/backend/app/models/assignment.py @@ -18,8 +18,12 @@ class Assignment(Base): id: Mapped[int] = mapped_column(primary_key=True) participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True) - challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE")) + challenge_id: Mapped[int | None] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"), nullable=True) # None для playthrough status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value) + + # Для прохождений (playthrough) + game_id: Mapped[int | None] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), nullable=True, index=True) + is_playthrough: Mapped[bool] = mapped_column(Boolean, default=False) event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created is_event_assignment: Mapped[bool] = mapped_column(Boolean, default=False, index=True) # True for Common Enemy assignments event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) # Link to event @@ -33,6 +37,8 @@ class Assignment(Base): # Relationships participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments") - challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments") + challenge: Mapped["Challenge | None"] = relationship("Challenge", back_populates="assignments") + game: Mapped["Game | None"] = relationship("Game", back_populates="playthrough_assignments", foreign_keys=[game_id]) event: Mapped["Event | None"] = relationship("Event", back_populates="assignments") dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False, cascade="all, delete-orphan", passive_deletes=True) + bonus_assignments: Mapped[list["BonusAssignment"]] = relationship("BonusAssignment", back_populates="main_assignment", cascade="all, delete-orphan") diff --git a/backend/app/models/bonus_assignment.py b/backend/app/models/bonus_assignment.py new file mode 100644 index 0000000..af4b986 --- /dev/null +++ b/backend/app/models/bonus_assignment.py @@ -0,0 +1,48 @@ +from datetime import datetime +from enum import Enum +from sqlalchemy import String, Text, DateTime, ForeignKey, Integer +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class BonusAssignmentStatus(str, Enum): + PENDING = "pending" + COMPLETED = "completed" + + +class BonusAssignment(Base): + """Бонусные челленджи для игр типа 'playthrough'""" + __tablename__ = "bonus_assignments" + + id: Mapped[int] = mapped_column(primary_key=True) + main_assignment_id: Mapped[int] = mapped_column( + ForeignKey("assignments.id", ondelete="CASCADE"), + index=True + ) + challenge_id: Mapped[int] = mapped_column( + ForeignKey("challenges.id", ondelete="CASCADE"), + index=True + ) + status: Mapped[str] = mapped_column( + String(20), + default=BonusAssignmentStatus.PENDING.value + ) + proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + proof_url: Mapped[str | None] = mapped_column(Text, nullable=True) + proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True) + points_earned: Mapped[int] = mapped_column(Integer, default=0) + completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Relationships + main_assignment: Mapped["Assignment"] = relationship( + "Assignment", + back_populates="bonus_assignments" + ) + challenge: Mapped["Challenge"] = relationship("Challenge") + dispute: Mapped["Dispute"] = relationship( + "Dispute", + back_populates="bonus_assignment", + uselist=False, + ) diff --git a/backend/app/models/dispute.py b/backend/app/models/dispute.py index e833ad8..b027a2c 100644 --- a/backend/app/models/dispute.py +++ b/backend/app/models/dispute.py @@ -8,16 +8,19 @@ from app.core.database import Base class DisputeStatus(str, Enum): OPEN = "open" + PENDING_ADMIN = "pending_admin" # Voting ended, waiting for admin decision RESOLVED_VALID = "valid" RESOLVED_INVALID = "invalid" class Dispute(Base): - """Dispute against a completed assignment's proof""" + """Dispute against a completed assignment's or bonus assignment's proof""" __tablename__ = "disputes" id: Mapped[int] = mapped_column(primary_key=True) - assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), unique=True, index=True) + # Either assignment_id OR bonus_assignment_id should be set (not both) + assignment_id: Mapped[int | None] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=True, index=True) + bonus_assignment_id: Mapped[int | None] = mapped_column(ForeignKey("bonus_assignments.id", ondelete="CASCADE"), nullable=True, index=True) raised_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) reason: Mapped[str] = mapped_column(Text, nullable=False) status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value) @@ -26,6 +29,7 @@ class Dispute(Base): # Relationships assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="dispute") + bonus_assignment: Mapped["BonusAssignment"] = relationship("BonusAssignment", back_populates="dispute") raised_by: Mapped["User"] = relationship("User", foreign_keys=[raised_by_id]) comments: Mapped[list["DisputeComment"]] = relationship("DisputeComment", back_populates="dispute", cascade="all, delete-orphan") votes: Mapped[list["DisputeVote"]] = relationship("DisputeVote", back_populates="dispute", cascade="all, delete-orphan") diff --git a/backend/app/models/game.py b/backend/app/models/game.py index 9f06c4d..a76fecb 100644 --- a/backend/app/models/game.py +++ b/backend/app/models/game.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from sqlalchemy import String, DateTime, ForeignKey, Text +from sqlalchemy import String, DateTime, ForeignKey, Text, Integer from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base @@ -12,6 +12,11 @@ class GameStatus(str, Enum): REJECTED = "rejected" # Отклонена +class GameType(str, Enum): + PLAYTHROUGH = "playthrough" # Прохождение игры + CHALLENGES = "challenges" # Челленджи + + class Game(Base): __tablename__ = "games" @@ -26,6 +31,15 @@ class Game(Base): approved_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + # Тип игры + game_type: Mapped[str] = mapped_column(String(20), default=GameType.CHALLENGES.value, nullable=False) + + # Поля для типа "Прохождение" (заполняются только для playthrough) + playthrough_points: Mapped[int | None] = mapped_column(Integer, nullable=True) + playthrough_description: Mapped[str | None] = mapped_column(Text, nullable=True) + playthrough_proof_type: Mapped[str | None] = mapped_column(String(20), nullable=True) # screenshot, video, steam + playthrough_proof_hint: Mapped[str | None] = mapped_column(Text, nullable=True) + # Relationships marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games") proposed_by: Mapped["User"] = relationship( @@ -43,6 +57,12 @@ class Game(Base): back_populates="game", cascade="all, delete-orphan" ) + # Assignments для прохождений (playthrough) + playthrough_assignments: Mapped[list["Assignment"]] = relationship( + "Assignment", + back_populates="game", + foreign_keys="Assignment.game_id" + ) @property def is_approved(self) -> bool: @@ -51,3 +71,11 @@ class Game(Base): @property def is_pending(self) -> bool: return self.status == GameStatus.PENDING.value + + @property + def is_playthrough(self) -> bool: + return self.game_type == GameType.PLAYTHROUGH.value + + @property + def is_challenges(self) -> bool: + return self.game_type == GameType.CHALLENGES.value diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index e49064c..a034ff3 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -46,6 +46,10 @@ from app.schemas.assignment import ( CompleteResult, DropResult, EventAssignmentResponse, + BonusAssignmentResponse, + CompleteBonusAssignment, + BonusCompleteResult, + AvailableGamesCount, ) from app.schemas.activity import ( ActivityResponse, @@ -144,6 +148,10 @@ __all__ = [ "CompleteResult", "DropResult", "EventAssignmentResponse", + "BonusAssignmentResponse", + "CompleteBonusAssignment", + "BonusCompleteResult", + "AvailableGamesCount", # Activity "ActivityResponse", "FeedResponse", diff --git a/backend/app/schemas/assignment.py b/backend/app/schemas/assignment.py index b03557e..82cd04e 100644 --- a/backend/app/schemas/assignment.py +++ b/backend/app/schemas/assignment.py @@ -1,7 +1,7 @@ from datetime import datetime from pydantic import BaseModel -from app.schemas.game import GameResponse +from app.schemas.game import GameResponse, GameShort, PlaythroughInfo from app.schemas.challenge import ChallengeResponse @@ -14,9 +14,26 @@ class CompleteAssignment(BaseModel): comment: str | None = None -class AssignmentResponse(BaseModel): +class BonusAssignmentResponse(BaseModel): + """Ответ с информацией о бонусном челлендже""" id: int challenge: ChallengeResponse + status: str # pending, completed + proof_url: str | None = None + proof_comment: str | None = None + points_earned: int = 0 + completed_at: datetime | None = None + + class Config: + from_attributes = True + + +class AssignmentResponse(BaseModel): + id: int + challenge: ChallengeResponse | None # None для playthrough + game: GameShort | None = None # Заполняется для playthrough + is_playthrough: bool = False + playthrough_info: PlaythroughInfo | None = None # Заполняется для playthrough status: str proof_url: str | None = None proof_comment: str | None = None @@ -25,6 +42,7 @@ class AssignmentResponse(BaseModel): started_at: datetime completed_at: datetime | None = None drop_penalty: int = 0 # Calculated penalty if dropped + bonus_challenges: list[BonusAssignmentResponse] = [] # Для playthrough class Config: from_attributes = True @@ -33,7 +51,10 @@ class AssignmentResponse(BaseModel): class SpinResult(BaseModel): assignment_id: int game: GameResponse - challenge: ChallengeResponse + challenge: ChallengeResponse | None # None для playthrough + is_playthrough: bool = False + playthrough_info: PlaythroughInfo | None = None # Заполняется для playthrough + bonus_challenges: list[ChallengeResponse] = [] # Для playthrough - список доступных бонусных челленджей can_drop: bool drop_penalty: int @@ -60,3 +81,22 @@ class EventAssignmentResponse(BaseModel): class Config: from_attributes = True + + +class CompleteBonusAssignment(BaseModel): + """Запрос на завершение бонусного челленджа""" + proof_url: str | None = None + comment: str | None = None + + +class BonusCompleteResult(BaseModel): + """Результат завершения бонусного челленджа""" + bonus_assignment_id: int + points_earned: int + total_bonus_points: int # Сумма очков за все бонусные челленджи + + +class AvailableGamesCount(BaseModel): + """Количество доступных игр для спина""" + available: int + total: int diff --git a/backend/app/schemas/dispute.py b/backend/app/schemas/dispute.py index 0acf1d2..16a505d 100644 --- a/backend/app/schemas/dispute.py +++ b/backend/app/schemas/dispute.py @@ -1,8 +1,13 @@ from datetime import datetime +from typing import TYPE_CHECKING from pydantic import BaseModel, Field from app.schemas.user import UserPublic -from app.schemas.challenge import ChallengeResponse +from app.schemas.challenge import ChallengeResponse, GameShort + +if TYPE_CHECKING: + from app.schemas.game import PlaythroughInfo + from app.schemas.assignment import BonusAssignmentResponse class DisputeCreate(BaseModel): @@ -63,7 +68,10 @@ class DisputeResponse(BaseModel): class AssignmentDetailResponse(BaseModel): """Detailed assignment information with proofs and dispute""" id: int - challenge: ChallengeResponse + challenge: ChallengeResponse | None # None for playthrough + game: GameShort | None = None # For playthrough + is_playthrough: bool = False + playthrough_info: dict | None = None # For playthrough (description, points, proof_type, proof_hint) participant: UserPublic status: str proof_url: str | None # External URL (YouTube, etc.) @@ -75,6 +83,7 @@ class AssignmentDetailResponse(BaseModel): completed_at: datetime | None can_dispute: bool # True if <24h since completion and not own assignment dispute: DisputeResponse | None + bonus_challenges: list[dict] | None = None # For playthrough class Config: from_attributes = True @@ -83,7 +92,11 @@ class AssignmentDetailResponse(BaseModel): class ReturnedAssignmentResponse(BaseModel): """Returned assignment that needs to be redone""" id: int - challenge: ChallengeResponse + challenge: ChallengeResponse | None = None # For challenge assignments + is_playthrough: bool = False + game_id: int | None = None # For playthrough assignments + game_title: str | None = None + game_cover_url: str | None = None original_completed_at: datetime dispute_reason: str diff --git a/backend/app/schemas/game.py b/backend/app/schemas/game.py index 5c24dc7..9aa3213 100644 --- a/backend/app/schemas/game.py +++ b/backend/app/schemas/game.py @@ -1,6 +1,9 @@ from datetime import datetime -from pydantic import BaseModel, Field, HttpUrl +from typing import Self +from pydantic import BaseModel, Field, model_validator +from app.models.game import GameType +from app.models.challenge import ProofType from app.schemas.user import UserPublic @@ -13,17 +16,47 @@ class GameBase(BaseModel): class GameCreate(GameBase): cover_url: str | None = None + # Тип игры + game_type: GameType = GameType.CHALLENGES + + # Поля для типа "Прохождение" + playthrough_points: int | None = Field(None, ge=1, le=500) + playthrough_description: str | None = None + playthrough_proof_type: ProofType | None = None + playthrough_proof_hint: str | None = None + + @model_validator(mode='after') + def validate_playthrough_fields(self) -> Self: + if self.game_type == GameType.PLAYTHROUGH: + if self.playthrough_points is None: + raise ValueError('playthrough_points обязателен для типа "Прохождение"') + if self.playthrough_description is None: + raise ValueError('playthrough_description обязателен для типа "Прохождение"') + if self.playthrough_proof_type is None: + raise ValueError('playthrough_proof_type обязателен для типа "Прохождение"') + return self + class GameUpdate(BaseModel): title: str | None = Field(None, min_length=1, max_length=100) download_url: str | None = None genre: str | None = None + # Тип игры + game_type: GameType | None = None + + # Поля для типа "Прохождение" + playthrough_points: int | None = Field(None, ge=1, le=500) + playthrough_description: str | None = None + playthrough_proof_type: ProofType | None = None + playthrough_proof_hint: str | None = None + class GameShort(BaseModel): id: int title: str cover_url: str | None = None + game_type: str = "challenges" class Config: from_attributes = True @@ -38,5 +71,22 @@ class GameResponse(GameBase): challenges_count: int = 0 created_at: datetime + # Тип игры + game_type: str = "challenges" + + # Поля для типа "Прохождение" + playthrough_points: int | None = None + playthrough_description: str | None = None + playthrough_proof_type: str | None = None + playthrough_proof_hint: str | None = None + class Config: from_attributes = True + + +class PlaythroughInfo(BaseModel): + """Информация о прохождении для игр типа playthrough""" + description: str + points: int + proof_type: str + proof_hint: str | None = None diff --git a/backend/app/services/dispute_scheduler.py b/backend/app/services/dispute_scheduler.py index 83e9757..c022ff1 100644 --- a/backend/app/services/dispute_scheduler.py +++ b/backend/app/services/dispute_scheduler.py @@ -1,5 +1,5 @@ """ -Dispute Scheduler for automatic dispute resolution after 24 hours. +Dispute Scheduler - marks disputes as pending admin review after 24 hours. """ import asyncio from datetime import datetime, timedelta @@ -8,16 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus -from app.services.disputes import dispute_service +from app.services.telegram_notifier import telegram_notifier # Configuration CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes -DISPUTE_WINDOW_HOURS = 24 # Disputes auto-resolve after 24 hours +DISPUTE_WINDOW_HOURS = 24 # Disputes need admin decision after 24 hours class DisputeScheduler: - """Background scheduler for automatic dispute resolution.""" + """Background scheduler that marks expired disputes for admin review.""" def __init__(self): self._running = False @@ -55,7 +55,7 @@ class DisputeScheduler: await asyncio.sleep(CHECK_INTERVAL_SECONDS) async def _process_expired_disputes(self, db: AsyncSession) -> None: - """Process and resolve expired disputes.""" + """Mark expired disputes as pending admin review.""" cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS) # Find all open disputes that have expired @@ -63,7 +63,6 @@ class DisputeScheduler: select(Dispute) .options( selectinload(Dispute.votes), - selectinload(Dispute.assignment).selectinload(Assignment.participant), ) .where( Dispute.status == DisputeStatus.OPEN.value, @@ -74,15 +73,25 @@ class DisputeScheduler: for dispute in expired_disputes: try: - result_status, votes_valid, votes_invalid = await dispute_service.resolve_dispute( - db, dispute.id - ) + # Count votes for logging + 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) + + # Mark as pending admin decision + dispute.status = DisputeStatus.PENDING_ADMIN.value + print( - f"[DisputeScheduler] Auto-resolved dispute {dispute.id}: " - f"{result_status} (valid: {votes_valid}, invalid: {votes_invalid})" + f"[DisputeScheduler] Dispute {dispute.id} marked as pending admin " + f"(recommendation: {'invalid' if votes_invalid > votes_valid else 'valid'}, " + f"votes: {votes_valid} valid, {votes_invalid} invalid)" ) except Exception as e: - print(f"[DisputeScheduler] Failed to resolve dispute {dispute.id}: {e}") + print(f"[DisputeScheduler] Failed to process dispute {dispute.id}: {e}") + + if expired_disputes: + await db.commit() + # Notify admins about pending disputes + await telegram_notifier.notify_admin_disputes_pending(db, len(expired_disputes)) # Global scheduler instance diff --git a/backend/app/services/disputes.py b/backend/app/services/disputes.py index 4cda1e3..717ade7 100644 --- a/backend/app/services/disputes.py +++ b/backend/app/services/disputes.py @@ -23,12 +23,15 @@ class DisputeService: Returns: Tuple of (result_status, votes_valid, votes_invalid) """ - # Get dispute with votes and assignment + from app.models import BonusAssignment, BonusAssignmentStatus + + # Get dispute with votes, assignment and bonus_assignment result = await db.execute( select(Dispute) .options( selectinload(Dispute.votes), selectinload(Dispute.assignment).selectinload(Assignment.participant), + selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant), ) .where(Dispute.id == dispute_id) ) @@ -46,9 +49,12 @@ class DisputeService: # Determine result: tie goes to the accused (valid) if votes_invalid > votes_valid: - # Proof is invalid - mark assignment as RETURNED + # Proof is invalid result_status = DisputeStatus.RESOLVED_INVALID.value - await self._handle_invalid_proof(db, dispute) + if dispute.bonus_assignment_id: + await self._handle_invalid_bonus_proof(db, dispute) + else: + await self._handle_invalid_proof(db, dispute) else: # Proof is valid (or tie) result_status = DisputeStatus.RESOLVED_VALID.value @@ -60,7 +66,11 @@ class DisputeService: await db.commit() # Send Telegram notification about dispute resolution - await self._notify_dispute_resolved(db, dispute, result_status == DisputeStatus.RESOLVED_INVALID.value) + is_invalid = result_status == DisputeStatus.RESOLVED_INVALID.value + if dispute.bonus_assignment_id: + await self._notify_bonus_dispute_resolved(db, dispute, is_invalid) + else: + await self._notify_dispute_resolved(db, dispute, is_invalid) return result_status, votes_valid, votes_invalid @@ -72,12 +82,13 @@ class DisputeService: ) -> None: """Send notification about dispute resolution to the assignment owner.""" try: - # Get assignment with challenge and marathon info + # Get assignment with challenge/game and marathon info result = await db.execute( select(Assignment) .options( selectinload(Assignment.participant), - selectinload(Assignment.challenge).selectinload(Challenge.game) + selectinload(Assignment.challenge).selectinload(Challenge.game), + selectinload(Assignment.game), # For playthrough ) .where(Assignment.id == dispute.assignment_id) ) @@ -86,12 +97,19 @@ class DisputeService: return participant = assignment.participant - challenge = assignment.challenge - game = challenge.game if challenge else None + + # Get title and marathon_id based on assignment type + if assignment.is_playthrough: + title = f"Прохождение: {assignment.game.title}" + marathon_id = assignment.game.marathon_id + else: + challenge = assignment.challenge + title = challenge.title if challenge else "Unknown" + marathon_id = challenge.game.marathon_id if challenge and challenge.game else 0 # Get marathon result = await db.execute( - select(Marathon).where(Marathon.id == game.marathon_id if game else 0) + select(Marathon).where(Marathon.id == marathon_id) ) marathon = result.scalar_one_or_none() @@ -100,12 +118,86 @@ class DisputeService: db, user_id=participant.user_id, marathon_title=marathon.title, - challenge_title=challenge.title if challenge else "Unknown", + challenge_title=title, is_valid=is_valid ) except Exception as e: print(f"[DisputeService] Failed to send notification: {e}") + async def _notify_bonus_dispute_resolved( + self, + db: AsyncSession, + dispute: Dispute, + is_invalid: bool + ) -> None: + """Send notification about bonus dispute resolution to the assignment owner.""" + try: + bonus_assignment = dispute.bonus_assignment + main_assignment = bonus_assignment.main_assignment + participant = main_assignment.participant + + # Get marathon info + result = await db.execute( + select(Game).where(Game.id == main_assignment.game_id) + ) + game = result.scalar_one_or_none() + if not game: + return + + result = await db.execute( + select(Marathon).where(Marathon.id == game.marathon_id) + ) + marathon = result.scalar_one_or_none() + + # Get challenge title + result = await db.execute( + select(Challenge).where(Challenge.id == bonus_assignment.challenge_id) + ) + challenge = result.scalar_one_or_none() + title = f"Бонус: {challenge.title}" if challenge else "Бонусный челлендж" + + if marathon and participant: + await telegram_notifier.notify_dispute_resolved( + db, + user_id=participant.user_id, + marathon_title=marathon.title, + challenge_title=title, + is_valid=not is_invalid + ) + except Exception as e: + print(f"[DisputeService] Failed to send bonus dispute notification: {e}") + + async def _handle_invalid_bonus_proof(self, db: AsyncSession, dispute: Dispute) -> None: + """ + Handle the case when bonus proof is determined to be invalid. + + - Reset bonus assignment to PENDING + - If main playthrough was already completed, subtract bonus points from participant + """ + from app.models import BonusAssignment, BonusAssignmentStatus, AssignmentStatus + + bonus_assignment = dispute.bonus_assignment + main_assignment = bonus_assignment.main_assignment + participant = main_assignment.participant + + # If main playthrough was already completed, we need to subtract the bonus points + if main_assignment.status == AssignmentStatus.COMPLETED.value: + points_to_subtract = bonus_assignment.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) + print(f"[DisputeService] Subtracted {points_to_subtract} points from participant {participant.id}") + + # Reset bonus assignment + bonus_assignment.status = BonusAssignmentStatus.PENDING.value + bonus_assignment.proof_path = None + bonus_assignment.proof_url = None + bonus_assignment.proof_comment = None + bonus_assignment.points_earned = 0 + bonus_assignment.completed_at = None + + print(f"[DisputeService] Bonus assignment {bonus_assignment.id} reset to PENDING due to invalid dispute") + async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None: """ Handle the case when proof is determined to be invalid. @@ -113,7 +205,10 @@ class DisputeService: - Mark assignment as RETURNED - Subtract points from participant - Reset streak if it was affected + - For playthrough: also reset bonus assignments """ + from app.models import BonusAssignment, BonusAssignmentStatus + assignment = dispute.assignment participant = assignment.participant @@ -121,22 +216,45 @@ class DisputeService: points_to_subtract = assignment.points_earned participant.total_points = max(0, participant.total_points - points_to_subtract) + # Reset streak - the completion was invalid so streak should be broken + participant.current_streak = 0 + # Reset assignment assignment.status = AssignmentStatus.RETURNED.value assignment.points_earned = 0 # Keep proof data so it can be reviewed + # For playthrough: reset all bonus assignments + if assignment.is_playthrough: + result = await db.execute( + select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id) + ) + bonus_assignments = result.scalars().all() + for ba in bonus_assignments: + 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 + print(f"[DisputeService] Reset {len(bonus_assignments)} bonus assignments for playthrough {assignment.id}") + print(f"[DisputeService] Assignment {assignment.id} marked as RETURNED, " f"subtracted {points_to_subtract} points from participant {participant.id}") async def get_pending_disputes(self, db: AsyncSession, older_than_hours: int = 24) -> list[Dispute]: - """Get all open disputes older than specified hours""" + """Get all open disputes (both regular and bonus) older than specified hours""" from datetime import timedelta + from app.models import BonusAssignment cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours) result = await db.execute( select(Dispute) + .options( + selectinload(Dispute.assignment), + selectinload(Dispute.bonus_assignment), + ) .where( Dispute.status == DisputeStatus.OPEN.value, Dispute.created_at < cutoff_time, diff --git a/backend/app/services/telegram_notifier.py b/backend/app/services/telegram_notifier.py index 6ae2e57..04ca4d0 100644 --- a/backend/app/services/telegram_notifier.py +++ b/backend/app/services/telegram_notifier.py @@ -312,6 +312,43 @@ class TelegramNotifier: ) return await self.notify_user(db, user_id, message) + async def notify_admin_disputes_pending( + self, + db: AsyncSession, + count: int + ) -> bool: + """Notify admin about disputes waiting for decision.""" + if not settings.TELEGRAM_ADMIN_ID: + logger.warning("[Notify] No TELEGRAM_ADMIN_ID configured") + return False + + admin_url = f"{settings.FRONTEND_URL}/admin/disputes" + use_inline_button = admin_url.startswith("https://") + + if use_inline_button: + message = ( + f"⚠️ {count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения\n\n" + f"Голосование завершено, требуется ваше решение." + ) + reply_markup = { + "inline_keyboard": [[ + {"text": "Открыть оспаривания", "url": admin_url} + ]] + } + else: + message = ( + f"⚠️ {count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения\n\n" + f"Голосование завершено, требуется ваше решение.\n\n" + f"🔗 {admin_url}" + ) + reply_markup = None + + return await self.send_message( + int(settings.TELEGRAM_ADMIN_ID), + message, + reply_markup=reply_markup + ) + # Global instance telegram_notifier = TelegramNotifier() diff --git a/docker-compose.yml b/docker-compose.yml index 2ad6884..feffa76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot} BOT_API_SECRET: ${BOT_API_SECRET:-} DEBUG: ${DEBUG:-false} + RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true} # S3 Storage S3_ENABLED: ${S3_ENABLED:-false} S3_BUCKET_NAME: ${S3_BUCKET_NAME:-} diff --git a/docs/disputes.md b/docs/disputes.md new file mode 100644 index 0000000..665d2aa --- /dev/null +++ b/docs/disputes.md @@ -0,0 +1,381 @@ +# Система оспаривания (Disputes) + +Система оспаривания позволяет участникам марафона проверять доказательства (пруфы) выполненных заданий друг друга и голосовать за их валидность. + +## Общий принцип работы + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ ЖИЗНЕННЫЙ ЦИКЛ ДИСПУТА │ +└──────────────────────────────────────────────────────────────────────────┘ + + Участник A Участник B Все участники + выполняет задание замечает проблему голосуют + │ │ │ + ▼ ▼ ▼ + ┌───────────┐ 24 часа ┌───────────┐ 24 часа ┌───────────┐ + │ Завершено │ ─────────────────▶ │ Оспорено │ ─────────────▶ │ Решено │ + │ │ окно оспаривания │ (OPEN) │ голосование │ │ + └───────────┘ └───────────┘ └───────────┘ + │ │ │ + │ │ ├──▶ VALID (пруф OK) + │ │ │ Задание остаётся + │ │ │ + │ │ └──▶ INVALID (пруф не OK) + │ │ Задание возвращается + │ │ + └──────────────────────────────────┘ + Если не оспорено — задание засчитано +``` + +## Кто может оспаривать + +| Условие | Можно оспорить? | +|---------|-----------------| +| Своё задание | ❌ Нельзя | +| Чужое задание (статус COMPLETED) | ✅ Можно (в течение 24 часов) | +| Чужое задание (статус ACTIVE/DROPPED) | ❌ Нельзя | +| Прошло более 24 часов с момента выполнения | ❌ Нельзя | +| Уже есть активный диспут на это задание | ❌ Нельзя | + +## Типы оспариваемых заданий + +### 1. Обычные челленджи + +Можно оспорить выполнение любого челленджа. При признании пруфа невалидным: +- Задание переходит в статус `RETURNED` +- Очки снимаются с участника +- Участник должен переделать задание + +### 2. Прохождения игр (Playthrough) + +Основное задание прохождения можно оспорить. При признании невалидным: +- Основное задание переходит в статус `RETURNED` +- Очки снимаются +- **Все бонусные челленджи сбрасываются** в статус `PENDING` + +### 3. Бонусные челленджи + +Каждый бонусный челлендж можно оспорить **отдельно**. При признании невалидным: +- Только этот бонусный челлендж сбрасывается в `PENDING` +- Участник может переделать его +- Основное задание и другие бонусы не затрагиваются + +**Важно:** Очки за бонусные челленджи начисляются только при завершении основного задания. Поэтому при оспаривании бонуса очки не снимаются — просто сбрасывается статус. + +## Процесс голосования + +### Создание диспута + +1. Участник нажимает "Оспорить" на странице деталей задания +2. Вводит причину оспаривания (минимум 10 символов) +3. Создаётся диспут со статусом `OPEN` +4. Владельцу задания отправляется уведомление в Telegram + +### Голосование + +- **Любой участник марафона** может голосовать +- Два варианта: "Валидно" (пруф OK) или "Невалидно" (пруф не OK) +- Можно **изменить** свой голос до завершения голосования +- Голосование длится **24 часа** с момента создания диспута + +### Комментарии + +- Участники могут оставлять комментарии для обсуждения +- Комментарии помогают другим участникам принять решение +- Комментарии доступны только пока диспут открыт + +## Разрешение диспута + +### Автоматическое (по таймеру) + +Через 24 часа диспут автоматически разрешается: +- Система подсчитывает голоса +- При равенстве голосов — **в пользу обвиняемого** (пруф валиден) +- Результат: `RESOLVED_VALID` или `RESOLVED_INVALID` + +**Технически:** Фоновый планировщик (`DisputeScheduler`) проверяет истёкшие диспуты каждые 5 минут. + +### Результаты + +| Результат | Условие | Последствия | +|-----------|---------|-------------| +| `RESOLVED_VALID` | Голосов "валидно" ≥ голосов "невалидно" | Задание остаётся выполненным | +| `RESOLVED_INVALID` | Голосов "невалидно" > голосов "валидно" | Задание возвращается | + +### Что происходит при INVALID + +**Для обычного задания:** +1. Статус → `RETURNED` +2. Очки (`points_earned`) вычитаются из общего счёта участника +3. Пруфы сохраняются для истории + +**Для прохождения:** +1. Основное задание → `RETURNED` +2. Очки вычитаются +3. Все бонусные челленджи сбрасываются: + - Статус → `PENDING` + - Пруфы удаляются + - Очки обнуляются + +**Для бонусного челленджа:** +1. Только этот бонус → `PENDING` +2. Пруфы удаляются +3. Можно переделать + +## API эндпоинты + +### Создание диспута + +``` +POST /api/v1/assignments/{assignment_id}/dispute +POST /api/v1/bonus-assignments/{bonus_id}/dispute + +Body: { "reason": "Описание проблемы с пруфом..." } +``` + +### Голосование + +``` +POST /api/v1/disputes/{dispute_id}/vote + +Body: { "vote": true } // true = валидно, false = невалидно +``` + +### Комментарии + +``` +POST /api/v1/disputes/{dispute_id}/comments + +Body: { "text": "Текст комментария" } +``` + +### Получение информации + +``` +GET /api/v1/assignments/{assignment_id} + +// В ответе включено поле dispute с полной информацией: +{ + "dispute": { + "id": 1, + "status": "open", + "reason": "...", + "votes_valid": 3, + "votes_invalid": 2, + "my_vote": true, + "expires_at": "2024-12-30T12:00:00Z", + "comments": [...], + "votes": [...] + } +} +``` + +## Структура базы данных + +### Таблица `disputes` + +| Поле | Тип | Описание | +|------|-----|----------| +| `id` | INT | PK | +| `assignment_id` | INT | FK → assignments (nullable для бонусов) | +| `bonus_assignment_id` | INT | FK → bonus_assignments (nullable для основных) | +| `raised_by_id` | INT | FK → users | +| `reason` | TEXT | Причина оспаривания | +| `status` | VARCHAR(20) | open / valid / invalid | +| `created_at` | DATETIME | Время создания | +| `resolved_at` | DATETIME | Время разрешения | + +**Ограничение:** Либо `assignment_id`, либо `bonus_assignment_id` должен быть заполнен (не оба). + +### Таблица `dispute_votes` + +| Поле | Тип | Описание | +|------|-----|----------| +| `id` | INT | PK | +| `dispute_id` | INT | FK → disputes | +| `user_id` | INT | FK → users | +| `vote` | BOOLEAN | true = валидно, false = невалидно | +| `created_at` | DATETIME | Время голоса | + +**Ограничение:** Один голос на участника (`UNIQUE dispute_id + user_id`). + +### Таблица `dispute_comments` + +| Поле | Тип | Описание | +|------|-----|----------| +| `id` | INT | PK | +| `dispute_id` | INT | FK → disputes | +| `user_id` | INT | FK → users | +| `text` | TEXT | Текст комментария | +| `created_at` | DATETIME | Время комментария | + +## UI компоненты + +### Кнопка "Оспорить" + +Появляется на странице деталей задания (`/assignments/{id}`) если: +- Статус задания: `COMPLETED` +- Это не своё задание +- Прошло меньше 24 часов с момента выполнения +- Нет активного диспута + +### Секция диспута + +Показывается если есть активный или завершённый диспут: +- Статус (открыт / валиден / невалиден) +- Таймер до окончания (для открытых) +- Причина оспаривания +- Кнопки голосования с счётчиками +- Секция комментариев + +### Для бонусных челленджей + +На каждом бонусном челлендже: +- Маленькая кнопка "Оспорить" (если можно) +- Бейдж статуса диспута +- Компактное голосование прямо в карточке бонуса + +## Уведомления + +### Telegram уведомления + +| Событие | Получатель | Сообщение | +|---------|------------|-----------| +| Создание диспута | Владелец задания | "Ваше задание X оспорено в марафоне Y" | +| Результат: валидно | Владелец задания | "Диспут по заданию X решён в вашу пользу" | +| Результат: невалидно | Владелец задания | "Диспут по заданию X решён не в вашу пользу, задание возвращено" | + +## Конфигурация + +```python +# backend/app/api/v1/assignments.py +DISPUTE_WINDOW_HOURS = 24 # Окно для создания диспута + +# backend/app/services/dispute_scheduler.py +CHECK_INTERVAL_SECONDS = 300 # Проверка каждые 5 минут +DISPUTE_WINDOW_HOURS = 24 # Время голосования +``` + +## Пример сценария + +### Сценарий 1: Успешное оспаривание + +1. **Иван** выполняет челлендж "Пройти уровень без смертей" +2. **Иван** прикладывает скриншот финального экрана +3. **Петр** открывает детали задания и видит, что на скриншоте есть смерти +4. **Петр** нажимает "Оспорить" и пишет: "На скриншоте видно 3 смерти" +5. Участники марафона голосуют: 5 за "невалидно", 2 за "валидно" +6. Через 24 часа диспут закрывается как `RESOLVED_INVALID` +7. Задание Ивана возвращается, очки снимаются +8. Иван получает уведомление и должен переделать задание + +### Сценарий 2: Оспаривание бонуса + +1. **Анна** проходит игру и выполняет бонусный челлендж +2. **Сергей** замечает проблему с пруфом бонуса +3. **Сергей** оспаривает только бонусный челлендж +4. Голосование: 4 за "невалидно", 1 за "валидно" +5. Результат: бонус сбрасывается в `PENDING` +6. Основное задание Анны **не затронуто** +7. Анна может переделать бонус (пока основное задание активно) + +## Ручное разрешение диспутов + +Администраторы системы и организаторы марафонов могут вручную разрешать диспуты, не дожидаясь окончания 24-часового окна голосования. + +### Кто может разрешать + +| Роль | Доступ | +|------|--------| +| **Системный админ** | Все диспуты во всех марафонах (`/admin/disputes`) | +| **Организатор марафона** | Только диспуты в своём марафоне (секция "Оспаривания" на странице марафона) | + +### Интерфейс для системных админов + +**Путь:** `/admin/disputes` + +- Отдельная страница в админ-панели +- Фильтры: "Открытые" / "Все" +- Показывает диспуты из всех марафонов +- Информация: марафон, задание, участник, кто оспорил, причина +- Счётчик голосов и время до истечения +- Кнопки "Валидно" / "Невалидно" для мгновенного решения + +### Интерфейс для организаторов + +**Путь:** На странице марафона (`/marathons/{id}`) → секция "Оспаривания" + +- Доступна только организаторам активного марафона +- Показывает только диспуты текущего марафона +- Компактный вид с возможностью раскрытия +- Ссылка на страницу задания для детального просмотра + +### API для ручного разрешения + +**Системные админы:** +``` +GET /api/v1/admin/disputes?status_filter=open|all +POST /api/v1/admin/disputes/{dispute_id}/resolve + +Body: { "is_valid": true|false } +``` + +**Организаторы марафона:** +``` +GET /api/v1/marathons/{marathon_id}/disputes?status_filter=open|all +POST /api/v1/marathons/{marathon_id}/disputes/{dispute_id}/resolve + +Body: { "is_valid": true|false } +``` + +### Что происходит при ручном разрешении + +Логика идентична автоматическому разрешению: + +**При `is_valid: true`:** +- Диспут закрывается как `RESOLVED_VALID` +- Задание остаётся выполненным +- Участник получает уведомление + +**При `is_valid: false`:** +- Диспут закрывается как `RESOLVED_INVALID` +- Задание возвращается, очки снимаются +- Участник получает уведомление + +### Важно: логика снятия очков за бонусы + +При отклонении бонусного диспута система проверяет статус основного прохождения: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ БОНУС ПРИЗНАН НЕВАЛИДНЫМ │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Основное прохождение Основное прохождение │ +│ НЕ завершено? УЖЕ завершено? │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌───────────┐ ┌───────────┐ │ +│ │ Просто │ │ Вычитаем │ │ +│ │ сбросить │ │ очки из │ │ +│ │ бонус │ │ участника │ │ +│ └───────────┘ └───────────┘ │ +│ (очки ещё не (очки уже были │ +│ были начислены) начислены при │ +│ завершении прохождения) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Почему так?** Очки за бонусные челленджи начисляются только в момент завершения основного прохождения (чтобы нельзя было получить очки за бонусы и потом дропнуть основное задание). + +## Логирование действий + +Ручное разрешение диспутов логируется в системе: + +| Действие | Тип лога | +|----------|----------| +| Админ подтвердил пруф | `DISPUTE_RESOLVE_VALID` | +| Админ отклонил пруф | `DISPUTE_RESOLVE_INVALID` | + +Логи доступны в `/admin/logs` для аудита действий администраторов. diff --git a/docs/tz-game-types.md b/docs/tz-game-types.md new file mode 100644 index 0000000..ac20516 --- /dev/null +++ b/docs/tz-game-types.md @@ -0,0 +1,906 @@ +# ТЗ: Типы игр "Прохождение" и "Челленджи" + +## Описание задачи + +Добавить систему типов для игр, которая определяет логику выпадения заданий при спине колеса. + +### Два типа игр: + +| Тип | Название | Поведение при выпадении | +|-----|----------|------------------------| +| `playthrough` | Прохождение | Основное задание — пройти игру. Челленджи становятся **дополнительными** заданиями | +| `challenges` | Челленджи | Выдаётся **случайный челлендж** из списка челленджей игры (текущее поведение) | + +--- + +## Детальное описание логики + +### Тип "Прохождение" (`playthrough`) + +**При создании игры** с типом "Прохождение" указываются дополнительные поля: +- **Очки за прохождение** (`playthrough_points`) — количество очков за прохождение игры +- **Описание прохождения** (`playthrough_description`) — описание задания (например: "Пройти основной сюжет игры") +- **Тип пруфа** (`playthrough_proof_type`) — screenshot / video / steam +- **Подсказка для пруфа** (`playthrough_proof_hint`) — опционально (например: "Скриншот финальных титров") + +**При выпадении игры** с типом "Прохождение": + +1. **Основное задание**: Пройти игру (очки и описание берутся из полей игры) +2. **Дополнительные задания**: Все челленджи игры становятся **опциональными** бонусными заданиями +3. **Пруфы**: + - Требуется **отдельный пруф на прохождение** игры (тип из `playthrough_proof_type`) + - Для каждого бонусного челленджа **тоже требуется пруф** (по типу челленджа) + - **Прикрепление файла не обязательно** — можно отправить только комментарий со ссылкой на видео +4. **Система очков**: + - За основное прохождение — `playthrough_points` (указанные при создании) + - За каждый выполненный доп. челлендж — очки челленджа +5. **Завершение**: Задание считается выполненным после прохождения основной игры. Доп. челленджи **не обязательны** — можно выполнять параллельно или игнорировать + +### Тип "Челленджи" (`challenges`) + +При выпадении игры с типом "Челленджи": + +1. Выбирается **один случайный челлендж** из списка челленджей игры +2. Участник выполняет только этот челлендж +3. Логика остаётся **без изменений** (текущее поведение системы) + +--- + +### Фильтрация игр при спине + +При выборе игры для спина необходимо исключать уже пройденные/дропнутые игры: + +| Тип игры | Условие исключения из спина | +|----------|----------------------------| +| `playthrough` | Игра **исключается**, если участник **завершил ИЛИ дропнул** прохождение этой игры | +| `challenges` | Игра **исключается**, только если участник выполнил **все** челленджи этой игры | + +**Логика:** +``` +Для каждой игры в марафоне: + ЕСЛИ game_type == "playthrough": + Проверить: есть ли Assignment с is_playthrough=True для этой игры + со статусом COMPLETED или DROPPED? + Если да → исключить игру + + ЕСЛИ game_type == "challenges": + Получить все челленджи игры + Получить все завершённые Assignment участника для этих челленджей + Если количество завершённых == количество челленджей → исключить игру +``` + +**Важно:** Если все игры исключены (всё пройдено), спин должен вернуть ошибку или специальный статус "Все игры пройдены!" + +### Бонусные челленджи + +Бонусные челленджи доступны **только пока основное задание активно**: +- После **завершения** прохождения — бонусные челленджи недоступны +- После **дропа** прохождения — бонусные челленджи недоступны +- Нельзя вернуться к бонусным челленджам позже + +### Взаимодействие с событиями + +**Все события игнорируются** при выпадении игры с типом `playthrough`: + +| Событие | Поведение для `playthrough` | +|---------|----------------------------| +| **JACKPOT** (x3 за hard) | Игнорируется | +| **GAME_CHOICE** (выбор из 3) | Игнорируется | +| **GOLDEN_HOUR** (x1.5) | Игнорируется | +| **DOUBLE_RISK** (x0.5, бесплатный дроп) | Игнорируется | +| **COMMON_ENEMY** | Игнорируется | +| **SWAP** | Игнорируется | + +Игрок получает стандартные очки `playthrough_points` без модификаторов. + +--- + +## Изменения в Backend + +### 1. Модель Game (`backend/app/models/game.py`) + +Добавить поля для типа игры и прохождения: + +```python +class GameType(str, Enum): + PLAYTHROUGH = "playthrough" # Прохождение + CHALLENGES = "challenges" # Челленджи + +class Game(Base): + # ... существующие поля ... + + # Тип игры + game_type: Mapped[str] = mapped_column( + String(20), + default=GameType.CHALLENGES.value, + nullable=False + ) + + # Поля для типа "Прохождение" (nullable, заполняются только для playthrough) + playthrough_points: Mapped[int | None] = mapped_column( + Integer, + nullable=True + ) + playthrough_description: Mapped[str | None] = mapped_column( + Text, + nullable=True + ) + playthrough_proof_type: Mapped[str | None] = mapped_column( + String(20), # screenshot, video, steam + nullable=True + ) + playthrough_proof_hint: Mapped[str | None] = mapped_column( + Text, + nullable=True + ) +``` + +### 2. Схемы Pydantic (`backend/app/schemas/`) + +Обновить схемы для Game: + +```python +# schemas/game.py +class GameType(str, Enum): + PLAYTHROUGH = "playthrough" + CHALLENGES = "challenges" + +class GameCreate(BaseModel): + # ... существующие поля ... + game_type: GameType = GameType.CHALLENGES + + # Поля для типа "Прохождение" + playthrough_points: int | None = None + playthrough_description: str | None = None + playthrough_proof_type: ProofType | None = None + playthrough_proof_hint: str | None = None + + @model_validator(mode='after') + def validate_playthrough_fields(self) -> Self: + if self.game_type == GameType.PLAYTHROUGH: + if self.playthrough_points is None: + raise ValueError('playthrough_points обязателен для типа "Прохождение"') + if self.playthrough_description is None: + raise ValueError('playthrough_description обязателен для типа "Прохождение"') + if self.playthrough_proof_type is None: + raise ValueError('playthrough_proof_type обязателен для типа "Прохождение"') + if self.playthrough_points < 1 or self.playthrough_points > 500: + raise ValueError('playthrough_points должен быть от 1 до 500') + return self + +class GameResponse(BaseModel): + # ... существующие поля ... + game_type: GameType + playthrough_points: int | None + playthrough_description: str | None + playthrough_proof_type: ProofType | None + playthrough_proof_hint: str | None + +class GameUpdate(BaseModel): + """Схема для редактирования игры""" + title: str | None = None + download_url: str | None = None + genre: str | None = None + game_type: GameType | None = None + playthrough_points: int | None = None + playthrough_description: str | None = None + playthrough_proof_type: ProofType | None = None + playthrough_proof_hint: str | None = None + + @model_validator(mode='after') + def validate_playthrough_fields(self) -> Self: + # Валидация только если меняем на playthrough + if self.game_type == GameType.PLAYTHROUGH: + if self.playthrough_points is not None: + if self.playthrough_points < 1 or self.playthrough_points > 500: + raise ValueError('playthrough_points должен быть от 1 до 500') + return self +``` + +### 3. Миграция Alembic + +```python +# Новая миграция +def upgrade(): + # Тип игры + op.add_column('games', sa.Column( + 'game_type', + sa.String(20), + nullable=False, + server_default='challenges' + )) + + # Поля для прохождения + op.add_column('games', sa.Column( + 'playthrough_points', + sa.Integer(), + nullable=True + )) + op.add_column('games', sa.Column( + 'playthrough_description', + sa.Text(), + nullable=True + )) + op.add_column('games', sa.Column( + 'playthrough_proof_type', + sa.String(20), + nullable=True + )) + op.add_column('games', sa.Column( + 'playthrough_proof_hint', + sa.Text(), + nullable=True + )) + +def downgrade(): + op.drop_column('games', 'playthrough_proof_hint') + op.drop_column('games', 'playthrough_proof_type') + op.drop_column('games', 'playthrough_description') + op.drop_column('games', 'playthrough_points') + op.drop_column('games', 'game_type') +``` + +### 4. Логика спина (`backend/app/api/v1/wheel.py`) + +Изменить функцию `spin_wheel`: + +```python +async def get_available_games( + participant: Participant, + marathon_games: list[Game], + db: AsyncSession +) -> list[Game]: + """Получить список игр, доступных для спина""" + available = [] + + for game in marathon_games: + if game.game_type == GameType.PLAYTHROUGH.value: + # Проверяем, прошёл ли участник эту игру + # Исключаем если COMPLETED или DROPPED + finished = await db.scalar( + select(Assignment) + .where( + Assignment.participant_id == participant.id, + Assignment.game_id == game.id, + Assignment.is_playthrough == True, + Assignment.status.in_([ + AssignmentStatus.COMPLETED.value, + AssignmentStatus.DROPPED.value + ]) + ) + ) + if not finished: + available.append(game) + + else: # GameType.CHALLENGES + # Проверяем, остались ли невыполненные челленджи + completed_challenge_ids = await db.scalars( + 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_challenge_ids.all()) + all_challenge_ids = {c.id for c in game.challenges} + + if completed_ids != all_challenge_ids: + available.append(game) + + return available + + +async def spin_wheel(...): + # Получаем доступные игры (исключаем пройденные) + available_games = await get_available_games(participant, marathon_games, db) + + if not available_games: + raise HTTPException( + status_code=400, + detail="Все игры пройдены! Поздравляем!" + ) + + game = random.choice(available_games) + + if game.game_type == GameType.PLAYTHROUGH.value: + # Для playthrough НЕ выбираем челлендж — основное задание это прохождение + # Данные берутся из полей игры: playthrough_points, playthrough_description + challenge = None # Или создаём виртуальный объект + + # Все челленджи игры становятся дополнительными + bonus_challenges = list(game.challenges) + + # Создаём Assignment с флагом is_playthrough=True + assignment = Assignment( + participant_id=participant.id, + challenge_id=None, # Нет привязки к челленджу + game_id=game.id, # Новое поле — привязка к игре + is_playthrough=True, + status=AssignmentStatus.ACTIVE, + # ... + ) + + else: # GameType.CHALLENGES + # Выбираем случайный НЕВЫПОЛНЕННЫЙ челлендж + completed_challenge_ids = await db.scalars( + 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_challenge_ids.all()) + + available_challenges = [c for c in game.challenges if c.id not in completed_ids] + challenge = random.choice(available_challenges) + bonus_challenges = [] + + assignment = Assignment( + participant_id=participant.id, + challenge_id=challenge.id, + is_playthrough=False, + status=AssignmentStatus.ACTIVE, + # ... + ) + + # ... сохранение Assignment ... +``` + +### 5. Модель Assignment (`backend/app/models/assignment.py`) + +Обновить модель для поддержки прохождений: + +```python +class Assignment(Base): + # ... существующие поля ... + + # Для прохождений: привязка к игре вместо челленджа + game_id: Mapped[int | None] = mapped_column( + ForeignKey("games.id"), + nullable=True + ) + is_playthrough: Mapped[bool] = mapped_column(Boolean, default=False) + + # Relationships + game: Mapped["Game"] = relationship(back_populates="playthrough_assignments") + +# Отдельная таблица для бонусных челленджей +class BonusAssignment(Base): + __tablename__ = "bonus_assignments" + + id: Mapped[int] = mapped_column(primary_key=True) + main_assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id")) + challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id")) + status: Mapped[str] = mapped_column(String(20), default="pending") # pending, completed + proof_path: Mapped[str | None] = mapped_column(Text, nullable=True) + proof_url: Mapped[str | None] = mapped_column(Text, nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(nullable=True) + points_earned: Mapped[int] = mapped_column(Integer, default=0) + + # Relationships + main_assignment: Mapped["Assignment"] = relationship(back_populates="bonus_assignments") + challenge: Mapped["Challenge"] = relationship() +``` + +### 6. API эндпоинты + +Добавить/обновить эндпоинты: + +```python +# Обновить ответ спина +class PlaythroughInfo(BaseModel): + """Информация о прохождении (для playthrough игр)""" + description: str + points: int + +class SpinResult(BaseModel): + assignment_id: int + game: GameResponse + challenge: ChallengeResponse | None # None для playthrough + is_playthrough: bool + playthrough_info: PlaythroughInfo | None # Заполняется для playthrough + bonus_challenges: list[ChallengeResponse] = [] # Для playthrough + can_drop: bool + drop_penalty: int + +# Завершение бонусного челленджа +@router.post("/assignments/{assignment_id}/bonus/{challenge_id}/complete") +async def complete_bonus_challenge( + assignment_id: int, + challenge_id: int, + proof: ProofData, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +) -> BonusAssignmentResponse: + """Завершить дополнительный челлендж для игры-прохождения""" + ... + +# Получение бонусных челленджей +@router.get("/assignments/{assignment_id}/bonus") +async def get_bonus_assignments( + assignment_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +) -> list[BonusAssignmentResponse]: + """Получить список бонусных челленджей и их статус""" + ... + +# Получение количества доступных игр для спина +@router.get("/marathons/{marathon_id}/available-games-count") +async def get_available_games_count( + marathon_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +) -> dict: + """ + Получить количество игр, доступных для спина. + Возвращает: { "available": 5, "total": 10 } + """ + participant = await get_participant(...) + marathon_games = await get_marathon_games(...) + available = await get_available_games(participant, marathon_games, db) + + return { + "available": len(available), + "total": len(marathon_games) + } + +# Редактирование игры +@router.patch("/marathons/{marathon_id}/games/{game_id}") +async def update_game( + marathon_id: int, + game_id: int, + game_data: GameUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +) -> GameResponse: + """ + Редактировать игру. + + Доступно только организатору марафона. + При смене типа на 'playthrough' необходимо указать playthrough_points и playthrough_description. + """ + # Проверка прав (организатор) + # Валидация: если меняем тип на playthrough, проверить что поля заполнены + # Обновление полей + ... +``` + +--- + +## Изменения в Frontend + +### 1. Типы (`frontend/src/types/index.ts`) + +```typescript +export type GameType = 'playthrough' | 'challenges' + +export interface Game { + // ... существующие поля ... + game_type: GameType + playthrough_points: number | null + playthrough_description: string | null +} + +export interface PlaythroughInfo { + description: string + points: number +} + +export interface SpinResult { + assignment_id: number + game: Game + challenge: Challenge | null // null для playthrough + is_playthrough: boolean + playthrough_info: PlaythroughInfo | null + bonus_challenges: Challenge[] + can_drop: boolean + drop_penalty: number +} + +export interface BonusAssignment { + id: number + challenge: Challenge + status: 'pending' | 'completed' + proof_url: string | null + completed_at: string | null + points_earned: number +} + +export interface GameUpdate { + title?: string + download_url?: string + genre?: string + game_type?: GameType + playthrough_points?: number + playthrough_description?: string +} +``` + +### 2. Форма добавления игры + +Добавить выбор типа игры и условные поля: + +```tsx +// components/AddGameForm.tsx +const [gameType, setGameType] = useState('challenges') +const [playthroughPoints, setPlaythroughPoints] = useState(100) +const [playthroughDescription, setPlaythroughDescription] = useState('') + +return ( +
+ {/* ... существующие поля ... */} + + +