Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования - Можно повторно оспаривать после разрешённых споров - Исправлены бонусные очки при перепрохождении после оспаривания - Сброс серии при невалидном пруфе - Колесо показывает только доступные игры - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
@@ -32,3 +32,5 @@ PUBLIC_URL=https://your-domain.com
|
||||
|
||||
# Frontend (for build)
|
||||
VITE_API_URL=/api/v1
|
||||
|
||||
RATE_LIMIT_ENABLED=false
|
||||
@@ -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'
|
||||
|
||||
156
backend/alembic/versions/020_add_game_types.py
Normal file
156
backend/alembic/versions/020_add_game_types.py
Normal file
@@ -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')
|
||||
100
backend/alembic/versions/021_add_bonus_disputes.py
Normal file
100
backend/alembic/versions/021_add_bonus_disputes.py
Normal file
@@ -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')
|
||||
@@ -5,7 +5,10 @@ from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
||||
from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent
|
||||
from app.models import (
|
||||
User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent,
|
||||
Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus
|
||||
)
|
||||
from app.schemas import (
|
||||
UserPublic, MessageResponse,
|
||||
AdminUserResponse, BanUserRequest, AdminResetPasswordRequest, AdminLogResponse, AdminLogsListResponse,
|
||||
@@ -837,3 +840,273 @@ async def get_dashboard(current_user: CurrentUser, db: DbSession):
|
||||
for log in recent_logs
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ============ Disputes Management ============
|
||||
|
||||
class AdminDisputeResponse(BaseModel):
|
||||
id: int
|
||||
assignment_id: int | None
|
||||
bonus_assignment_id: int | None
|
||||
marathon_id: int
|
||||
marathon_title: str
|
||||
challenge_title: str
|
||||
participant_nickname: str
|
||||
raised_by_nickname: str
|
||||
reason: str
|
||||
status: str
|
||||
votes_valid: int
|
||||
votes_invalid: int
|
||||
created_at: str
|
||||
expires_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ResolveDisputeRequest(BaseModel):
|
||||
is_valid: bool = Field(..., description="True = proof is valid, False = proof is invalid")
|
||||
|
||||
|
||||
@router.get("/disputes", response_model=list[AdminDisputeResponse])
|
||||
async def list_disputes(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
status: str = Query("pending", pattern="^(open|pending|all)$"),
|
||||
):
|
||||
"""List all disputes. Admin only.
|
||||
|
||||
Status filter:
|
||||
- pending: disputes waiting for admin decision (default)
|
||||
- open: disputes still in voting phase
|
||||
- all: all disputes
|
||||
"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
from datetime import timedelta
|
||||
DISPUTE_WINDOW_HOURS = 24
|
||||
|
||||
query = (
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.raised_by),
|
||||
selectinload(Dispute.votes),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.game), # For playthrough
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.order_by(Dispute.created_at.desc())
|
||||
)
|
||||
|
||||
if status == "pending":
|
||||
# Disputes waiting for admin decision
|
||||
query = query.where(Dispute.status == DisputeStatus.PENDING_ADMIN.value)
|
||||
elif status == "open":
|
||||
# Disputes still in voting phase
|
||||
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
|
||||
|
||||
result = await db.execute(query)
|
||||
disputes = result.scalars().all()
|
||||
|
||||
response = []
|
||||
for dispute in disputes:
|
||||
# Get info based on dispute type
|
||||
if dispute.bonus_assignment_id:
|
||||
bonus = dispute.bonus_assignment
|
||||
main_assignment = bonus.main_assignment
|
||||
participant = main_assignment.participant
|
||||
challenge_title = f"Бонус: {bonus.challenge.title}"
|
||||
marathon_id = main_assignment.game.marathon_id
|
||||
|
||||
# Get marathon title
|
||||
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = marathon_result.scalar_one_or_none()
|
||||
marathon_title = marathon.title if marathon else "Unknown"
|
||||
else:
|
||||
assignment = dispute.assignment
|
||||
participant = assignment.participant
|
||||
if assignment.is_playthrough:
|
||||
challenge_title = f"Прохождение: {assignment.game.title}"
|
||||
marathon_id = assignment.game.marathon_id
|
||||
else:
|
||||
challenge_title = assignment.challenge.title
|
||||
marathon_id = assignment.challenge.game.marathon_id
|
||||
|
||||
# Get marathon title
|
||||
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = marathon_result.scalar_one_or_none()
|
||||
marathon_title = marathon.title if marathon else "Unknown"
|
||||
|
||||
# Count votes
|
||||
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
|
||||
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
|
||||
|
||||
# Calculate expiry
|
||||
expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||
|
||||
response.append(AdminDisputeResponse(
|
||||
id=dispute.id,
|
||||
assignment_id=dispute.assignment_id,
|
||||
bonus_assignment_id=dispute.bonus_assignment_id,
|
||||
marathon_id=marathon_id,
|
||||
marathon_title=marathon_title,
|
||||
challenge_title=challenge_title,
|
||||
participant_nickname=participant.user.nickname,
|
||||
raised_by_nickname=dispute.raised_by.nickname,
|
||||
reason=dispute.reason,
|
||||
status=dispute.status,
|
||||
votes_valid=votes_valid,
|
||||
votes_invalid=votes_invalid,
|
||||
created_at=dispute.created_at.isoformat(),
|
||||
expires_at=expires_at.isoformat(),
|
||||
))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/disputes/{dispute_id}/resolve", response_model=MessageResponse)
|
||||
async def resolve_dispute(
|
||||
request: Request,
|
||||
dispute_id: int,
|
||||
data: ResolveDisputeRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Manually resolve a dispute. Admin only."""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Get dispute
|
||||
result = await db.execute(
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.where(Dispute.id == dispute_id)
|
||||
)
|
||||
dispute = result.scalar_one_or_none()
|
||||
|
||||
if not dispute:
|
||||
raise HTTPException(status_code=404, detail="Dispute not found")
|
||||
|
||||
# Allow resolving disputes that are either open or pending admin decision
|
||||
if dispute.status not in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]:
|
||||
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||
|
||||
# Determine result
|
||||
if data.is_valid:
|
||||
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||
action_type = AdminActionType.DISPUTE_RESOLVE_VALID.value
|
||||
else:
|
||||
result_status = DisputeStatus.RESOLVED_INVALID.value
|
||||
action_type = AdminActionType.DISPUTE_RESOLVE_INVALID.value
|
||||
|
||||
# Handle invalid proof
|
||||
if dispute.bonus_assignment_id:
|
||||
# Reset bonus assignment
|
||||
bonus = dispute.bonus_assignment
|
||||
main_assignment = bonus.main_assignment
|
||||
participant = main_assignment.participant
|
||||
|
||||
# Only subtract points if main playthrough was already completed
|
||||
# (bonus points are added only when main playthrough is completed)
|
||||
if main_assignment.status == AssignmentStatus.COMPLETED.value:
|
||||
points_to_subtract = bonus.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
# Also reduce the points_earned on the main assignment
|
||||
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
|
||||
|
||||
bonus.status = BonusAssignmentStatus.PENDING.value
|
||||
bonus.proof_path = None
|
||||
bonus.proof_url = None
|
||||
bonus.proof_comment = None
|
||||
bonus.points_earned = 0
|
||||
bonus.completed_at = None
|
||||
else:
|
||||
# Reset main assignment
|
||||
assignment = dispute.assignment
|
||||
participant = assignment.participant
|
||||
|
||||
# Subtract points
|
||||
points_to_subtract = assignment.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
|
||||
# Reset streak - the completion was invalid
|
||||
participant.current_streak = 0
|
||||
|
||||
# Reset assignment
|
||||
assignment.status = AssignmentStatus.RETURNED.value
|
||||
assignment.points_earned = 0
|
||||
|
||||
# For playthrough: reset all bonus assignments
|
||||
if assignment.is_playthrough:
|
||||
bonus_result = await db.execute(
|
||||
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
|
||||
)
|
||||
for ba in bonus_result.scalars().all():
|
||||
ba.status = BonusAssignmentStatus.PENDING.value
|
||||
ba.proof_path = None
|
||||
ba.proof_url = None
|
||||
ba.proof_comment = None
|
||||
ba.points_earned = 0
|
||||
ba.completed_at = None
|
||||
|
||||
# Update dispute
|
||||
dispute.status = result_status
|
||||
dispute.resolved_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Get details for logging
|
||||
if dispute.bonus_assignment_id:
|
||||
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
|
||||
marathon_id = dispute.bonus_assignment.main_assignment.game.marathon_id
|
||||
elif dispute.assignment.is_playthrough:
|
||||
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
|
||||
marathon_id = dispute.assignment.game.marathon_id
|
||||
else:
|
||||
challenge_title = dispute.assignment.challenge.title
|
||||
marathon_id = dispute.assignment.challenge.game.marathon_id
|
||||
|
||||
# Log action
|
||||
await log_admin_action(
|
||||
db, current_user.id, action_type,
|
||||
"dispute", dispute_id,
|
||||
{
|
||||
"challenge_title": challenge_title,
|
||||
"marathon_id": marathon_id,
|
||||
"is_valid": data.is_valid,
|
||||
},
|
||||
request.client.host if request.client else None
|
||||
)
|
||||
|
||||
# Send notification
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
if dispute.bonus_assignment_id:
|
||||
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
|
||||
else:
|
||||
participant_user_id = dispute.assignment.participant.user_id
|
||||
|
||||
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = marathon_result.scalar_one_or_none()
|
||||
|
||||
if marathon:
|
||||
await telegram_notifier.notify_dispute_resolved(
|
||||
db,
|
||||
user_id=participant_user_id,
|
||||
marathon_title=marathon.title,
|
||||
challenge_title=challenge_title,
|
||||
is_valid=data.is_valid
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Assignment details and dispute system endpoints.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -10,12 +10,13 @@ from sqlalchemy.orm import selectinload
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
from app.models import (
|
||||
Assignment, AssignmentStatus, Participant, Challenge, User, Marathon,
|
||||
Dispute, DisputeStatus, DisputeComment, DisputeVote,
|
||||
Dispute, DisputeStatus, DisputeComment, DisputeVote, BonusAssignment, BonusAssignmentStatus,
|
||||
)
|
||||
from app.schemas import (
|
||||
AssignmentDetailResponse, DisputeCreate, DisputeResponse,
|
||||
DisputeCommentCreate, DisputeCommentResponse, DisputeVoteCreate,
|
||||
MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse,
|
||||
BonusAssignmentResponse, CompleteBonusAssignment, BonusCompleteResult,
|
||||
)
|
||||
from app.schemas.user import UserPublic
|
||||
from app.services.storage import storage_service
|
||||
@@ -92,11 +93,18 @@ async def get_assignment_detail(
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get detailed information about an assignment including proofs and dispute"""
|
||||
from app.models import Game
|
||||
|
||||
# Get assignment with all relationships
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game), # For playthrough
|
||||
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough
|
||||
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.raised_by), # Bonus disputes
|
||||
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user),
|
||||
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.votes).selectinload(DisputeVote.user),
|
||||
selectinload(Assignment.participant).selectinload(Participant.user),
|
||||
selectinload(Assignment.dispute).selectinload(Dispute.raised_by),
|
||||
selectinload(Assignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user),
|
||||
@@ -109,8 +117,13 @@ async def get_assignment_detail(
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
# Check user is participant of the marathon
|
||||
# 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
|
||||
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")
|
||||
|
||||
# Check user is participant of the marathon
|
||||
# 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
|
||||
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")
|
||||
|
||||
# Check user is participant of the marathon
|
||||
# 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
|
||||
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,6 +717,11 @@ async def add_dispute_comment(
|
||||
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||
|
||||
# Check user is participant of the marathon
|
||||
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(
|
||||
@@ -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,6 +782,11 @@ async def vote_on_dispute(
|
||||
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||
|
||||
# Check user is participant of the marathon
|
||||
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(
|
||||
@@ -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,8 +860,22 @@ async def get_returned_assignments(
|
||||
)
|
||||
assignments = result.scalars().all()
|
||||
|
||||
return [
|
||||
ReturnedAssignmentResponse(
|
||||
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,
|
||||
@@ -551,6 +897,191 @@ async def get_returned_assignments(
|
||||
),
|
||||
original_completed_at=a.completed_at,
|
||||
dispute_reason=a.dispute.reason if a.dispute else "Оспорено",
|
||||
))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ============ Bonus Assignments Endpoints ============
|
||||
|
||||
@router.get("/assignments/{assignment_id}/bonus", response_model=list[BonusAssignmentResponse])
|
||||
async def get_bonus_assignments(
|
||||
assignment_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Get bonus assignments for a playthrough assignment"""
|
||||
# Get assignment with bonus challenges
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.game),
|
||||
selectinload(Assignment.participant),
|
||||
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
for a in assignments
|
||||
.where(Assignment.id == assignment_id)
|
||||
)
|
||||
assignment = result.scalar_one_or_none()
|
||||
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
if not assignment.is_playthrough:
|
||||
raise HTTPException(status_code=400, detail="This is not a playthrough assignment")
|
||||
|
||||
# Check user is the owner
|
||||
if assignment.participant.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only view your own bonus assignments")
|
||||
|
||||
# Build response
|
||||
return [
|
||||
BonusAssignmentResponse(
|
||||
id=ba.id,
|
||||
challenge=ChallengeResponse(
|
||||
id=ba.challenge.id,
|
||||
title=ba.challenge.title,
|
||||
description=ba.challenge.description,
|
||||
type=ba.challenge.type,
|
||||
difficulty=ba.challenge.difficulty,
|
||||
points=ba.challenge.points,
|
||||
estimated_time=ba.challenge.estimated_time,
|
||||
proof_type=ba.challenge.proof_type,
|
||||
proof_hint=ba.challenge.proof_hint,
|
||||
game=GameShort(
|
||||
id=assignment.game.id,
|
||||
title=assignment.game.title,
|
||||
cover_url=storage_service.get_url(assignment.game.cover_path, "covers") if hasattr(assignment.game, 'cover_path') else None,
|
||||
game_type=assignment.game.game_type,
|
||||
),
|
||||
is_generated=ba.challenge.is_generated,
|
||||
created_at=ba.challenge.created_at,
|
||||
),
|
||||
status=ba.status,
|
||||
proof_url=ba.proof_url,
|
||||
proof_comment=ba.proof_comment,
|
||||
points_earned=ba.points_earned,
|
||||
completed_at=ba.completed_at,
|
||||
)
|
||||
for ba in assignment.bonus_assignments
|
||||
]
|
||||
|
||||
|
||||
@router.post("/assignments/{assignment_id}/bonus/{bonus_id}/complete", response_model=BonusCompleteResult)
|
||||
async def complete_bonus_assignment(
|
||||
assignment_id: int,
|
||||
bonus_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
proof_url: str | None = Form(None),
|
||||
comment: str | None = Form(None),
|
||||
proof_file: UploadFile | None = File(None),
|
||||
):
|
||||
"""Complete a bonus challenge for a playthrough assignment"""
|
||||
from app.core.config import settings
|
||||
|
||||
# Get main assignment
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.participant),
|
||||
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.where(Assignment.id == assignment_id)
|
||||
)
|
||||
assignment = result.scalar_one_or_none()
|
||||
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
if not assignment.is_playthrough:
|
||||
raise HTTPException(status_code=400, detail="This is not a playthrough assignment")
|
||||
|
||||
# Check user is the owner
|
||||
if assignment.participant.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only complete your own bonus assignments")
|
||||
|
||||
# Check main assignment is active or completed (completed allows re-doing bonus after bonus dispute)
|
||||
if assignment.status not in [AssignmentStatus.ACTIVE.value, AssignmentStatus.COMPLETED.value]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Bonus challenges can only be completed while the main assignment is active or completed"
|
||||
)
|
||||
|
||||
# Find the bonus assignment
|
||||
bonus_assignment = None
|
||||
for ba in assignment.bonus_assignments:
|
||||
if ba.id == bonus_id:
|
||||
bonus_assignment = ba
|
||||
break
|
||||
|
||||
if not bonus_assignment:
|
||||
raise HTTPException(status_code=404, detail="Bonus assignment not found")
|
||||
|
||||
if bonus_assignment.status == BonusAssignmentStatus.COMPLETED.value:
|
||||
raise HTTPException(status_code=400, detail="This bonus challenge is already completed")
|
||||
|
||||
# Validate proof (need file, URL, or comment)
|
||||
if not proof_file and not proof_url and not comment:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Необходимо прикрепить файл, ссылку или комментарий"
|
||||
)
|
||||
|
||||
# Handle file upload
|
||||
if proof_file:
|
||||
contents = await proof_file.read()
|
||||
if len(contents) > settings.MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
|
||||
)
|
||||
|
||||
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg"
|
||||
if ext not in settings.ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
|
||||
)
|
||||
|
||||
# Upload file to storage
|
||||
filename = storage_service.generate_filename(bonus_id, proof_file.filename)
|
||||
file_path = await storage_service.upload_file(
|
||||
content=contents,
|
||||
folder="bonus_proofs",
|
||||
filename=filename,
|
||||
content_type=proof_file.content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
bonus_assignment.proof_path = file_path
|
||||
else:
|
||||
bonus_assignment.proof_url = proof_url
|
||||
|
||||
# Complete the bonus assignment
|
||||
bonus_assignment.status = BonusAssignmentStatus.COMPLETED.value
|
||||
bonus_assignment.proof_comment = comment
|
||||
bonus_assignment.points_earned = bonus_assignment.challenge.points
|
||||
bonus_assignment.completed_at = datetime.utcnow()
|
||||
|
||||
# If main assignment is already COMPLETED, add bonus points immediately
|
||||
# This handles the case where a bonus was disputed and user is re-completing it
|
||||
if assignment.status == AssignmentStatus.COMPLETED.value:
|
||||
participant = assignment.participant
|
||||
participant.total_points += bonus_assignment.points_earned
|
||||
assignment.points_earned += bonus_assignment.points_earned
|
||||
|
||||
# NOTE: If main is not completed yet, points will be added when main is completed
|
||||
# This prevents exploiting by dropping the main assignment after getting bonus points
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Calculate total bonus points for this assignment
|
||||
total_bonus_points = sum(
|
||||
ba.points_earned for ba in assignment.bonus_assignments
|
||||
if ba.status == BonusAssignmentStatus.COMPLETED.value
|
||||
)
|
||||
|
||||
return BonusCompleteResult(
|
||||
bonus_assignment_id=bonus_assignment.id,
|
||||
points_earned=bonus_assignment.points_earned,
|
||||
total_bonus_points=total_bonus_points,
|
||||
)
|
||||
|
||||
@@ -7,8 +7,12 @@ from app.api.deps import (
|
||||
require_participant, require_organizer, get_participant,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
|
||||
from app.models import (
|
||||
Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType,
|
||||
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant
|
||||
)
|
||||
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
||||
from app.schemas.assignment import AvailableGamesCount
|
||||
from app.services.storage import storage_service
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
@@ -43,6 +47,12 @@ def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
|
||||
approved_by=UserPublic.model_validate(game.approved_by) if game.approved_by else None,
|
||||
challenges_count=challenges_count,
|
||||
created_at=game.created_at,
|
||||
# Поля для типа игры
|
||||
game_type=game.game_type,
|
||||
playthrough_points=game.playthrough_points,
|
||||
playthrough_description=game.playthrough_description,
|
||||
playthrough_proof_type=game.playthrough_proof_type,
|
||||
playthrough_proof_hint=game.playthrough_proof_hint,
|
||||
)
|
||||
|
||||
|
||||
@@ -145,6 +155,12 @@ async def add_game(
|
||||
proposed_by_id=current_user.id,
|
||||
status=game_status,
|
||||
approved_by_id=current_user.id if is_organizer else None,
|
||||
# Поля для типа игры
|
||||
game_type=data.game_type.value,
|
||||
playthrough_points=data.playthrough_points,
|
||||
playthrough_description=data.playthrough_description,
|
||||
playthrough_proof_type=data.playthrough_proof_type.value if data.playthrough_proof_type else None,
|
||||
playthrough_proof_hint=data.playthrough_proof_hint,
|
||||
)
|
||||
db.add(game)
|
||||
|
||||
@@ -171,6 +187,12 @@ async def add_game(
|
||||
approved_by=UserPublic.model_validate(current_user) if is_organizer else None,
|
||||
challenges_count=0,
|
||||
created_at=game.created_at,
|
||||
# Поля для типа игры
|
||||
game_type=game.game_type,
|
||||
playthrough_points=game.playthrough_points,
|
||||
playthrough_description=game.playthrough_description,
|
||||
playthrough_proof_type=game.playthrough_proof_type,
|
||||
playthrough_proof_hint=game.playthrough_proof_hint,
|
||||
)
|
||||
|
||||
|
||||
@@ -227,6 +249,18 @@ async def update_game(
|
||||
if data.genre is not None:
|
||||
game.genre = data.genre
|
||||
|
||||
# Поля для типа игры
|
||||
if data.game_type is not None:
|
||||
game.game_type = data.game_type.value
|
||||
if data.playthrough_points is not None:
|
||||
game.playthrough_points = data.playthrough_points
|
||||
if data.playthrough_description is not None:
|
||||
game.playthrough_description = data.playthrough_description
|
||||
if data.playthrough_proof_type is not None:
|
||||
game.playthrough_proof_type = data.playthrough_proof_type.value
|
||||
if data.playthrough_proof_hint is not None:
|
||||
game.playthrough_proof_hint = data.playthrough_proof_hint
|
||||
|
||||
await db.commit()
|
||||
|
||||
return await get_game(game_id, current_user, db)
|
||||
@@ -398,3 +432,159 @@ async def upload_cover(
|
||||
await db.commit()
|
||||
|
||||
return await get_game(game_id, current_user, db)
|
||||
|
||||
|
||||
async def get_available_games_for_participant(
|
||||
db, participant: Participant, marathon_id: int
|
||||
) -> tuple[list[Game], int]:
|
||||
"""
|
||||
Получить список игр, доступных для спина участника.
|
||||
|
||||
Возвращает кортеж (доступные игры, всего игр).
|
||||
|
||||
Логика исключения:
|
||||
- playthrough: игра исключается если участник завершил ИЛИ дропнул прохождение
|
||||
- challenges: игра исключается если участник выполнил ВСЕ челленджи
|
||||
"""
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
# Получаем все одобренные игры с челленджами
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.options(selectinload(Game.challenges))
|
||||
.where(
|
||||
Game.marathon_id == marathon_id,
|
||||
Game.status == GameStatus.APPROVED.value
|
||||
)
|
||||
)
|
||||
all_games = list(result.scalars().all())
|
||||
|
||||
# Фильтруем игры с челленджами (для типа challenges)
|
||||
# или игры с заполненными playthrough полями (для типа playthrough)
|
||||
games_with_content = []
|
||||
for game in all_games:
|
||||
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||
# Для playthrough не нужны челленджи
|
||||
if game.playthrough_points and game.playthrough_description:
|
||||
games_with_content.append(game)
|
||||
else:
|
||||
# Для challenges нужны челленджи
|
||||
if game.challenges:
|
||||
games_with_content.append(game)
|
||||
|
||||
total_games = len(games_with_content)
|
||||
if total_games == 0:
|
||||
return [], 0
|
||||
|
||||
# Получаем завершённые/дропнутые assignments участника
|
||||
finished_statuses = [AssignmentStatus.COMPLETED.value, AssignmentStatus.DROPPED.value]
|
||||
|
||||
# Для playthrough: получаем game_id завершённых/дропнутых прохождений
|
||||
playthrough_result = await db.execute(
|
||||
select(Assignment.game_id)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.is_playthrough == True,
|
||||
Assignment.status.in_(finished_statuses)
|
||||
)
|
||||
)
|
||||
finished_playthrough_game_ids = set(playthrough_result.scalars().all())
|
||||
|
||||
# Для challenges: получаем challenge_id завершённых заданий
|
||||
challenges_result = await db.execute(
|
||||
select(Assignment.challenge_id)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.is_playthrough == False,
|
||||
Assignment.status == AssignmentStatus.COMPLETED.value
|
||||
)
|
||||
)
|
||||
completed_challenge_ids = set(challenges_result.scalars().all())
|
||||
|
||||
# Фильтруем доступные игры
|
||||
available_games = []
|
||||
for game in games_with_content:
|
||||
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||
# Исключаем если игра уже завершена/дропнута
|
||||
if game.id not in finished_playthrough_game_ids:
|
||||
available_games.append(game)
|
||||
else:
|
||||
# Для challenges: исключаем если все челленджи выполнены
|
||||
game_challenge_ids = {c.id for c in game.challenges}
|
||||
if not game_challenge_ids.issubset(completed_challenge_ids):
|
||||
available_games.append(game)
|
||||
|
||||
return available_games, total_games
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/available-games-count", response_model=AvailableGamesCount)
|
||||
async def get_available_games_count(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""
|
||||
Получить количество игр, доступных для спина.
|
||||
|
||||
Возвращает { available: X, total: Y } где:
|
||||
- available: количество игр, которые могут выпасть
|
||||
- total: общее количество игр в марафоне
|
||||
"""
|
||||
participant = await get_participant(db, current_user.id, marathon_id)
|
||||
if not participant:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
available_games, total_games = await get_available_games_for_participant(
|
||||
db, participant, marathon_id
|
||||
)
|
||||
|
||||
return AvailableGamesCount(
|
||||
available=len(available_games),
|
||||
total=total_games
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/available-games", response_model=list[GameResponse])
|
||||
async def get_available_games(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""
|
||||
Получить список игр, доступных для спина.
|
||||
|
||||
Возвращает только те игры, которые могут выпасть участнику:
|
||||
- Для playthrough: исключаются игры которые уже завершены/дропнуты
|
||||
- Для challenges: исключаются игры где все челленджи выполнены
|
||||
"""
|
||||
participant = await get_participant(db, current_user.id, marathon_id)
|
||||
if not participant:
|
||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||
|
||||
available_games, _ = await get_available_games_for_participant(
|
||||
db, participant, marathon_id
|
||||
)
|
||||
|
||||
# Convert to response with challenges count
|
||||
result = []
|
||||
for game in available_games:
|
||||
challenges_count = len(game.challenges) if game.challenges else 0
|
||||
result.append(GameResponse(
|
||||
id=game.id,
|
||||
title=game.title,
|
||||
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||
download_url=game.download_url,
|
||||
genre=game.genre,
|
||||
status=game.status,
|
||||
proposed_by=None,
|
||||
approved_by=None,
|
||||
challenges_count=challenges_count,
|
||||
created_at=game.created_at,
|
||||
game_type=game.game_type,
|
||||
playthrough_points=game.playthrough_points,
|
||||
playthrough_description=game.playthrough_description,
|
||||
playthrough_proof_type=game.playthrough_proof_type,
|
||||
playthrough_proof_hint=game.playthrough_proof_hint,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
@@ -20,6 +20,7 @@ optional_auth = HTTPBearer(auto_error=False)
|
||||
from app.models import (
|
||||
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus,
|
||||
)
|
||||
from app.schemas import (
|
||||
MarathonCreate,
|
||||
@@ -703,3 +704,260 @@ async def delete_marathon_cover(
|
||||
await db.commit()
|
||||
|
||||
return await get_marathon(marathon_id, current_user, db)
|
||||
|
||||
|
||||
# ============ Marathon Disputes (for organizers) ============
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class MarathonDisputeResponse(BaseModel):
|
||||
id: int
|
||||
assignment_id: int | None
|
||||
bonus_assignment_id: int | None
|
||||
challenge_title: str
|
||||
participant_nickname: str
|
||||
raised_by_nickname: str
|
||||
reason: str
|
||||
status: str
|
||||
votes_valid: int
|
||||
votes_invalid: int
|
||||
created_at: str
|
||||
expires_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ResolveDisputeRequest(BaseModel):
|
||||
is_valid: bool = Field(..., description="True = proof is valid, False = proof is invalid")
|
||||
|
||||
|
||||
@router.get("/{marathon_id}/disputes", response_model=list[MarathonDisputeResponse])
|
||||
async def list_marathon_disputes(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
status_filter: str = "open",
|
||||
):
|
||||
"""List disputes in a marathon. Organizers only."""
|
||||
await require_organizer(db, current_user, marathon_id)
|
||||
marathon = await get_marathon_or_404(db, marathon_id)
|
||||
|
||||
from datetime import timedelta
|
||||
DISPUTE_WINDOW_HOURS = 24
|
||||
|
||||
# Get all assignments in this marathon (through games)
|
||||
games_result = await db.execute(
|
||||
select(Game.id).where(Game.marathon_id == marathon_id)
|
||||
)
|
||||
game_ids = [g[0] for g in games_result.all()]
|
||||
|
||||
if not game_ids:
|
||||
return []
|
||||
|
||||
# Get disputes for assignments in these games
|
||||
# Using selectinload for eager loading - no explicit joins needed
|
||||
query = (
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.raised_by),
|
||||
selectinload(Dispute.votes),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.challenge),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.order_by(Dispute.created_at.desc())
|
||||
)
|
||||
|
||||
if status_filter == "open":
|
||||
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
|
||||
|
||||
result = await db.execute(query)
|
||||
all_disputes = result.scalars().unique().all()
|
||||
|
||||
# Filter disputes that belong to this marathon's games
|
||||
response = []
|
||||
for dispute in all_disputes:
|
||||
# Check if dispute belongs to this marathon
|
||||
if dispute.bonus_assignment_id:
|
||||
bonus = dispute.bonus_assignment
|
||||
if not bonus or not bonus.main_assignment:
|
||||
continue
|
||||
if bonus.main_assignment.game_id not in game_ids:
|
||||
continue
|
||||
participant = bonus.main_assignment.participant
|
||||
challenge_title = f"Бонус: {bonus.challenge.title}"
|
||||
else:
|
||||
assignment = dispute.assignment
|
||||
if not assignment:
|
||||
continue
|
||||
if assignment.is_playthrough:
|
||||
if assignment.game_id not in game_ids:
|
||||
continue
|
||||
challenge_title = f"Прохождение: {assignment.game.title}"
|
||||
else:
|
||||
if not assignment.challenge or assignment.challenge.game_id not in game_ids:
|
||||
continue
|
||||
challenge_title = assignment.challenge.title
|
||||
participant = assignment.participant
|
||||
|
||||
# Count votes
|
||||
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
|
||||
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
|
||||
|
||||
# Calculate expiry
|
||||
expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||
|
||||
response.append(MarathonDisputeResponse(
|
||||
id=dispute.id,
|
||||
assignment_id=dispute.assignment_id,
|
||||
bonus_assignment_id=dispute.bonus_assignment_id,
|
||||
challenge_title=challenge_title,
|
||||
participant_nickname=participant.user.nickname,
|
||||
raised_by_nickname=dispute.raised_by.nickname,
|
||||
reason=dispute.reason,
|
||||
status=dispute.status,
|
||||
votes_valid=votes_valid,
|
||||
votes_invalid=votes_invalid,
|
||||
created_at=dispute.created_at.isoformat(),
|
||||
expires_at=expires_at.isoformat(),
|
||||
))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{marathon_id}/disputes/{dispute_id}/resolve", response_model=MessageResponse)
|
||||
async def resolve_marathon_dispute(
|
||||
marathon_id: int,
|
||||
dispute_id: int,
|
||||
data: ResolveDisputeRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Manually resolve a dispute in a marathon. Organizers only."""
|
||||
await require_organizer(db, current_user, marathon_id)
|
||||
marathon = await get_marathon_or_404(db, marathon_id)
|
||||
|
||||
# Get dispute
|
||||
result = await db.execute(
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.where(Dispute.id == dispute_id)
|
||||
)
|
||||
dispute = result.scalar_one_or_none()
|
||||
|
||||
if not dispute:
|
||||
raise HTTPException(status_code=404, detail="Dispute not found")
|
||||
|
||||
# Verify dispute belongs to this marathon
|
||||
if dispute.bonus_assignment_id:
|
||||
bonus = dispute.bonus_assignment
|
||||
if bonus.main_assignment.game.marathon_id != marathon_id:
|
||||
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||
else:
|
||||
assignment = dispute.assignment
|
||||
if assignment.is_playthrough:
|
||||
if assignment.game.marathon_id != marathon_id:
|
||||
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||
else:
|
||||
if assignment.challenge.game.marathon_id != marathon_id:
|
||||
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||
|
||||
if dispute.status != DisputeStatus.OPEN.value:
|
||||
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||
|
||||
# Determine result
|
||||
if data.is_valid:
|
||||
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||
else:
|
||||
result_status = DisputeStatus.RESOLVED_INVALID.value
|
||||
|
||||
# Handle invalid proof
|
||||
if dispute.bonus_assignment_id:
|
||||
# Reset bonus assignment
|
||||
bonus = dispute.bonus_assignment
|
||||
main_assignment = bonus.main_assignment
|
||||
participant = main_assignment.participant
|
||||
|
||||
# Only subtract points if main playthrough was already completed
|
||||
# (bonus points are added only when main playthrough is completed)
|
||||
if main_assignment.status == AssignmentStatus.COMPLETED.value:
|
||||
points_to_subtract = bonus.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
# Also reduce the points_earned on the main assignment
|
||||
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
|
||||
|
||||
bonus.status = BonusAssignmentStatus.PENDING.value
|
||||
bonus.proof_path = None
|
||||
bonus.proof_url = None
|
||||
bonus.proof_comment = None
|
||||
bonus.points_earned = 0
|
||||
bonus.completed_at = None
|
||||
else:
|
||||
# Reset main assignment
|
||||
assignment = dispute.assignment
|
||||
participant = assignment.participant
|
||||
|
||||
# Subtract points
|
||||
points_to_subtract = assignment.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
|
||||
# Reset streak - the completion was invalid
|
||||
participant.current_streak = 0
|
||||
|
||||
# Reset assignment
|
||||
assignment.status = AssignmentStatus.RETURNED.value
|
||||
assignment.points_earned = 0
|
||||
|
||||
# For playthrough: reset all bonus assignments
|
||||
if assignment.is_playthrough:
|
||||
bonus_result = await db.execute(
|
||||
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
|
||||
)
|
||||
for ba in bonus_result.scalars().all():
|
||||
ba.status = BonusAssignmentStatus.PENDING.value
|
||||
ba.proof_path = None
|
||||
ba.proof_url = None
|
||||
ba.proof_comment = None
|
||||
ba.points_earned = 0
|
||||
ba.completed_at = None
|
||||
|
||||
# Update dispute
|
||||
dispute.status = result_status
|
||||
dispute.resolved_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send notification
|
||||
if dispute.bonus_assignment_id:
|
||||
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
|
||||
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
|
||||
elif dispute.assignment.is_playthrough:
|
||||
participant_user_id = dispute.assignment.participant.user_id
|
||||
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
|
||||
else:
|
||||
participant_user_id = dispute.assignment.participant.user_id
|
||||
challenge_title = dispute.assignment.challenge.title
|
||||
|
||||
await telegram_notifier.notify_dispute_resolved(
|
||||
db,
|
||||
user_id=participant_user_id,
|
||||
marathon_title=marathon.title,
|
||||
challenge_title=challenge_title,
|
||||
is_valid=data.is_valid
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
||||
)
|
||||
|
||||
@@ -9,15 +9,18 @@ from app.core.config import settings
|
||||
from app.models import (
|
||||
Marathon, MarathonStatus, Game, Challenge, Participant,
|
||||
Assignment, AssignmentStatus, Activity, ActivityType,
|
||||
EventType, Difficulty, User
|
||||
EventType, Difficulty, User, BonusAssignment, BonusAssignmentStatus, GameType,
|
||||
DisputeStatus,
|
||||
)
|
||||
from app.schemas import (
|
||||
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
||||
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
|
||||
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse,
|
||||
)
|
||||
from app.schemas.game import PlaythroughInfo
|
||||
from app.services.points import PointsService
|
||||
from app.services.events import event_service
|
||||
from app.services.storage import storage_service
|
||||
from app.api.v1.games import get_available_games_for_participant
|
||||
|
||||
router = APIRouter(tags=["wheel"])
|
||||
|
||||
@@ -48,7 +51,9 @@ async def get_active_assignment(db, participant_id: int, is_event: bool = False)
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
|
||||
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == participant_id,
|
||||
@@ -64,7 +69,9 @@ async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
|
||||
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == participant_id,
|
||||
@@ -94,7 +101,7 @@ async def activate_returned_assignment(db, returned_assignment: Assignment) -> N
|
||||
|
||||
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
|
||||
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""Spin the wheel to get a random game and challenge"""
|
||||
"""Spin the wheel to get a random game and challenge (or playthrough)"""
|
||||
# Check marathon is active
|
||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = result.scalar_one_or_none()
|
||||
@@ -115,43 +122,110 @@ 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()
|
||||
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:
|
||||
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.marathon_id == marathon_id)
|
||||
.where(Game.id == game.id)
|
||||
)
|
||||
games = [g for g in result.scalars().all() if g.challenges]
|
||||
game = result.scalar_one()
|
||||
|
||||
if not games:
|
||||
raise HTTPException(status_code=400, detail="No games with challenges available")
|
||||
# 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]
|
||||
|
||||
game = random.choice(games)
|
||||
challenge = random.choice(game.challenges)
|
||||
if not available_challenges:
|
||||
raise HTTPException(status_code=400, detail="No challenges available for this game")
|
||||
|
||||
# Create assignment (store event_type for jackpot multiplier on completion)
|
||||
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
|
||||
)
|
||||
db.add(assignment)
|
||||
await db.flush() # Get assignment.id for bonus assignments
|
||||
|
||||
# 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)
|
||||
|
||||
# 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,
|
||||
@@ -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,9 +274,8 @@ 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(
|
||||
# Build response
|
||||
game_response = GameResponse(
|
||||
id=game.id,
|
||||
title=game.title,
|
||||
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||
@@ -204,7 +284,51 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
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,
|
||||
@@ -215,10 +339,11 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
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,
|
||||
),
|
||||
is_playthrough=False,
|
||||
can_drop=True,
|
||||
drop_penalty=drop_penalty,
|
||||
)
|
||||
@@ -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,7 +497,12 @@ 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
|
||||
# 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)")
|
||||
|
||||
@@ -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)
|
||||
# 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
|
||||
)
|
||||
full_challenge = result.scalar_one()
|
||||
marathon_id = full_challenge.game.marathon_id
|
||||
|
||||
# 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,8 +889,61 @@ async def get_my_history(
|
||||
)
|
||||
assignments = result.scalars().all()
|
||||
|
||||
return [
|
||||
AssignmentResponse(
|
||||
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,
|
||||
),
|
||||
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,
|
||||
@@ -575,7 +958,8 @@ async def get_my_history(
|
||||
game=GameShort(
|
||||
id=a.challenge.game.id,
|
||||
title=a.challenge.game.title,
|
||||
cover_url=None
|
||||
cover_url=None,
|
||||
game_type=a.challenge.game.game_type,
|
||||
),
|
||||
is_generated=a.challenge.is_generated,
|
||||
created_at=a.challenge.created_at,
|
||||
@@ -587,6 +971,6 @@ async def get_my_history(
|
||||
streak_at_completion=a.streak_at_completion,
|
||||
started_at=a.started_at,
|
||||
completed_at=a.completed_at,
|
||||
)
|
||||
for a in assignments
|
||||
]
|
||||
))
|
||||
|
||||
return responses
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
48
backend/app/models/bonus_assignment.py
Normal file
48
backend/app/models/bonus_assignment.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,8 +49,11 @@ 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
|
||||
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)
|
||||
@@ -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
|
||||
|
||||
# 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
|
||||
game = challenge.game if challenge else None
|
||||
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,
|
||||
|
||||
@@ -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"⚠️ <b>{count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения</b>\n\n"
|
||||
f"Голосование завершено, требуется ваше решение."
|
||||
)
|
||||
reply_markup = {
|
||||
"inline_keyboard": [[
|
||||
{"text": "Открыть оспаривания", "url": admin_url}
|
||||
]]
|
||||
}
|
||||
else:
|
||||
message = (
|
||||
f"⚠️ <b>{count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения</b>\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()
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
381
docs/disputes.md
Normal file
381
docs/disputes.md
Normal file
@@ -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` для аудита действий администраторов.
|
||||
906
docs/tz-game-types.md
Normal file
906
docs/tz-game-types.md
Normal file
@@ -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<GameType>('challenges')
|
||||
const [playthroughPoints, setPlaythroughPoints] = useState<number>(100)
|
||||
const [playthroughDescription, setPlaythroughDescription] = useState<string>('')
|
||||
|
||||
return (
|
||||
<form>
|
||||
{/* ... существующие поля ... */}
|
||||
|
||||
<Select
|
||||
label="Тип игры"
|
||||
value={gameType}
|
||||
onChange={setGameType}
|
||||
options={[
|
||||
{ value: 'challenges', label: 'Челленджи' },
|
||||
{ value: 'playthrough', label: 'Прохождение' }
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Поля только для типа "Прохождение" */}
|
||||
{gameType === 'playthrough' && (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
label="Очки за прохождение"
|
||||
value={playthroughPoints}
|
||||
onChange={setPlaythroughPoints}
|
||||
min={1}
|
||||
max={500}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Описание прохождения"
|
||||
value={playthroughDescription}
|
||||
onChange={setPlaythroughDescription}
|
||||
placeholder="Например: Пройти основной сюжет игры"
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Отображение результата спина
|
||||
|
||||
Для типа "Прохождение" показывать:
|
||||
- Основное задание с описанием из `playthrough_info`
|
||||
- Очки за прохождение
|
||||
- Список дополнительных челленджей (опциональные)
|
||||
|
||||
```tsx
|
||||
// components/SpinResult.tsx
|
||||
{result.is_playthrough ? (
|
||||
<PlaythroughCard
|
||||
game={result.game}
|
||||
info={result.playthrough_info}
|
||||
bonusChallenges={result.bonus_challenges}
|
||||
/>
|
||||
) : (
|
||||
<ChallengeCard challenge={result.challenge} />
|
||||
)}
|
||||
```
|
||||
|
||||
### 4. Карточка текущего задания
|
||||
|
||||
Для playthrough показывать прогресс по доп. челленджам:
|
||||
|
||||
```tsx
|
||||
// components/CurrentAssignment.tsx
|
||||
{assignment.is_playthrough && (
|
||||
<div className="mt-4">
|
||||
<h4>Дополнительные задания (опционально)</h4>
|
||||
<BonusChallengesList
|
||||
assignmentId={assignment.id}
|
||||
challenges={assignment.bonus_challenges}
|
||||
onComplete={handleBonusComplete}
|
||||
/>
|
||||
<p className="text-sm text-gray-500">
|
||||
Выполнено: {completedCount} / {totalCount} (+{bonusPoints} очков)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 5. Форма завершения бонусного челленджа
|
||||
|
||||
```tsx
|
||||
// components/BonusChallengeCompleteModal.tsx
|
||||
<Modal>
|
||||
<h3>Завершить челлендж: {challenge.title}</h3>
|
||||
<p>{challenge.description}</p>
|
||||
<p>Очки: +{challenge.points}</p>
|
||||
|
||||
<ProofUpload
|
||||
proofType={challenge.proof_type}
|
||||
onUpload={handleProofUpload}
|
||||
/>
|
||||
|
||||
<Button onClick={handleComplete}>
|
||||
Завершить (+{challenge.points} очков)
|
||||
</Button>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
### 6. Редактирование игры
|
||||
|
||||
Добавить модалку/страницу редактирования игры:
|
||||
|
||||
```tsx
|
||||
// components/EditGameModal.tsx
|
||||
interface EditGameModalProps {
|
||||
game: Game
|
||||
onSave: (data: GameUpdate) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const EditGameModal = ({ game, onSave, onClose }: EditGameModalProps) => {
|
||||
const [title, setTitle] = useState(game.title)
|
||||
const [downloadUrl, setDownloadUrl] = useState(game.download_url)
|
||||
const [genre, setGenre] = useState(game.genre)
|
||||
const [gameType, setGameType] = useState<GameType>(game.game_type)
|
||||
const [playthroughPoints, setPlaythroughPoints] = useState(game.playthrough_points ?? 100)
|
||||
const [playthroughDescription, setPlaythroughDescription] = useState(game.playthrough_description ?? '')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: GameUpdate = {
|
||||
title,
|
||||
download_url: downloadUrl,
|
||||
genre,
|
||||
game_type: gameType,
|
||||
...(gameType === 'playthrough' && {
|
||||
playthrough_points: playthroughPoints,
|
||||
playthrough_description: playthroughDescription,
|
||||
}),
|
||||
}
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<h2>Редактирование игры</h2>
|
||||
|
||||
<Input label="Название" value={title} onChange={setTitle} />
|
||||
<Input label="Ссылка на скачивание" value={downloadUrl} onChange={setDownloadUrl} />
|
||||
<Input label="Жанр" value={genre} onChange={setGenre} />
|
||||
|
||||
<Select
|
||||
label="Тип игры"
|
||||
value={gameType}
|
||||
onChange={setGameType}
|
||||
options={[
|
||||
{ value: 'challenges', label: 'Челленджи' },
|
||||
{ value: 'playthrough', label: 'Прохождение' }
|
||||
]}
|
||||
/>
|
||||
|
||||
{gameType === 'playthrough' && (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
label="Очки за прохождение"
|
||||
value={playthroughPoints}
|
||||
onChange={setPlaythroughPoints}
|
||||
min={1}
|
||||
max={500}
|
||||
/>
|
||||
<Textarea
|
||||
label="Описание прохождения"
|
||||
value={playthroughDescription}
|
||||
onChange={setPlaythroughDescription}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>Отмена</Button>
|
||||
<Button onClick={handleSubmit}>Сохранить</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Кнопка редактирования в списке игр
|
||||
|
||||
```tsx
|
||||
// components/GameCard.tsx (или GamesList)
|
||||
{isOrganizer && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingGame(game)}
|
||||
>
|
||||
Редактировать
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
### 8. Счётчик доступных игр
|
||||
|
||||
Отображать количество игр, которые ещё могут выпасть при спине:
|
||||
|
||||
```tsx
|
||||
// components/AvailableGamesCounter.tsx
|
||||
interface AvailableGamesCounterProps {
|
||||
available: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const AvailableGamesCounter = ({ available, total }: AvailableGamesCounterProps) => {
|
||||
const allCompleted = available === 0
|
||||
|
||||
return (
|
||||
<div className="text-sm text-gray-500">
|
||||
{allCompleted ? (
|
||||
<span className="text-green-600 font-medium">
|
||||
Все игры пройдены!
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Доступно игр: <strong>{available}</strong> из {total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Использование на странице марафона / рядом с колесом
|
||||
<AvailableGamesCounter available={gamesCount.available} total={gamesCount.total} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Уточнённые требования
|
||||
|
||||
| Вопрос | Решение |
|
||||
|--------|---------|
|
||||
| Очки за прохождение | Устанавливаются при создании игры (поле `playthrough_points`) |
|
||||
| Обязательность доп. челленджей | **Не обязательны** — можно завершить задание без них |
|
||||
| Пруф на прохождение | Тип указывается при создании (`playthrough_proof_type`) |
|
||||
| Пруфы на бонусные челленджи | **Требуются** — по типу челленджа (screenshot/video/steam) |
|
||||
| Прикрепление файла | **Не обязательно** — можно отправить комментарий со ссылкой |
|
||||
| Миграция существующих игр | Тип по умолчанию: `challenges` |
|
||||
| Дроп игры (playthrough) | Дропнутая игра **не выпадает** повторно |
|
||||
| Бонусные челленджи после завершения | **Недоступны** — только пока задание активно |
|
||||
| Счётчик игр | Показывать "Доступно игр: X из Y" |
|
||||
| События для playthrough | **Все игнорируются** — стандартные очки без модификаторов |
|
||||
|
||||
---
|
||||
|
||||
## План реализации
|
||||
|
||||
### Этап 1: Backend (модели и миграции) ✅
|
||||
- [x] Добавить enum `GameType` в `backend/app/models/game.py`
|
||||
- [x] Добавить поля `game_type`, `playthrough_points`, `playthrough_description`, `playthrough_proof_type`, `playthrough_proof_hint` в модель Game
|
||||
- [x] Создать модель `BonusAssignment` в `backend/app/models/bonus_assignment.py`
|
||||
- [x] Обновить модель `Assignment` — добавить `game_id`, `is_playthrough`
|
||||
- [x] Создать миграцию Alembic (`020_add_game_types.py`)
|
||||
|
||||
### Этап 2: Backend (схемы и API) ✅
|
||||
- [x] Обновить Pydantic схемы для Game (`GameCreate`, `GameResponse`)
|
||||
- [x] Добавить схему `GameUpdate` с валидацией
|
||||
- [x] Обновить API создания игры
|
||||
- [x] Добавить API редактирования игры (`PATCH /games/{id}`)
|
||||
- [x] Добавить API счётчика игр (`GET /available-games-count`)
|
||||
- [x] Добавить схемы для `BonusAssignment`, `PlaythroughInfo`
|
||||
- [x] Добавить эндпоинты для бонусных челленджей
|
||||
|
||||
### Этап 3: Backend (логика спина) ✅
|
||||
- [x] Добавить функцию `get_available_games()` для фильтрации пройденных игр
|
||||
- [x] Обновить логику `spin_wheel` для обработки типов
|
||||
- [x] Для типа `challenges` — выбирать только невыполненные челленджи
|
||||
- [x] Обработать случай "Все игры пройдены"
|
||||
- [x] Обновить ответ SpinResult
|
||||
- [x] Обновить логику завершения задания для playthrough
|
||||
- [x] Добавить логику завершения бонусных челленджей
|
||||
- [x] Игнорирование событий для playthrough
|
||||
|
||||
### Этап 4: Frontend (типы и формы) ✅
|
||||
- [x] Обновить типы TypeScript (`Game`, `SpinResult`, `BonusAssignment`, `GameUpdate`, `AvailableGamesCount`)
|
||||
- [x] Добавить выбор типа в форму создания игры
|
||||
- [x] Добавить условные поля "Очки", "Описание", "Тип пруфа", "Подсказка" для типа "Прохождение"
|
||||
- [x] Добавить API метод `gamesApi.update()` и `gamesApi.getAvailableGamesCount()`
|
||||
- [x] Добавить API методы для бонусных челленджей
|
||||
|
||||
### Этап 5: Frontend (UI) ✅
|
||||
- [x] Обновить отображение результата спина для playthrough
|
||||
- [x] Обновить карточку текущего задания (PlayPage)
|
||||
- [x] Показ бонусных челленджей со статусами
|
||||
- [x] Бейдж "Прохождение" на карточках игр в лобби
|
||||
- [x] Поддержка пруфа через комментарий для playthrough
|
||||
|
||||
### Этап 6: Тестирование
|
||||
- [ ] Тестирование миграции на существующих данных
|
||||
- [ ] Проверка создания игр обоих типов
|
||||
- [ ] Проверка редактирования игр (смена типа, обновление полей)
|
||||
- [ ] Проверка спина для playthrough и challenges
|
||||
- [ ] Проверка фильтрации пройденных игр (playthrough не выпадает повторно)
|
||||
- [ ] Проверка фильтрации челленджей (выпадают только невыполненные)
|
||||
- [ ] Проверка состояния "Все игры пройдены"
|
||||
- [ ] Проверка завершения основного и бонусных заданий
|
||||
|
||||
---
|
||||
|
||||
## Схема работы
|
||||
|
||||
### Создание игры
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ СОЗДАНИЕ ИГРЫ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Выбор типа │
|
||||
└─────────────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ "Прохождение" │ │ "Челленджи" │
|
||||
│ │ │ │
|
||||
│ Доп. поля: │ │ Стандартные │
|
||||
│ • Очки │ │ поля │
|
||||
│ • Описание │ │ │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### Спин колеса
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ СПИН КОЛЕСА │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Выбор игры │
|
||||
│ (random) │
|
||||
└─────────────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ game_type = │ │ game_type = │
|
||||
│ "playthrough" │ │ "challenges" │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Основное: │ │ Случайный │
|
||||
│ playthrough_ │ │ челлендж │
|
||||
│ description │ │ │
|
||||
│ │ │ (текущая │
|
||||
│ Очки: │ │ логика) │
|
||||
│ playthrough_ │ │ │
|
||||
│ points │ │ │
|
||||
│ │ │ │
|
||||
│ Доп. задания: │ │ │
|
||||
│ Все челленджи │ │ │
|
||||
│ (опционально) │ │ │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Пруф: │ │ Пруф: │
|
||||
│ На прохождение │ │ По типу │
|
||||
│ игры │ │ челленджа │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Очки: │ │ Очки: │
|
||||
│ + За прохождение│ │ + За челлендж │
|
||||
│ + Бонус за доп. │ │ │
|
||||
│ челленджи │ │ │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
AdminDashboardPage,
|
||||
AdminUsersPage,
|
||||
AdminMarathonsPage,
|
||||
AdminDisputesPage,
|
||||
AdminLogsPage,
|
||||
AdminBroadcastPage,
|
||||
AdminContentPage,
|
||||
@@ -208,6 +209,7 @@ function App() {
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||
<Route path="disputes" element={<AdminDisputesPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
||||
<Route path="content" element={<AdminContentPage />} />
|
||||
|
||||
@@ -7,7 +7,8 @@ import type {
|
||||
AdminLogsResponse,
|
||||
BroadcastResponse,
|
||||
StaticContent,
|
||||
DashboardStats
|
||||
DashboardStats,
|
||||
AdminDispute
|
||||
} from '@/types'
|
||||
|
||||
export const adminApi = {
|
||||
@@ -125,6 +126,19 @@ export const adminApi = {
|
||||
deleteContent: async (key: string): Promise<void> => {
|
||||
await client.delete(`/admin/content/${key}`)
|
||||
},
|
||||
|
||||
// Disputes
|
||||
listDisputes: async (status: 'pending' | 'open' | 'all' = 'pending'): Promise<AdminDispute[]> => {
|
||||
const response = await client.get<AdminDispute[]>('/admin/disputes', { params: { status } })
|
||||
return response.data
|
||||
},
|
||||
|
||||
resolveDispute: async (disputeId: number, isValid: boolean): Promise<{ message: string }> => {
|
||||
const response = await client.post<{ message: string }>(`/admin/disputes/${disputeId}/resolve`, {
|
||||
is_valid: isValid,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// Public content API (no auth required)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import client from './client'
|
||||
import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment } from '@/types'
|
||||
import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment, BonusAssignment } from '@/types'
|
||||
|
||||
export interface BonusCompleteResult {
|
||||
bonus_assignment_id: number
|
||||
points_earned: number
|
||||
total_bonus_points: number
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
// Get detailed assignment info with proofs and dispute
|
||||
@@ -14,6 +20,12 @@ export const assignmentsApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Create a dispute against a bonus assignment
|
||||
createBonusDispute: async (bonusId: number, reason: string): Promise<Dispute> => {
|
||||
const response = await client.post<Dispute>(`/bonus-assignments/${bonusId}/dispute`, { reason })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Add a comment to a dispute
|
||||
addComment: async (disputeId: number, text: string): Promise<DisputeComment> => {
|
||||
const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text })
|
||||
@@ -44,4 +56,51 @@ export const assignmentsApi = {
|
||||
type: isVideo ? 'video' : 'image',
|
||||
}
|
||||
},
|
||||
|
||||
// Get bonus assignments for a playthrough assignment
|
||||
getBonusAssignments: async (assignmentId: number): Promise<BonusAssignment[]> => {
|
||||
const response = await client.get<BonusAssignment[]>(`/assignments/${assignmentId}/bonus`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Complete a bonus challenge
|
||||
completeBonusAssignment: async (
|
||||
assignmentId: number,
|
||||
bonusId: number,
|
||||
data: { proof_file?: File; proof_url?: string; comment?: string }
|
||||
): Promise<BonusCompleteResult> => {
|
||||
const formData = new FormData()
|
||||
if (data.proof_file) {
|
||||
formData.append('proof_file', data.proof_file)
|
||||
}
|
||||
if (data.proof_url) {
|
||||
formData.append('proof_url', data.proof_url)
|
||||
}
|
||||
if (data.comment) {
|
||||
formData.append('comment', data.comment)
|
||||
}
|
||||
const response = await client.post<BonusCompleteResult>(
|
||||
`/assignments/${assignmentId}/bonus/${bonusId}/complete`,
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get bonus proof media as blob URL (supports both images and videos)
|
||||
getBonusProofMediaUrl: async (
|
||||
assignmentId: number,
|
||||
bonusId: number
|
||||
): Promise<{ url: string; type: 'image' | 'video' }> => {
|
||||
const response = await client.get(
|
||||
`/assignments/${assignmentId}/bonus/${bonusId}/proof-media`,
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
const contentType = response.headers['content-type'] || ''
|
||||
const isVideo = contentType.startsWith('video/')
|
||||
return {
|
||||
url: URL.createObjectURL(response.data),
|
||||
type: isVideo ? 'video' : 'image',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import client from './client'
|
||||
import type { Game, GameStatus, Challenge, ChallengePreview, ChallengesPreviewResponse } from '@/types'
|
||||
import type { Game, GameStatus, GameType, ProofType, Challenge, ChallengePreview, ChallengesPreviewResponse, AvailableGamesCount } from '@/types'
|
||||
|
||||
export interface CreateGameData {
|
||||
title: string
|
||||
download_url: string
|
||||
genre?: string
|
||||
cover_url?: string
|
||||
// Game type fields
|
||||
game_type?: GameType
|
||||
playthrough_points?: number
|
||||
playthrough_description?: string
|
||||
playthrough_proof_type?: ProofType
|
||||
playthrough_proof_hint?: string
|
||||
}
|
||||
|
||||
export interface UpdateGameData {
|
||||
title?: string
|
||||
download_url?: string
|
||||
genre?: string
|
||||
game_type?: GameType
|
||||
playthrough_points?: number
|
||||
playthrough_description?: string
|
||||
playthrough_proof_type?: ProofType
|
||||
playthrough_proof_hint?: string
|
||||
}
|
||||
|
||||
export interface CreateChallengeData {
|
||||
@@ -45,6 +62,21 @@ export const gamesApi = {
|
||||
await client.delete(`/games/${id}`)
|
||||
},
|
||||
|
||||
update: async (id: number, data: UpdateGameData): Promise<Game> => {
|
||||
const response = await client.patch<Game>(`/games/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getAvailableGamesCount: async (marathonId: number): Promise<AvailableGamesCount> => {
|
||||
const response = await client.get<AvailableGamesCount>(`/marathons/${marathonId}/available-games-count`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getAvailableGames: async (marathonId: number): Promise<Game[]> => {
|
||||
const response = await client.get<Game[]>(`/marathons/${marathonId}/available-games`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
approve: async (id: number): Promise<Game> => {
|
||||
const response = await client.post<Game>(`/games/${id}/approve`)
|
||||
return response.data
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import client from './client'
|
||||
import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode } from '@/types'
|
||||
import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode, MarathonDispute } from '@/types'
|
||||
|
||||
export interface CreateMarathonData {
|
||||
title: string
|
||||
@@ -96,4 +96,20 @@ export const marathonsApi = {
|
||||
const response = await client.delete<Marathon>(`/marathons/${id}/cover`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Disputes management for organizers
|
||||
listDisputes: async (id: number, status: 'open' | 'all' = 'open'): Promise<MarathonDispute[]> => {
|
||||
const response = await client.get<MarathonDispute[]>(`/marathons/${id}/disputes`, {
|
||||
params: { status_filter: status }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
resolveDispute: async (marathonId: number, disputeId: number, isValid: boolean): Promise<{ message: string }> => {
|
||||
const response = await client.post<{ message: string }>(
|
||||
`/marathons/${marathonId}/disputes/${disputeId}/resolve`,
|
||||
{ is_valid: isValid }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -191,14 +191,15 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
||||
const isEvent = isEventActivity(activity.type)
|
||||
const { title, details, extra } = formatActivityMessage(activity)
|
||||
|
||||
// Get assignment_id and dispute status for complete activities
|
||||
const activityData = activity.data as { assignment_id?: number; dispute_status?: string } | null
|
||||
// Get assignment_id, dispute status, and is_redo for complete activities
|
||||
const activityData = activity.data as { assignment_id?: number; dispute_status?: string; is_redo?: boolean } | null
|
||||
const assignmentId = activity.type === 'complete' && activityData?.assignment_id
|
||||
? activityData.assignment_id
|
||||
: null
|
||||
const disputeStatus = activity.type === 'complete' && activityData?.dispute_status
|
||||
? activityData.dispute_status
|
||||
: null
|
||||
const isRedo = activity.type === 'complete' && activityData?.is_redo === true
|
||||
|
||||
// Determine accent color based on activity type
|
||||
const getAccentConfig = () => {
|
||||
@@ -323,6 +324,12 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Детали
|
||||
</button>
|
||||
{isRedo && (
|
||||
<span className="text-xs text-purple-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-purple-500/10">
|
||||
<Zap className="w-3 h-3" />
|
||||
Перепрохождение
|
||||
</span>
|
||||
)}
|
||||
{disputeStatus === 'open' && (
|
||||
<span className="text-xs text-orange-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-orange-500/10">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
|
||||
@@ -23,11 +23,19 @@ export function AssignmentDetailPage() {
|
||||
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null)
|
||||
const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null)
|
||||
|
||||
// Bonus proof media
|
||||
const [bonusProofMedia, setBonusProofMedia] = useState<Record<number, { url: string; type: 'image' | 'video' }>>({})
|
||||
|
||||
// Dispute creation
|
||||
const [showDisputeForm, setShowDisputeForm] = useState(false)
|
||||
const [disputeReason, setDisputeReason] = useState('')
|
||||
const [isCreatingDispute, setIsCreatingDispute] = useState(false)
|
||||
|
||||
// Bonus dispute creation
|
||||
const [activeBonusDisputeId, setActiveBonusDisputeId] = useState<number | null>(null)
|
||||
const [bonusDisputeReason, setBonusDisputeReason] = useState('')
|
||||
const [isCreatingBonusDispute, setIsCreatingBonusDispute] = useState(false)
|
||||
|
||||
// Comment
|
||||
const [commentText, setCommentText] = useState('')
|
||||
const [isAddingComment, setIsAddingComment] = useState(false)
|
||||
@@ -38,10 +46,13 @@ export function AssignmentDetailPage() {
|
||||
useEffect(() => {
|
||||
loadAssignment()
|
||||
return () => {
|
||||
// Cleanup blob URL on unmount
|
||||
// Cleanup blob URLs on unmount
|
||||
if (proofMediaBlobUrl) {
|
||||
URL.revokeObjectURL(proofMediaBlobUrl)
|
||||
}
|
||||
Object.values(bonusProofMedia).forEach(media => {
|
||||
URL.revokeObjectURL(media.url)
|
||||
})
|
||||
}
|
||||
}, [id])
|
||||
|
||||
@@ -63,6 +74,22 @@ export function AssignmentDetailPage() {
|
||||
// Ignore error, media just won't show
|
||||
}
|
||||
}
|
||||
|
||||
// Load bonus proof media for playthrough
|
||||
if (data.is_playthrough && data.bonus_challenges) {
|
||||
const bonusMedia: Record<number, { url: string; type: 'image' | 'video' }> = {}
|
||||
for (const bonus of data.bonus_challenges) {
|
||||
if (bonus.proof_image_url) {
|
||||
try {
|
||||
const { url, type } = await assignmentsApi.getBonusProofMediaUrl(parseInt(id), bonus.id)
|
||||
bonusMedia[bonus.id] = { url, type }
|
||||
} catch {
|
||||
// Ignore error, media just won't show
|
||||
}
|
||||
}
|
||||
}
|
||||
setBonusProofMedia(bonusMedia)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
setError(error.response?.data?.detail || 'Не удалось загрузить данные')
|
||||
@@ -88,6 +115,37 @@ export function AssignmentDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateBonusDispute = async (bonusId: number) => {
|
||||
if (!bonusDisputeReason.trim()) return
|
||||
|
||||
setIsCreatingBonusDispute(true)
|
||||
try {
|
||||
await assignmentsApi.createBonusDispute(bonusId, bonusDisputeReason)
|
||||
setBonusDisputeReason('')
|
||||
setActiveBonusDisputeId(null)
|
||||
await loadAssignment()
|
||||
toast.success('Оспаривание бонуса создано')
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось создать оспаривание')
|
||||
} finally {
|
||||
setIsCreatingBonusDispute(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBonusVote = async (disputeId: number, vote: boolean) => {
|
||||
setIsVoting(true)
|
||||
try {
|
||||
await assignmentsApi.vote(disputeId, vote)
|
||||
await loadAssignment()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось проголосовать')
|
||||
} finally {
|
||||
setIsVoting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVote = async (vote: boolean) => {
|
||||
if (!assignment?.dispute) return
|
||||
|
||||
@@ -215,31 +273,54 @@ export function AssignmentDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Challenge info */}
|
||||
{/* Challenge/Playthrough info */}
|
||||
<GlassCard variant="neon">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center">
|
||||
<Gamepad2 className="w-7 h-7 text-neon-400" />
|
||||
<div className={`w-14 h-14 rounded-xl border flex items-center justify-center ${
|
||||
assignment.is_playthrough
|
||||
? 'bg-gradient-to-br from-accent-500/20 to-purple-500/20 border-accent-500/20'
|
||||
: 'bg-gradient-to-br from-neon-500/20 to-accent-500/20 border-neon-500/20'
|
||||
}`}>
|
||||
<Gamepad2 className={`w-7 h-7 ${assignment.is_playthrough ? 'text-accent-400' : 'text-neon-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p>
|
||||
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{assignment.is_playthrough ? assignment.game?.title : assignment.challenge?.game.title}
|
||||
</p>
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{assignment.is_playthrough ? 'Прохождение игры' : assignment.challenge?.title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{assignment.is_playthrough && (
|
||||
<span className="px-3 py-1 bg-accent-500/20 text-accent-400 rounded-full text-xs font-medium border border-accent-500/30">
|
||||
Прохождение
|
||||
</span>
|
||||
)}
|
||||
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-1.5 ${status.color}`}>
|
||||
{status.icon}
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
|
||||
<p className="text-gray-300 mb-4">
|
||||
{assignment.is_playthrough
|
||||
? assignment.playthrough_info?.description
|
||||
: assignment.challenge?.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30 flex items-center gap-1.5">
|
||||
<Trophy className="w-4 h-4" />
|
||||
+{assignment.challenge.points} очков
|
||||
+{assignment.is_playthrough
|
||||
? assignment.playthrough_info?.points
|
||||
: assignment.challenge?.points} очков
|
||||
</span>
|
||||
{!assignment.is_playthrough && assignment.challenge && (
|
||||
<>
|
||||
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
|
||||
{assignment.challenge.difficulty}
|
||||
</span>
|
||||
@@ -249,6 +330,8 @@ export function AssignmentDetailPage() {
|
||||
~{assignment.challenge.estimated_time} мин
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-dark-600 text-sm text-gray-400 space-y-1">
|
||||
@@ -271,6 +354,185 @@ export function AssignmentDetailPage() {
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Bonus challenges for playthrough */}
|
||||
{assignment.is_playthrough && assignment.bonus_challenges && assignment.bonus_challenges.length > 0 && (
|
||||
<GlassCard>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||
<Trophy className="w-5 h-5 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Бонусные челленджи</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Выполнено: {assignment.bonus_challenges.filter((b: { status: string }) => b.status === 'completed').length} из {assignment.bonus_challenges.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{assignment.bonus_challenges.map((bonus) => (
|
||||
<div
|
||||
key={bonus.id}
|
||||
className={`p-4 rounded-xl border ${
|
||||
bonus.dispute ? 'bg-yellow-500/10 border-yellow-500/30' :
|
||||
bonus.status === 'completed'
|
||||
? 'bg-green-500/10 border-green-500/30'
|
||||
: 'bg-dark-700/50 border-dark-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{bonus.dispute ? (
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400" />
|
||||
) : bonus.status === 'completed' ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
) : null}
|
||||
<span className="text-white font-medium">{bonus.challenge.title}</span>
|
||||
{bonus.dispute && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
bonus.dispute.status === 'open' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
bonus.dispute.status === 'valid' ? 'bg-green-500/20 text-green-400' :
|
||||
'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
{bonus.dispute.status === 'open' ? 'Оспаривается' :
|
||||
bonus.dispute.status === 'valid' ? 'Валидно' : 'Невалидно'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">{bonus.challenge.description}</p>
|
||||
{bonus.status === 'completed' && (bonus.proof_url || bonus.proof_image_url || bonus.proof_comment) && (
|
||||
<div className="mt-2 text-xs space-y-2">
|
||||
{bonusProofMedia[bonus.id] && (
|
||||
<div className="rounded-lg overflow-hidden border border-dark-600 max-w-xs">
|
||||
{bonusProofMedia[bonus.id].type === 'video' ? (
|
||||
<video
|
||||
src={bonusProofMedia[bonus.id].url}
|
||||
controls
|
||||
className="w-full max-h-32 bg-dark-900"
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<a href={bonusProofMedia[bonus.id].url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={bonusProofMedia[bonus.id].url}
|
||||
alt="Proof"
|
||||
className="w-full h-auto max-h-32 object-cover hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{bonus.proof_url && (
|
||||
<a
|
||||
href={bonus.proof_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-neon-400 hover:underline flex items-center gap-1 break-all"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 shrink-0" />
|
||||
{bonus.proof_url}
|
||||
</a>
|
||||
)}
|
||||
{bonus.proof_comment && (
|
||||
<p className="text-gray-400">"{bonus.proof_comment}"</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bonus dispute form */}
|
||||
{activeBonusDisputeId === bonus.id && (
|
||||
<div className="mt-3 p-3 bg-red-500/10 rounded-lg border border-red-500/30">
|
||||
<textarea
|
||||
className="input w-full min-h-[80px] resize-none mb-2 text-sm"
|
||||
placeholder="Причина оспаривания (минимум 10 символов)..."
|
||||
value={bonusDisputeReason}
|
||||
onChange={(e) => setBonusDisputeReason(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 disabled:opacity-50"
|
||||
onClick={() => handleCreateBonusDispute(bonus.id)}
|
||||
disabled={bonusDisputeReason.trim().length < 10 || isCreatingBonusDispute}
|
||||
>
|
||||
{isCreatingBonusDispute ? 'Создание...' : 'Оспорить'}
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-dark-600 text-gray-300 rounded-lg hover:bg-dark-500"
|
||||
onClick={() => {
|
||||
setActiveBonusDisputeId(null)
|
||||
setBonusDisputeReason('')
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bonus dispute info */}
|
||||
{bonus.dispute && (
|
||||
<div className="mt-3 p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
|
||||
<p className="text-xs text-gray-400 mb-1">
|
||||
Оспорил: <span className="text-white">{bonus.dispute.raised_by.nickname}</span>
|
||||
</p>
|
||||
<p className="text-sm text-white mb-2">{bonus.dispute.reason}</p>
|
||||
|
||||
{bonus.dispute.status === 'open' && (
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ThumbsUp className="w-3 h-3 text-green-400" />
|
||||
<span className="text-green-400 text-sm font-medium">{bonus.dispute.votes_valid}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ThumbsDown className="w-3 h-3 text-red-400" />
|
||||
<span className="text-red-400 text-sm font-medium">{bonus.dispute.votes_invalid}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<button
|
||||
className={`p-1.5 rounded ${bonus.dispute.my_vote === true ? 'bg-green-500/30' : 'bg-dark-600 hover:bg-dark-500'}`}
|
||||
onClick={() => handleBonusVote(bonus.dispute!.id, true)}
|
||||
disabled={isVoting}
|
||||
>
|
||||
<ThumbsUp className="w-3 h-3 text-green-400" />
|
||||
</button>
|
||||
<button
|
||||
className={`p-1.5 rounded ${bonus.dispute.my_vote === false ? 'bg-red-500/30' : 'bg-dark-600 hover:bg-dark-500'}`}
|
||||
onClick={() => handleBonusVote(bonus.dispute!.id, false)}
|
||||
disabled={isVoting}
|
||||
>
|
||||
<ThumbsDown className="w-3 h-3 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-3 flex flex-col items-end gap-2">
|
||||
{bonus.status === 'completed' ? (
|
||||
<span className="text-green-400 font-semibold">+{bonus.points_earned}</span>
|
||||
) : (
|
||||
<span className="text-gray-500">+{bonus.challenge.points}</span>
|
||||
)}
|
||||
{/* Dispute button for bonus */}
|
||||
{bonus.can_dispute && !bonus.dispute && activeBonusDisputeId !== bonus.id && (
|
||||
<button
|
||||
className="text-xs px-2 py-1 text-red-400 hover:bg-red-500/10 rounded flex items-center gap-1"
|
||||
onClick={() => setActiveBonusDisputeId(bonus.id)}
|
||||
>
|
||||
<Flag className="w-3 h-3" />
|
||||
Оспорить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Proof section */}
|
||||
<GlassCard>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { marathonsApi, gamesApi } from '@/api'
|
||||
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
|
||||
import type { Marathon, Game, Challenge, ChallengePreview, GameType, ProofType } from '@/types'
|
||||
import { NeonButton, Input, GlassCard, StatsCard } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useToast } from '@/store/toast'
|
||||
@@ -31,8 +31,25 @@ export function LobbyPage() {
|
||||
const [gameUrl, setGameUrl] = useState('')
|
||||
const [gameUrlError, setGameUrlError] = useState<string | null>(null)
|
||||
const [gameGenre, setGameGenre] = useState('')
|
||||
const [gameType, setGameType] = useState<GameType>('challenges')
|
||||
const [playthroughPoints, setPlaythroughPoints] = useState(50)
|
||||
const [playthroughDescription, setPlaythroughDescription] = useState('')
|
||||
const [playthroughProofType, setPlaythroughProofType] = useState<ProofType>('screenshot')
|
||||
const [playthroughProofHint, setPlaythroughProofHint] = useState('')
|
||||
const [isAddingGame, setIsAddingGame] = useState(false)
|
||||
|
||||
// Edit game modal
|
||||
const [editingGame, setEditingGame] = useState<Game | null>(null)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [editUrl, setEditUrl] = useState('')
|
||||
const [editGenre, setEditGenre] = useState('')
|
||||
const [editGameType, setEditGameType] = useState<GameType>('challenges')
|
||||
const [editPlaythroughPoints, setEditPlaythroughPoints] = useState(50)
|
||||
const [editPlaythroughDescription, setEditPlaythroughDescription] = useState('')
|
||||
const [editPlaythroughProofType, setEditPlaythroughProofType] = useState<ProofType>('screenshot')
|
||||
const [editPlaythroughProofHint, setEditPlaythroughProofHint] = useState('')
|
||||
const [isEditingGame, setIsEditingGame] = useState(false)
|
||||
|
||||
const validateUrl = (url: string): boolean => {
|
||||
if (!url.trim()) return true // Empty is ok, will be caught by required check
|
||||
try {
|
||||
@@ -185,17 +202,38 @@ export function LobbyPage() {
|
||||
const handleAddGame = async () => {
|
||||
if (!id || !gameTitle.trim() || !gameUrl.trim() || !validateUrl(gameUrl)) return
|
||||
|
||||
// Validate playthrough fields
|
||||
if (gameType === 'playthrough') {
|
||||
if (!playthroughDescription.trim()) {
|
||||
toast.warning('Заполните описание прохождения')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setIsAddingGame(true)
|
||||
try {
|
||||
await gamesApi.create(parseInt(id), {
|
||||
title: gameTitle.trim(),
|
||||
download_url: gameUrl.trim(),
|
||||
genre: gameGenre.trim() || undefined,
|
||||
game_type: gameType,
|
||||
...(gameType === 'playthrough' && {
|
||||
playthrough_points: playthroughPoints,
|
||||
playthrough_description: playthroughDescription.trim(),
|
||||
playthrough_proof_type: playthroughProofType,
|
||||
playthrough_proof_hint: playthroughProofHint.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
// Reset form
|
||||
setGameTitle('')
|
||||
setGameUrl('')
|
||||
setGameUrlError(null)
|
||||
setGameGenre('')
|
||||
setGameType('challenges')
|
||||
setPlaythroughPoints(50)
|
||||
setPlaythroughDescription('')
|
||||
setPlaythroughProofType('screenshot')
|
||||
setPlaythroughProofHint('')
|
||||
setShowAddGame(false)
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
@@ -205,6 +243,56 @@ export function LobbyPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const openEditModal = (game: Game) => {
|
||||
setEditingGame(game)
|
||||
setEditTitle(game.title)
|
||||
setEditUrl(game.download_url)
|
||||
setEditGenre(game.genre || '')
|
||||
setEditGameType(game.game_type || 'challenges')
|
||||
setEditPlaythroughPoints(game.playthrough_points || 50)
|
||||
setEditPlaythroughDescription(game.playthrough_description || '')
|
||||
setEditPlaythroughProofType((game.playthrough_proof_type as ProofType) || 'screenshot')
|
||||
setEditPlaythroughProofHint(game.playthrough_proof_hint || '')
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
setEditingGame(null)
|
||||
}
|
||||
|
||||
const handleEditGame = async () => {
|
||||
if (!editingGame) return
|
||||
|
||||
// Validate playthrough fields
|
||||
if (editGameType === 'playthrough' && !editPlaythroughDescription.trim()) {
|
||||
toast.warning('Заполните описание прохождения')
|
||||
return
|
||||
}
|
||||
|
||||
setIsEditingGame(true)
|
||||
try {
|
||||
await gamesApi.update(editingGame.id, {
|
||||
title: editTitle.trim(),
|
||||
download_url: editUrl.trim(),
|
||||
genre: editGenre.trim() || undefined,
|
||||
game_type: editGameType,
|
||||
...(editGameType === 'playthrough' && {
|
||||
playthrough_points: editPlaythroughPoints,
|
||||
playthrough_description: editPlaythroughDescription.trim(),
|
||||
playthrough_proof_type: editPlaythroughProofType,
|
||||
playthrough_proof_hint: editPlaythroughProofHint.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
toast.success('Игра обновлена')
|
||||
closeEditModal()
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
console.error('Failed to update game:', error)
|
||||
toast.error('Не удалось обновить игру')
|
||||
} finally {
|
||||
setIsEditingGame(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteGame = async (gameId: number) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Удалить игру?',
|
||||
@@ -717,6 +805,11 @@ export function LobbyPage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-semibold text-white">{game.title}</h4>
|
||||
{game.game_type === 'playthrough' && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-lg bg-accent-500/20 text-accent-400 border border-accent-500/30">
|
||||
Прохождение
|
||||
</span>
|
||||
)}
|
||||
{getStatusBadge(game.status)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap">
|
||||
@@ -759,6 +852,15 @@ export function LobbyPage() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isOrganizer && (
|
||||
<button
|
||||
onClick={() => openEditModal(game)}
|
||||
className="p-2 rounded-lg text-neon-400 hover:bg-neon-500/10 transition-colors"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{(isOrganizer || game.proposed_by?.id === user?.id) && (
|
||||
<button
|
||||
onClick={() => handleDeleteGame(game.id)}
|
||||
@@ -1839,15 +1941,75 @@ export function LobbyPage() {
|
||||
value={gameGenre}
|
||||
onChange={(e) => setGameGenre(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Game type selector */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип игры</label>
|
||||
<select
|
||||
value={gameType}
|
||||
onChange={(e) => setGameType(e.target.value as GameType)}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="challenges">Челленджи — случайный челлендж при спине</option>
|
||||
<option value="playthrough">Прохождение — основная задача + бонусные челленджи</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Playthrough fields */}
|
||||
{gameType === 'playthrough' && (
|
||||
<div className="space-y-3 p-3 bg-accent-500/10 rounded-lg border border-accent-500/20">
|
||||
<p className="text-xs text-accent-400 font-medium">Настройки прохождения</p>
|
||||
<textarea
|
||||
placeholder="Что нужно сделать для прохождения (например: пройти игру до финальных титров)"
|
||||
value={playthroughDescription}
|
||||
onChange={(e) => setPlaythroughDescription(e.target.value)}
|
||||
className="input w-full resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Очки за прохождение</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={playthroughPoints}
|
||||
onChange={(e) => setPlaythroughPoints(parseInt(e.target.value) || 50)}
|
||||
min={1}
|
||||
max={500}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||
<select
|
||||
value={playthroughProofType}
|
||||
onChange={(e) => setPlaythroughProofType(e.target.value as ProofType)}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="screenshot">Скриншот</option>
|
||||
<option value="video">Видео</option>
|
||||
<option value="steam">Steam</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Подсказка для пруфа (необязательно)"
|
||||
value={playthroughProofHint}
|
||||
onChange={(e) => setPlaythroughProofHint(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Все челленджи этой игры станут бонусными (опциональными)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<NeonButton
|
||||
onClick={handleAddGame}
|
||||
isLoading={isAddingGame}
|
||||
disabled={!gameTitle || !gameUrl || !!gameUrlError}
|
||||
disabled={!gameTitle || !gameUrl || !!gameUrlError || (gameType === 'playthrough' && !playthroughDescription)}
|
||||
>
|
||||
{isOrganizer ? 'Добавить' : 'Предложить'}
|
||||
</NeonButton>
|
||||
<NeonButton variant="outline" onClick={() => { setShowAddGame(false); setGameUrlError(null) }}>
|
||||
<NeonButton variant="outline" onClick={() => { setShowAddGame(false); setGameUrlError(null); setGameType('challenges') }}>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
</div>
|
||||
@@ -1924,6 +2086,114 @@ export function LobbyPage() {
|
||||
onUpdate={setMarathon}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Game Modal */}
|
||||
{editingGame && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="glass rounded-2xl border border-neon-500/20 w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-bold text-white">Редактировать игру</h3>
|
||||
<button
|
||||
onClick={closeEditModal}
|
||||
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder="Название игры"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Ссылка для скачивания"
|
||||
value={editUrl}
|
||||
onChange={(e) => setEditUrl(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Жанр (необязательно)"
|
||||
value={editGenre}
|
||||
onChange={(e) => setEditGenre(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Game type selector */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип игры</label>
|
||||
<select
|
||||
value={editGameType}
|
||||
onChange={(e) => setEditGameType(e.target.value as GameType)}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="challenges">Челленджи</option>
|
||||
<option value="playthrough">Прохождение</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Playthrough fields */}
|
||||
{editGameType === 'playthrough' && (
|
||||
<div className="space-y-3 p-3 bg-accent-500/10 rounded-lg border border-accent-500/20">
|
||||
<p className="text-xs text-accent-400 font-medium">Настройки прохождения</p>
|
||||
<textarea
|
||||
placeholder="Описание прохождения"
|
||||
value={editPlaythroughDescription}
|
||||
onChange={(e) => setEditPlaythroughDescription(e.target.value)}
|
||||
className="input w-full resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editPlaythroughPoints}
|
||||
onChange={(e) => setEditPlaythroughPoints(parseInt(e.target.value) || 50)}
|
||||
min={1}
|
||||
max={500}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||
<select
|
||||
value={editPlaythroughProofType}
|
||||
onChange={(e) => setEditPlaythroughProofType(e.target.value as ProofType)}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="screenshot">Скриншот</option>
|
||||
<option value="video">Видео</option>
|
||||
<option value="steam">Steam</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Подсказка для пруфа (необязательно)"
|
||||
value={editPlaythroughProofHint}
|
||||
onChange={(e) => setEditPlaythroughProofHint(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<NeonButton
|
||||
className="flex-1"
|
||||
onClick={handleEditGame}
|
||||
isLoading={isEditingGame}
|
||||
disabled={!editTitle.trim() || !editUrl.trim() || (editGameType === 'playthrough' && !editPlaythroughDescription.trim())}
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
Сохранить
|
||||
</NeonButton>
|
||||
<NeonButton variant="outline" onClick={closeEditModal}>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
||||
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
||||
import type { Marathon, ActiveEvent, Challenge, MarathonDispute } from '@/types'
|
||||
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useToast } from '@/store/toast'
|
||||
@@ -13,7 +13,8 @@ import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
|
||||
import {
|
||||
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
|
||||
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
|
||||
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles
|
||||
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles,
|
||||
AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, ExternalLink, User
|
||||
} from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
@@ -39,10 +40,23 @@ export function MarathonPage() {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
||||
|
||||
// Disputes for organizers
|
||||
const [showDisputes, setShowDisputes] = useState(false)
|
||||
const [disputes, setDisputes] = useState<MarathonDispute[]>([])
|
||||
const [loadingDisputes, setLoadingDisputes] = useState(false)
|
||||
const [disputeFilter, setDisputeFilter] = useState<'open' | 'all'>('open')
|
||||
const [resolvingDisputeId, setResolvingDisputeId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadMarathon()
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (showDisputes) {
|
||||
loadDisputes()
|
||||
}
|
||||
}, [showDisputes, disputeFilter])
|
||||
|
||||
const loadMarathon = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
@@ -80,6 +94,57 @@ export function MarathonPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadDisputes = async () => {
|
||||
if (!id) return
|
||||
setLoadingDisputes(true)
|
||||
try {
|
||||
const data = await marathonsApi.listDisputes(parseInt(id), disputeFilter)
|
||||
setDisputes(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load disputes:', error)
|
||||
toast.error('Не удалось загрузить оспаривания')
|
||||
} finally {
|
||||
setLoadingDisputes(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolveDispute = async (disputeId: number, isValid: boolean) => {
|
||||
if (!id) return
|
||||
setResolvingDisputeId(disputeId)
|
||||
try {
|
||||
await marathonsApi.resolveDispute(parseInt(id), disputeId, isValid)
|
||||
toast.success(isValid ? 'Пруф подтверждён' : 'Пруф отклонён')
|
||||
await loadDisputes()
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve dispute:', error)
|
||||
toast.error('Не удалось разрешить диспут')
|
||||
} finally {
|
||||
setResolvingDisputeId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDisputeDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const getDisputeTimeRemaining = (expiresAt: string) => {
|
||||
const now = new Date()
|
||||
const expires = new Date(expiresAt)
|
||||
const diff = expires.getTime() - now.getTime()
|
||||
|
||||
if (diff <= 0) return 'Истекло'
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
||||
|
||||
return `${hours}ч ${minutes}м`
|
||||
}
|
||||
|
||||
const getInviteLink = () => {
|
||||
if (!marathon) return ''
|
||||
return `${window.location.origin}/invite/${marathon.invite_code}`
|
||||
@@ -385,6 +450,196 @@ export function MarathonPage() {
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Disputes management for organizers */}
|
||||
{marathon.status === 'active' && isOrganizer && (
|
||||
<GlassCard>
|
||||
<button
|
||||
onClick={() => setShowDisputes(!showDisputes)}
|
||||
className="w-full flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h3 className="font-semibold text-white">Оспаривания</h3>
|
||||
<p className="text-sm text-gray-400">Проверьте спорные выполнения</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{disputes.filter(d => d.status === 'open').length > 0 && (
|
||||
<span className="px-2 py-1 bg-orange-500/20 text-orange-400 rounded text-xs font-medium">
|
||||
{disputes.filter(d => d.status === 'open').length} открыто
|
||||
</span>
|
||||
)}
|
||||
{showDisputes ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{showDisputes && (
|
||||
<div className="mt-6 pt-6 border-t border-dark-600">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
|
||||
disputeFilter === 'open'
|
||||
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
|
||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => setDisputeFilter('open')}
|
||||
>
|
||||
Открытые
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
|
||||
disputeFilter === 'all'
|
||||
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
|
||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => setDisputeFilter('all')}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loadingDisputes ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-accent-500" />
|
||||
</div>
|
||||
) : disputes.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-green-500/10 border border-green-500/30 flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{disputeFilter === 'open' ? 'Нет открытых оспариваний' : 'Нет оспариваний'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{disputes.map((dispute) => (
|
||||
<div
|
||||
key={dispute.id}
|
||||
className={`p-4 bg-dark-700/50 rounded-xl border ${
|
||||
dispute.status === 'open' ? 'border-orange-500/30' : 'border-dark-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Challenge title */}
|
||||
<h4 className="text-white font-medium truncate mb-1">
|
||||
{dispute.challenge_title}
|
||||
</h4>
|
||||
{/* Participants */}
|
||||
<div className="flex flex-wrap gap-3 text-xs text-gray-400 mb-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
Автор: <span className="text-white">{dispute.participant_nickname}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Оспорил: <span className="text-white">{dispute.raised_by_nickname}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* Reason */}
|
||||
<p className="text-sm text-gray-300 mb-2 line-clamp-2">
|
||||
{dispute.reason}
|
||||
</p>
|
||||
{/* Votes & Time */}
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-0.5 text-green-400">
|
||||
<ThumbsUp className="w-3 h-3" />
|
||||
<span>{dispute.votes_valid}</span>
|
||||
</div>
|
||||
<span className="text-gray-600">/</span>
|
||||
<div className="flex items-center gap-0.5 text-red-400">
|
||||
<ThumbsDown className="w-3 h-3" />
|
||||
<span>{dispute.votes_invalid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-500">{formatDisputeDate(dispute.created_at)}</span>
|
||||
{dispute.status === 'open' && (
|
||||
<span className="text-orange-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{getDisputeTimeRemaining(dispute.expires_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Status & Actions */}
|
||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
{dispute.status === 'open' ? (
|
||||
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Открыт
|
||||
</span>
|
||||
) : dispute.status === 'valid' ? (
|
||||
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Валидно
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-red-500/20 text-red-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3" />
|
||||
Невалидно
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Link to assignment */}
|
||||
{dispute.assignment_id && (
|
||||
<Link
|
||||
to={`/assignments/${dispute.assignment_id}`}
|
||||
className="text-xs text-accent-400 hover:text-accent-300 flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Открыть
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Resolution buttons */}
|
||||
{dispute.status === 'open' && (
|
||||
<div className="flex gap-1.5 mt-1">
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-500/50 text-green-400 hover:bg-green-500/10 !px-2 !py-1 text-xs"
|
||||
onClick={() => handleResolveDispute(dispute.id, true)}
|
||||
isLoading={resolvingDisputeId === dispute.id}
|
||||
disabled={resolvingDisputeId !== null}
|
||||
icon={<CheckCircle className="w-3 h-3" />}
|
||||
>
|
||||
Валидно
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10 !px-2 !py-1 text-xs"
|
||||
onClick={() => handleResolveDispute(dispute.id, false)}
|
||||
isLoading={resolvingDisputeId === dispute.id}
|
||||
disabled={resolvingDisputeId !== null}
|
||||
icon={<XCircle className="w-3 h-3" />}
|
||||
>
|
||||
Невалидно
|
||||
</NeonButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Invite link */}
|
||||
{marathon.status !== 'finished' && (
|
||||
<GlassCard>
|
||||
|
||||
@@ -55,6 +55,15 @@ export function PlayPage() {
|
||||
|
||||
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
|
||||
|
||||
// Bonus challenge completion
|
||||
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
|
||||
const [bonusProofFile, setBonusProofFile] = useState<File | null>(null)
|
||||
const [bonusProofUrl, setBonusProofUrl] = useState('')
|
||||
const [bonusComment, setBonusComment] = useState('')
|
||||
const [isCompletingBonus, setIsCompletingBonus] = useState(false)
|
||||
|
||||
const bonusFileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const eventFileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -168,17 +177,17 @@ export function PlayPage() {
|
||||
const loadData = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const [marathonData, assignment, gamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
|
||||
const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
|
||||
marathonsApi.get(parseInt(id)),
|
||||
wheelApi.getCurrentAssignment(parseInt(id)),
|
||||
gamesApi.list(parseInt(id), 'approved'),
|
||||
gamesApi.getAvailableGames(parseInt(id)),
|
||||
eventsApi.getActive(parseInt(id)),
|
||||
eventsApi.getEventAssignment(parseInt(id)),
|
||||
assignmentsApi.getReturnedAssignments(parseInt(id)),
|
||||
])
|
||||
setMarathon(marathonData)
|
||||
setCurrentAssignment(assignment)
|
||||
setGames(gamesData)
|
||||
setGames(availableGamesData)
|
||||
setActiveEvent(eventData)
|
||||
setEventAssignment(eventAssignmentData)
|
||||
setReturnedAssignments(returnedData)
|
||||
@@ -219,10 +228,20 @@ export function PlayPage() {
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!currentAssignment) return
|
||||
|
||||
// For playthrough: allow file, URL, or comment
|
||||
// For challenges: require file or URL
|
||||
if (currentAssignment.is_playthrough) {
|
||||
if (!proofFile && !proofUrl && !comment) {
|
||||
toast.warning('Пожалуйста, предоставьте доказательство (файл, ссылку или комментарий)')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!proofFile && !proofUrl) {
|
||||
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setIsCompleting(true)
|
||||
try {
|
||||
@@ -270,6 +289,39 @@ export function PlayPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBonusComplete = async (bonusId: number) => {
|
||||
if (!currentAssignment) return
|
||||
if (!bonusProofFile && !bonusProofUrl && !bonusComment) {
|
||||
toast.warning('Прикрепите файл, ссылку или комментарий')
|
||||
return
|
||||
}
|
||||
|
||||
setIsCompletingBonus(true)
|
||||
try {
|
||||
const result = await assignmentsApi.completeBonusAssignment(
|
||||
currentAssignment.id,
|
||||
bonusId,
|
||||
{
|
||||
proof_file: bonusProofFile || undefined,
|
||||
proof_url: bonusProofUrl || undefined,
|
||||
comment: bonusComment || undefined,
|
||||
}
|
||||
)
|
||||
toast.success(`Бонус отмечен! +${result.points_earned} очков начислится при завершении игры`)
|
||||
setBonusProofFile(null)
|
||||
setBonusProofUrl('')
|
||||
setBonusComment('')
|
||||
setExpandedBonusId(null)
|
||||
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось выполнить бонус')
|
||||
} finally {
|
||||
setIsCompletingBonus(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEventComplete = async () => {
|
||||
if (!eventAssignment?.assignment) return
|
||||
if (!eventProofFile && !eventProofUrl) {
|
||||
@@ -529,12 +581,23 @@ export function PlayPage() {
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
{ra.is_playthrough ? (
|
||||
<>
|
||||
<p className="text-white font-medium">Прохождение: {ra.game_title}</p>
|
||||
<p className="text-gray-400 text-sm">Прохождение игры</p>
|
||||
</>
|
||||
) : ra.challenge ? (
|
||||
<>
|
||||
<p className="text-white font-medium">{ra.challenge.title}</p>
|
||||
<p className="text-gray-400 text-sm">{ra.challenge.game.title}</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{!ra.is_playthrough && ra.challenge && (
|
||||
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded-lg border border-green-500/30">
|
||||
+{ra.challenge.points}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-orange-300 text-xs mt-2">
|
||||
Причина: {ra.dispute_reason}
|
||||
@@ -640,28 +703,28 @@ export function PlayPage() {
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-400 text-sm mb-1">Игра</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{eventAssignment.assignment.challenge.game.title}
|
||||
{eventAssignment.assignment.challenge?.game.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-400 text-sm mb-1">Задание</p>
|
||||
<p className="text-xl font-bold text-neon-400 mb-2">
|
||||
{eventAssignment.assignment.challenge.title}
|
||||
{eventAssignment.assignment.challenge?.title}
|
||||
</p>
|
||||
<p className="text-gray-300">
|
||||
{eventAssignment.assignment.challenge.description}
|
||||
{eventAssignment.assignment.challenge?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
||||
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
|
||||
+{eventAssignment.assignment.challenge.points} очков
|
||||
+{eventAssignment.assignment.challenge?.points} очков
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
|
||||
{eventAssignment.assignment.challenge.difficulty}
|
||||
{eventAssignment.assignment.challenge?.difficulty}
|
||||
</span>
|
||||
{eventAssignment.assignment.challenge.estimated_time && (
|
||||
{eventAssignment.assignment.challenge?.estimated_time && (
|
||||
<span className="text-gray-400 text-sm flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
~{eventAssignment.assignment.challenge.estimated_time} мин
|
||||
@@ -669,7 +732,7 @@ export function PlayPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{eventAssignment.assignment.challenge.proof_hint && (
|
||||
{eventAssignment.assignment.challenge?.proof_hint && (
|
||||
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<p className="text-sm text-gray-400">
|
||||
<strong className="text-white">Нужно доказательство:</strong> {eventAssignment.assignment.challenge.proof_hint}
|
||||
@@ -680,7 +743,7 @@ export function PlayPage() {
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Загрузить доказательство ({eventAssignment.assignment.challenge.proof_type})
|
||||
Загрузить доказательство ({eventAssignment.assignment.challenge?.proof_type})
|
||||
</label>
|
||||
|
||||
<input
|
||||
@@ -891,36 +954,225 @@ export function PlayPage() {
|
||||
<>
|
||||
<GlassCard variant="neon">
|
||||
<div className="text-center mb-6">
|
||||
<span className="px-4 py-1.5 bg-neon-500/20 text-neon-400 rounded-full text-sm font-medium border border-neon-500/30">
|
||||
Активное задание
|
||||
<span className={`px-4 py-1.5 rounded-full text-sm font-medium border ${
|
||||
currentAssignment.is_playthrough
|
||||
? 'bg-accent-500/20 text-accent-400 border-accent-500/30'
|
||||
: 'bg-neon-500/20 text-neon-400 border-neon-500/30'
|
||||
}`}>
|
||||
{currentAssignment.is_playthrough ? 'Прохождение игры' : 'Активное задание'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-400 text-sm mb-1">Игра</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{currentAssignment.challenge.game.title}
|
||||
{currentAssignment.is_playthrough
|
||||
? currentAssignment.game?.title
|
||||
: currentAssignment.challenge?.game.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{currentAssignment.is_playthrough ? (
|
||||
// Playthrough task
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-400 text-sm mb-1">Задание</p>
|
||||
<p className="text-xl font-bold text-neon-400 mb-2">
|
||||
{currentAssignment.challenge.title}
|
||||
<p className="text-gray-400 text-sm mb-1">Задача</p>
|
||||
<p className="text-xl font-bold text-accent-400 mb-2">
|
||||
Пройти игру
|
||||
</p>
|
||||
<p className="text-gray-300">
|
||||
{currentAssignment.challenge.description}
|
||||
{currentAssignment.playthrough_info?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
||||
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
|
||||
+{currentAssignment.challenge.points} очков
|
||||
+{currentAssignment.playthrough_info?.points} очков
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{currentAssignment.playthrough_info?.proof_hint && (
|
||||
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<p className="text-sm text-gray-400">
|
||||
<strong className="text-white">Нужно доказательство:</strong> {currentAssignment.playthrough_info.proof_hint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bonus challenges */}
|
||||
{currentAssignment.bonus_challenges && currentAssignment.bonus_challenges.length > 0 && (
|
||||
<div className="mb-6 p-4 bg-accent-500/10 rounded-xl border border-accent-500/20">
|
||||
<p className="text-accent-400 font-medium mb-3">
|
||||
Бонусные челленджи (опционально) — {currentAssignment.bonus_challenges.filter(b => b.status === 'completed').length}/{currentAssignment.bonus_challenges.length}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{currentAssignment.bonus_challenges.map((bonus) => (
|
||||
<div
|
||||
key={bonus.id}
|
||||
className={`rounded-lg border overflow-hidden ${
|
||||
bonus.status === 'completed'
|
||||
? 'bg-green-500/10 border-green-500/30'
|
||||
: 'bg-dark-700/50 border-dark-600'
|
||||
}`}
|
||||
>
|
||||
{/* Bonus header */}
|
||||
<div
|
||||
className={`p-3 flex items-center justify-between ${
|
||||
bonus.status === 'pending' ? 'cursor-pointer hover:bg-dark-600/50' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (bonus.status === 'pending') {
|
||||
setExpandedBonusId(expandedBonusId === bonus.id ? null : bonus.id)
|
||||
setBonusProofFile(null)
|
||||
setBonusProofUrl('')
|
||||
setBonusComment('')
|
||||
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{bonus.status === 'completed' && (
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
)}
|
||||
<p className="text-white font-medium text-sm">{bonus.challenge.title}</p>
|
||||
</div>
|
||||
<p className="text-gray-400 text-xs mt-0.5">{bonus.challenge.description}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-2">
|
||||
{bonus.status === 'completed' ? (
|
||||
<span className="text-green-400 text-sm font-medium">+{bonus.points_earned}</span>
|
||||
) : (
|
||||
<span className="text-accent-400 text-sm">+{bonus.challenge.points}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded form for completing */}
|
||||
{expandedBonusId === bonus.id && bonus.status === 'pending' && (
|
||||
<div className="p-3 border-t border-dark-600 bg-dark-800/50 space-y-3">
|
||||
{bonus.challenge.proof_hint && (
|
||||
<p className="text-xs text-gray-400">
|
||||
<strong className="text-white">Пруф:</strong> {bonus.challenge.proof_hint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* File upload */}
|
||||
<input
|
||||
ref={bonusFileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
validateAndSetFile(e.target.files?.[0] || null, setBonusProofFile, bonusFileInputRef)
|
||||
}}
|
||||
/>
|
||||
{bonusProofFile ? (
|
||||
<div className="flex items-center gap-2 p-2 bg-dark-700/50 rounded-lg border border-dark-600">
|
||||
<span className="text-white text-sm flex-1 truncate">{bonusProofFile.name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setBonusProofFile(null)
|
||||
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
|
||||
}}
|
||||
className="p-1 rounded text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
bonusFileInputRef.current?.click()
|
||||
}}
|
||||
className="w-full p-2 border border-dashed border-dark-500 rounded-lg text-gray-400 text-sm hover:border-accent-400 hover:text-accent-400 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Загрузить файл
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="text-center text-gray-500 text-xs">или</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="input text-sm"
|
||||
placeholder="Ссылка на пруф (YouTube, Steam и т.д.)"
|
||||
value={bonusProofUrl}
|
||||
onChange={(e) => setBonusProofUrl(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<textarea
|
||||
className="input text-sm resize-none"
|
||||
placeholder="Комментарий (необязательно)"
|
||||
rows={2}
|
||||
value={bonusComment}
|
||||
onChange={(e) => setBonusComment(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleBonusComplete(bonus.id)
|
||||
}}
|
||||
isLoading={isCompletingBonus}
|
||||
disabled={!bonusProofFile && !bonusProofUrl && !bonusComment}
|
||||
icon={<Check className="w-3 h-3" />}
|
||||
>
|
||||
Выполнено
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setBonusProofFile(null)
|
||||
setBonusProofUrl('')
|
||||
setBonusComment('')
|
||||
setExpandedBonusId(null)
|
||||
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Нажмите на бонус, чтобы отметить. Очки начислятся при завершении игры.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Regular challenge
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-400 text-sm mb-1">Задание</p>
|
||||
<p className="text-xl font-bold text-neon-400 mb-2">
|
||||
{currentAssignment.challenge?.title}
|
||||
</p>
|
||||
<p className="text-gray-300">
|
||||
{currentAssignment.challenge?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
||||
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
|
||||
+{currentAssignment.challenge?.points} очков
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
|
||||
{currentAssignment.challenge.difficulty}
|
||||
{currentAssignment.challenge?.difficulty}
|
||||
</span>
|
||||
{currentAssignment.challenge.estimated_time && (
|
||||
{currentAssignment.challenge?.estimated_time && (
|
||||
<span className="text-gray-400 text-sm flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
~{currentAssignment.challenge.estimated_time} мин
|
||||
@@ -928,18 +1180,22 @@ export function PlayPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentAssignment.challenge.proof_hint && (
|
||||
{currentAssignment.challenge?.proof_hint && (
|
||||
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<p className="text-sm text-gray-400">
|
||||
<strong className="text-white">Нужно доказательство:</strong> {currentAssignment.challenge.proof_hint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Загрузить доказательство ({currentAssignment.challenge.proof_type})
|
||||
Загрузить доказательство ({currentAssignment.is_playthrough
|
||||
? currentAssignment.playthrough_info?.proof_type
|
||||
: currentAssignment.challenge?.proof_type})
|
||||
</label>
|
||||
|
||||
<input
|
||||
@@ -1000,7 +1256,10 @@ export function PlayPage() {
|
||||
className="flex-1"
|
||||
onClick={handleComplete}
|
||||
isLoading={isCompleting}
|
||||
disabled={!proofFile && !proofUrl}
|
||||
disabled={currentAssignment.is_playthrough
|
||||
? (!proofFile && !proofUrl && !comment)
|
||||
: (!proofFile && !proofUrl)
|
||||
}
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
>
|
||||
Выполнено
|
||||
|
||||
312
frontend/src/pages/admin/AdminDisputesPage.tsx
Normal file
312
frontend/src/pages/admin/AdminDisputesPage.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { AdminDispute } from '@/types'
|
||||
import { GlassCard, NeonButton } from '@/components/ui'
|
||||
import { useToast } from '@/store/toast'
|
||||
import {
|
||||
AlertTriangle, Loader2, CheckCircle, XCircle, Clock,
|
||||
ThumbsUp, ThumbsDown, User, Trophy, ExternalLink
|
||||
} from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export function AdminDisputesPage() {
|
||||
const toast = useToast()
|
||||
const [disputes, setDisputes] = useState<AdminDispute[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<'pending' | 'open' | 'all'>('pending')
|
||||
const [resolvingId, setResolvingId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadDisputes()
|
||||
}, [filter])
|
||||
|
||||
const loadDisputes = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listDisputes(filter)
|
||||
setDisputes(data)
|
||||
} catch (err) {
|
||||
toast.error('Не удалось загрузить оспаривания')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolve = async (disputeId: number, isValid: boolean) => {
|
||||
setResolvingId(disputeId)
|
||||
try {
|
||||
await adminApi.resolveDispute(disputeId, isValid)
|
||||
toast.success(isValid ? 'Пруф подтверждён' : 'Пруф отклонён')
|
||||
await loadDisputes()
|
||||
} catch (err) {
|
||||
toast.error('Не удалось разрешить диспут')
|
||||
} finally {
|
||||
setResolvingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const getTimeRemaining = (expiresAt: string) => {
|
||||
const now = new Date()
|
||||
const expires = new Date(expiresAt)
|
||||
const diff = expires.getTime() - now.getTime()
|
||||
|
||||
if (diff <= 0) return 'Истекло'
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
||||
|
||||
return `${hours}ч ${minutes}м`
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Голосование
|
||||
</span>
|
||||
)
|
||||
case 'pending_admin':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-orange-500/20 text-orange-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Ожидает решения
|
||||
</span>
|
||||
)
|
||||
case 'valid':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Валидно
|
||||
</span>
|
||||
)
|
||||
case 'invalid':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-red-500/20 text-red-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3" />
|
||||
Невалидно
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const pendingCount = disputes.filter(d => d.status === 'pending_admin').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-400">
|
||||
Оспаривания
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Управление диспутами и проверка пруфов
|
||||
</p>
|
||||
</div>
|
||||
{pendingCount > 0 && (
|
||||
<div className="px-4 py-2 bg-orange-500/20 border border-orange-500/30 rounded-xl">
|
||||
<span className="text-orange-400 font-semibold">{pendingCount}</span>
|
||||
<span className="text-gray-400 ml-2">ожида{pendingCount === 1 ? 'ет' : 'ют'} решения</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||
filter === 'pending'
|
||||
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
|
||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => setFilter('pending')}
|
||||
>
|
||||
Ожидают решения
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||
filter === 'open'
|
||||
? 'bg-blue-500/20 text-blue-400 border border-blue-500/30'
|
||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => setFilter('open')}
|
||||
>
|
||||
Голосование
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||
filter === 'all'
|
||||
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
|
||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-accent-500" />
|
||||
</div>
|
||||
) : disputes.length === 0 ? (
|
||||
<GlassCard className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-green-500/10 border border-green-500/30 flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-green-400" />
|
||||
</div>
|
||||
<p className="text-gray-400">
|
||||
{filter === 'pending' ? 'Нет оспариваний, ожидающих решения' :
|
||||
filter === 'open' ? 'Нет оспариваний в стадии голосования' :
|
||||
'Нет оспариваний'}
|
||||
</p>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{disputes.map((dispute) => (
|
||||
<GlassCard
|
||||
key={dispute.id}
|
||||
className={
|
||||
dispute.status === 'pending_admin' ? 'border-orange-500/30' :
|
||||
dispute.status === 'open' ? 'border-blue-500/30' : ''
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* Left side - Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center shrink-0">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-white font-semibold truncate">
|
||||
{dispute.challenge_title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Trophy className="w-3 h-3" />
|
||||
<span className="truncate">{dispute.marathon_title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Participants */}
|
||||
<div className="flex flex-wrap gap-4 mb-3 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<User className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-400">Автор:</span>
|
||||
<span className="text-white">{dispute.participant_nickname}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-400">Оспорил:</span>
|
||||
<span className="text-white">{dispute.raised_by_nickname}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-lg border border-dark-600 mb-3">
|
||||
<p className="text-sm text-gray-400 mb-1">Причина:</p>
|
||||
<p className="text-white text-sm">{dispute.reason}</p>
|
||||
</div>
|
||||
|
||||
{/* Votes & Time */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-green-400">
|
||||
<ThumbsUp className="w-4 h-4" />
|
||||
<span className="font-medium">{dispute.votes_valid}</span>
|
||||
</div>
|
||||
<span className="text-gray-600">/</span>
|
||||
<div className="flex items-center gap-1 text-red-400">
|
||||
<ThumbsDown className="w-4 h-4" />
|
||||
<span className="font-medium">{dispute.votes_invalid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-600">•</span>
|
||||
<span className="text-gray-400">{formatDate(dispute.created_at)}</span>
|
||||
{dispute.status === 'open' && (
|
||||
<>
|
||||
<span className="text-gray-600">•</span>
|
||||
<span className="text-yellow-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{getTimeRemaining(dispute.expires_at)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Status & Actions */}
|
||||
<div className="flex flex-col items-end gap-3 shrink-0">
|
||||
{getStatusBadge(dispute.status)}
|
||||
|
||||
{/* Link to assignment */}
|
||||
{dispute.assignment_id && (
|
||||
<Link
|
||||
to={`/assignments/${dispute.assignment_id}`}
|
||||
className="text-xs text-accent-400 hover:text-accent-300 flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Открыть
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Resolution buttons - show for open and pending_admin */}
|
||||
{(dispute.status === 'open' || dispute.status === 'pending_admin') && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Vote recommendation for pending disputes */}
|
||||
{dispute.status === 'pending_admin' && (
|
||||
<div className="text-xs text-gray-400 text-right mb-1">
|
||||
Рекомендация: {dispute.votes_invalid > dispute.votes_valid ? (
|
||||
<span className="text-red-400">невалидно</span>
|
||||
) : (
|
||||
<span className="text-green-400">валидно</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-500/50 text-green-400 hover:bg-green-500/10"
|
||||
onClick={() => handleResolve(dispute.id, true)}
|
||||
isLoading={resolvingId === dispute.id}
|
||||
disabled={resolvingId !== null}
|
||||
icon={<CheckCircle className="w-4 h-4" />}
|
||||
>
|
||||
Валидно
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||
onClick={() => handleResolve(dispute.id, false)}
|
||||
isLoading={resolvingId === dispute.id}
|
||||
disabled={resolvingId !== null}
|
||||
icon={<XCircle className="w-4 h-4" />}
|
||||
>
|
||||
Невалидно
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,13 +12,15 @@ import {
|
||||
Shield,
|
||||
MessageCircle,
|
||||
Sparkles,
|
||||
Lock
|
||||
Lock,
|
||||
AlertTriangle
|
||||
} from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
||||
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
||||
{ to: '/admin/disputes', icon: AlertTriangle, label: 'Оспаривания' },
|
||||
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
||||
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
||||
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
||||
|
||||
@@ -2,6 +2,7 @@ export { AdminLayout } from './AdminLayout'
|
||||
export { AdminDashboardPage } from './AdminDashboardPage'
|
||||
export { AdminUsersPage } from './AdminUsersPage'
|
||||
export { AdminMarathonsPage } from './AdminMarathonsPage'
|
||||
export { AdminDisputesPage } from './AdminDisputesPage'
|
||||
export { AdminLogsPage } from './AdminLogsPage'
|
||||
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
||||
export { AdminContentPage } from './AdminContentPage'
|
||||
|
||||
@@ -122,6 +122,14 @@ export interface LeaderboardEntry {
|
||||
|
||||
// Game types
|
||||
export type GameStatus = 'pending' | 'approved' | 'rejected'
|
||||
export type GameType = 'challenges' | 'playthrough'
|
||||
|
||||
export interface PlaythroughInfo {
|
||||
description: string
|
||||
points: number
|
||||
proof_type: ProofType
|
||||
proof_hint: string | null
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
id: number
|
||||
@@ -134,12 +142,24 @@ export interface Game {
|
||||
approved_by: User | null
|
||||
challenges_count: number
|
||||
created_at: string
|
||||
// Game type fields
|
||||
game_type: GameType
|
||||
playthrough_points: number | null
|
||||
playthrough_description: string | null
|
||||
playthrough_proof_type: ProofType | null
|
||||
playthrough_proof_hint: string | null
|
||||
}
|
||||
|
||||
export interface GameShort {
|
||||
id: number
|
||||
title: string
|
||||
cover_url: string | null
|
||||
game_type?: GameType
|
||||
}
|
||||
|
||||
export interface AvailableGamesCount {
|
||||
available: number
|
||||
total: number
|
||||
}
|
||||
|
||||
// Challenge types
|
||||
@@ -199,10 +219,27 @@ export interface ChallengesPreviewResponse {
|
||||
|
||||
// Assignment types
|
||||
export type AssignmentStatus = 'active' | 'completed' | 'dropped' | 'returned'
|
||||
export type BonusAssignmentStatus = 'pending' | 'completed'
|
||||
|
||||
export interface BonusAssignment {
|
||||
id: number
|
||||
challenge: Challenge
|
||||
status: BonusAssignmentStatus
|
||||
proof_url: string | null
|
||||
proof_image_url: string | null
|
||||
proof_comment: string | null
|
||||
points_earned: number
|
||||
completed_at: string | null
|
||||
can_dispute?: boolean
|
||||
dispute?: Dispute | null
|
||||
}
|
||||
|
||||
export interface Assignment {
|
||||
id: number
|
||||
challenge: Challenge
|
||||
challenge: Challenge | null // null for playthrough
|
||||
game?: GameShort // For playthrough
|
||||
is_playthrough?: boolean
|
||||
playthrough_info?: PlaythroughInfo // For playthrough
|
||||
status: AssignmentStatus
|
||||
proof_url: string | null
|
||||
proof_comment: string | null
|
||||
@@ -211,12 +248,16 @@ export interface Assignment {
|
||||
started_at: string
|
||||
completed_at: string | null
|
||||
drop_penalty: number
|
||||
bonus_challenges?: BonusAssignment[] // For playthrough
|
||||
}
|
||||
|
||||
export interface SpinResult {
|
||||
assignment_id: number
|
||||
game: Game
|
||||
challenge: Challenge
|
||||
challenge: Challenge | null // null for playthrough
|
||||
is_playthrough?: boolean
|
||||
playthrough_info?: PlaythroughInfo // For playthrough
|
||||
bonus_challenges?: Challenge[] // Available bonus challenges for playthrough
|
||||
can_drop: boolean
|
||||
drop_penalty: number
|
||||
}
|
||||
@@ -508,8 +549,42 @@ export interface DashboardStats {
|
||||
recent_logs: AdminLog[]
|
||||
}
|
||||
|
||||
// Admin dispute
|
||||
export interface AdminDispute {
|
||||
id: number
|
||||
assignment_id: number | null
|
||||
bonus_assignment_id: number | null
|
||||
marathon_id: number
|
||||
marathon_title: string
|
||||
challenge_title: string
|
||||
participant_nickname: string
|
||||
raised_by_nickname: string
|
||||
reason: string
|
||||
status: DisputeStatus
|
||||
votes_valid: number
|
||||
votes_invalid: number
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
// Marathon organizer dispute
|
||||
export interface MarathonDispute {
|
||||
id: number
|
||||
assignment_id: number | null
|
||||
bonus_assignment_id: number | null
|
||||
challenge_title: string
|
||||
participant_nickname: string
|
||||
raised_by_nickname: string
|
||||
reason: string
|
||||
status: DisputeStatus
|
||||
votes_valid: number
|
||||
votes_invalid: number
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
// Dispute types
|
||||
export type DisputeStatus = 'open' | 'valid' | 'invalid'
|
||||
export type DisputeStatus = 'open' | 'pending_admin' | 'valid' | 'invalid'
|
||||
|
||||
export interface DisputeComment {
|
||||
id: number
|
||||
@@ -541,7 +616,10 @@ export interface Dispute {
|
||||
|
||||
export interface AssignmentDetail {
|
||||
id: number
|
||||
challenge: Challenge
|
||||
challenge: Challenge | null // null for playthrough
|
||||
game?: GameShort // for playthrough
|
||||
is_playthrough: boolean
|
||||
playthrough_info?: PlaythroughInfo // for playthrough
|
||||
participant: User
|
||||
status: AssignmentStatus
|
||||
proof_url: string | null
|
||||
@@ -553,11 +631,16 @@ export interface AssignmentDetail {
|
||||
completed_at: string | null
|
||||
can_dispute: boolean
|
||||
dispute: Dispute | null
|
||||
bonus_challenges?: BonusAssignment[] // for playthrough
|
||||
}
|
||||
|
||||
export interface ReturnedAssignment {
|
||||
id: number
|
||||
challenge: Challenge
|
||||
challenge: Challenge | null // For challenge assignments
|
||||
is_playthrough: boolean
|
||||
game_id: number | null // For playthrough assignments
|
||||
game_title: string | null
|
||||
game_cover_url: string | null
|
||||
original_completed_at: string
|
||||
dispute_reason: string
|
||||
}
|
||||
|
||||
20
nginx.conf
20
nginx.conf
@@ -17,10 +17,6 @@ http {
|
||||
# File upload limit (15 MB)
|
||||
client_max_body_size 15M;
|
||||
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=api_auth:10m rate=10r/m;
|
||||
limit_req_zone $binary_remote_addr zone=api_general:10m rate=60r/m;
|
||||
|
||||
upstream backend {
|
||||
server backend:8000;
|
||||
}
|
||||
@@ -41,22 +37,8 @@ http {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Auth API - strict rate limit (10 req/min with burst of 5)
|
||||
location /api/v1/auth {
|
||||
limit_req zone=api_auth burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Backend API - general rate limit (60 req/min with burst of 20)
|
||||
# Backend API (rate limiting handled by backend via RATE_LIMIT_ENABLED env)
|
||||
location /api {
|
||||
limit_req zone=api_general burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
Reference in New Issue
Block a user