Add dispute system

This commit is contained in:
2025-12-16 00:33:50 +07:00
parent 339a212e57
commit c7966656d8
22 changed files with 1584 additions and 8 deletions

View File

@@ -0,0 +1,81 @@
"""Add disputes tables for proof verification system
Revision ID: 009_add_disputes
Revises: 008_rename_to_game_choice
Create Date: 2024-12-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '009_add_disputes'
down_revision: Union[str, None] = '008_rename_to_game_choice'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
# Create disputes table
if 'disputes' not in tables:
op.create_table(
'disputes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('assignment_id', sa.Integer(), nullable=False),
sa.Column('raised_by_id', sa.Integer(), nullable=False),
sa.Column('reason', sa.Text(), nullable=False),
sa.Column('status', sa.String(20), nullable=False, server_default='open'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('resolved_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['assignment_id'], ['assignments.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['raised_by_id'], ['users.id'], ondelete='CASCADE'),
sa.UniqueConstraint('assignment_id', name='uq_dispute_assignment'),
)
op.create_index('ix_disputes_assignment_id', 'disputes', ['assignment_id'])
# Create dispute_comments table
if 'dispute_comments' not in tables:
op.create_table(
'dispute_comments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('dispute_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['dispute_id'], ['disputes.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
)
op.create_index('ix_dispute_comments_dispute_id', 'dispute_comments', ['dispute_id'])
# Create dispute_votes table
if 'dispute_votes' not in tables:
op.create_table(
'dispute_votes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('dispute_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('vote', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['dispute_id'], ['disputes.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.UniqueConstraint('dispute_id', 'user_id', name='uq_dispute_vote_user'),
)
op.create_index('ix_dispute_votes_dispute_id', 'dispute_votes', ['dispute_id'])
def downgrade() -> None:
op.drop_index('ix_dispute_votes_dispute_id', table_name='dispute_votes')
op.drop_table('dispute_votes')
op.drop_index('ix_dispute_comments_dispute_id', table_name='dispute_comments')
op.drop_table('dispute_comments')
op.drop_index('ix_disputes_assignment_id', table_name='disputes')
op.drop_table('disputes')

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
@@ -13,3 +13,4 @@ router.include_router(wheel.router)
router.include_router(feed.router) router.include_router(feed.router)
router.include_router(admin.router) router.include_router(admin.router)
router.include_router(events.router) router.include_router(events.router)
router.include_router(assignments.router)

View File

@@ -0,0 +1,433 @@
"""
Assignment details and dispute system endpoints.
"""
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import (
Assignment, AssignmentStatus, Participant, Challenge, User,
Dispute, DisputeStatus, DisputeComment, DisputeVote,
)
from app.schemas import (
AssignmentDetailResponse, DisputeCreate, DisputeResponse,
DisputeCommentCreate, DisputeCommentResponse, DisputeVoteCreate,
MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse,
)
from app.schemas.user import UserPublic
router = APIRouter(tags=["assignments"])
# Dispute window: 24 hours after completion
DISPUTE_WINDOW_HOURS = 24
def user_to_public(user: User) -> UserPublic:
"""Convert User model to UserPublic schema"""
return UserPublic(
id=user.id,
login=user.login,
nickname=user.nickname,
avatar_url=None,
role=user.role,
created_at=user.created_at,
)
def build_dispute_response(dispute: Dispute, current_user_id: int) -> DisputeResponse:
"""Build DisputeResponse from Dispute model"""
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)
my_vote = None
for v in dispute.votes:
if v.user_id == current_user_id:
my_vote = v.vote
break
expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS)
return DisputeResponse(
id=dispute.id,
raised_by=user_to_public(dispute.raised_by),
reason=dispute.reason,
status=dispute.status,
comments=[
DisputeCommentResponse(
id=c.id,
user=user_to_public(c.user),
text=c.text,
created_at=c.created_at,
)
for c in sorted(dispute.comments, key=lambda x: x.created_at)
],
votes=[
{
"user": user_to_public(v.user),
"vote": v.vote,
"created_at": v.created_at,
}
for v in dispute.votes
],
votes_valid=votes_valid,
votes_invalid=votes_invalid,
my_vote=my_vote,
expires_at=expires_at,
created_at=dispute.created_at,
resolved_at=dispute.resolved_at,
)
@router.get("/assignments/{assignment_id}", response_model=AssignmentDetailResponse)
async def get_assignment_detail(
assignment_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get detailed information about an assignment including proofs and dispute"""
# Get assignment with all relationships
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.participant).selectinload(Participant.user),
selectinload(Assignment.dispute).selectinload(Dispute.raised_by),
selectinload(Assignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user),
selectinload(Assignment.dispute).selectinload(Dispute.votes).selectinload(DisputeVote.user),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
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")
# Build response
challenge = assignment.challenge
game = challenge.game
owner_user = assignment.participant.user
# Determine if user can dispute
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
):
time_since_completion = datetime.utcnow() - assignment.completed_at
can_dispute = time_since_completion < timedelta(hours=DISPUTE_WINDOW_HOURS)
# Build proof URLs
proof_image_url = None
if assignment.proof_path:
# Extract filename from path
proof_image_url = f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}"
return AssignmentDetailResponse(
id=assignment.id,
challenge=ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(
id=game.id,
title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
),
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,
)
@router.post("/assignments/{assignment_id}/dispute", response_model=DisputeResponse)
async def create_dispute(
assignment_id: int,
data: DisputeCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a dispute against an assignment's proof"""
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.participant),
selectinload(Assignment.dispute),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
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 assignment.status != AssignmentStatus.COMPLETED.value:
raise HTTPException(status_code=400, detail="Can only dispute completed assignments")
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")
if not assignment.completed_at:
raise HTTPException(status_code=400, detail="Assignment has no completion date")
time_since_completion = datetime.utcnow() - 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
dispute = Dispute(
assignment_id=assignment_id,
raised_by_id=current_user.id,
reason=data.reason,
status=DisputeStatus.OPEN.value,
)
db.add(dispute)
await db.commit()
await db.refresh(dispute)
# 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("/disputes/{dispute_id}/comments", response_model=DisputeCommentResponse)
async def add_dispute_comment(
dispute_id: int,
data: DisputeCommentCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Add a comment to a dispute discussion"""
# Get dispute with assignment
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise HTTPException(status_code=404, detail="Dispute not found")
if dispute.status != DisputeStatus.OPEN.value:
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Check user is participant of the marathon
marathon_id = dispute.assignment.challenge.game.marathon_id
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")
# Create comment
comment = DisputeComment(
dispute_id=dispute_id,
user_id=current_user.id,
text=data.text,
)
db.add(comment)
await db.commit()
await db.refresh(comment)
# Get user for response
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one()
return DisputeCommentResponse(
id=comment.id,
user=user_to_public(user),
text=comment.text,
created_at=comment.created_at,
)
@router.post("/disputes/{dispute_id}/vote", response_model=MessageResponse)
async def vote_on_dispute(
dispute_id: int,
data: DisputeVoteCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Vote on a dispute (True = valid/proof is OK, False = invalid/proof is not OK)"""
# Get dispute with assignment
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise HTTPException(status_code=404, detail="Dispute not found")
if dispute.status != DisputeStatus.OPEN.value:
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Check user is participant of the marathon
marathon_id = dispute.assignment.challenge.game.marathon_id
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")
# Check if user already voted
result = await db.execute(
select(DisputeVote).where(
DisputeVote.dispute_id == dispute_id,
DisputeVote.user_id == current_user.id,
)
)
existing_vote = result.scalar_one_or_none()
if existing_vote:
# Update existing vote
existing_vote.vote = data.vote
existing_vote.created_at = datetime.utcnow()
else:
# Create new vote
vote = DisputeVote(
dispute_id=dispute_id,
user_id=current_user.id,
vote=data.vote,
)
db.add(vote)
await db.commit()
vote_text = "валидным" if data.vote else "невалидным"
return MessageResponse(message=f"Вы проголосовали: пруф {vote_text}")
@router.get("/marathons/{marathon_id}/returned-assignments", response_model=list[ReturnedAssignmentResponse])
async def get_returned_assignments(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get current user's returned assignments that need to be redone"""
# Check user is participant
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")
# Get returned assignments
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.dispute),
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.RETURNED.value,
)
.order_by(Assignment.completed_at.asc()) # Oldest first
)
assignments = result.scalars().all()
return [
ReturnedAssignmentResponse(
id=a.id,
challenge=ChallengeResponse(
id=a.challenge.id,
title=a.challenge.title,
description=a.challenge.description,
type=a.challenge.type,
difficulty=a.challenge.difficulty,
points=a.challenge.points,
estimated_time=a.challenge.estimated_time,
proof_type=a.challenge.proof_type,
proof_hint=a.challenge.proof_hint,
game=GameShort(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=f"/uploads/covers/{a.challenge.game.cover_path.split('/')[-1]}" if a.challenge.game.cover_path else None,
),
is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at,
),
original_completed_at=a.completed_at,
dispute_reason=a.dispute.reason if a.dispute else "Оспорено",
)
for a in assignments
]

View File

@@ -1111,6 +1111,7 @@ async def complete_event_assignment(
# Log activity # Log activity
activity_data = { activity_data = {
"assignment_id": assignment.id,
"game": challenge.game.title, "game": challenge.game.title,
"challenge": challenge.title, "challenge": challenge.title,
"difficulty": challenge.difficulty, "difficulty": challenge.difficulty,

View File

@@ -60,6 +60,39 @@ async def get_active_assignment(db, participant_id: int, is_event: bool = False)
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment | None:
"""Get the oldest returned assignment that needs to be redone."""
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant_id,
Assignment.status == AssignmentStatus.RETURNED.value,
Assignment.is_event_assignment == False,
)
.order_by(Assignment.completed_at.asc()) # Oldest first
.limit(1)
)
return result.scalar_one_or_none()
async def activate_returned_assignment(db, returned_assignment: Assignment) -> None:
"""
Re-activate a returned assignment.
Simply changes the status back to ACTIVE.
"""
returned_assignment.status = AssignmentStatus.ACTIVE.value
returned_assignment.started_at = datetime.utcnow()
# Clear previous proof data for fresh attempt
returned_assignment.proof_path = None
returned_assignment.proof_url = None
returned_assignment.proof_comment = None
returned_assignment.completed_at = None
returned_assignment.points_earned = 0
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult) @router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession): 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"""
@@ -347,6 +380,7 @@ async def complete_assignment(
# Log activity # Log activity
activity_data = { activity_data = {
"assignment_id": assignment.id,
"game": full_challenge.game.title, "game": full_challenge.game.title,
"challenge": challenge.title, "challenge": challenge.title,
"difficulty": challenge.difficulty, "difficulty": challenge.difficulty,
@@ -407,6 +441,13 @@ async def complete_assignment(
await db.commit() await db.commit()
# Check for returned assignments and activate the oldest one
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
if returned_assignment:
await activate_returned_assignment(db, returned_assignment)
await db.commit()
print(f"[WHEEL] Auto-activated returned assignment {returned_assignment.id} for participant {participant.id}")
return CompleteResult( return CompleteResult(
points_earned=total_points, points_earned=total_points,
streak_bonus=streak_bonus, streak_bonus=streak_bonus,

View File

@@ -9,6 +9,7 @@ from app.core.config import settings
from app.core.database import engine, Base, async_session_maker from app.core.database import engine, Base, async_session_maker
from app.api.v1 import router as api_router from app.api.v1 import router as api_router
from app.services.event_scheduler import event_scheduler from app.services.event_scheduler import event_scheduler
from app.services.dispute_scheduler import dispute_scheduler
@asynccontextmanager @asynccontextmanager
@@ -23,13 +24,15 @@ async def lifespan(app: FastAPI):
(upload_dir / "covers").mkdir(parents=True, exist_ok=True) (upload_dir / "covers").mkdir(parents=True, exist_ok=True)
(upload_dir / "proofs").mkdir(parents=True, exist_ok=True) (upload_dir / "proofs").mkdir(parents=True, exist_ok=True)
# Start event scheduler # Start schedulers
await event_scheduler.start(async_session_maker) await event_scheduler.start(async_session_maker)
await dispute_scheduler.start(async_session_maker)
yield yield
# Shutdown # Shutdown
await event_scheduler.stop() await event_scheduler.stop()
await dispute_scheduler.stop()
await engine.dispose() await engine.dispose()

View File

@@ -7,6 +7,7 @@ from app.models.assignment import Assignment, AssignmentStatus
from app.models.activity import Activity, ActivityType from app.models.activity import Activity, ActivityType
from app.models.event import Event, EventType from app.models.event import Event, EventType
from app.models.swap_request import SwapRequest, SwapRequestStatus from app.models.swap_request import SwapRequest, SwapRequestStatus
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
__all__ = [ __all__ = [
"User", "User",
@@ -30,4 +31,8 @@ __all__ = [
"EventType", "EventType",
"SwapRequest", "SwapRequest",
"SwapRequestStatus", "SwapRequestStatus",
"Dispute",
"DisputeStatus",
"DisputeComment",
"DisputeVote",
] ]

View File

@@ -10,6 +10,7 @@ class AssignmentStatus(str, Enum):
ACTIVE = "active" ACTIVE = "active"
COMPLETED = "completed" COMPLETED = "completed"
DROPPED = "dropped" DROPPED = "dropped"
RETURNED = "returned" # Disputed and needs to be redone
class Assignment(Base): class Assignment(Base):
@@ -34,3 +35,4 @@ class Assignment(Base):
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments") participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments") challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments") event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")
dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False)

View File

@@ -0,0 +1,66 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class DisputeStatus(str, Enum):
OPEN = "open"
RESOLVED_VALID = "valid"
RESOLVED_INVALID = "invalid"
class Dispute(Base):
"""Dispute against a completed 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)
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)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
assignment: Mapped["Assignment"] = relationship("Assignment", 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")
class DisputeComment(Base):
"""Comment in a dispute discussion"""
__tablename__ = "dispute_comments"
id: Mapped[int] = mapped_column(primary_key=True)
dispute_id: Mapped[int] = mapped_column(ForeignKey("disputes.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
text: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
dispute: Mapped["Dispute"] = relationship("Dispute", back_populates="comments")
user: Mapped["User"] = relationship("User")
class DisputeVote(Base):
"""Vote in a dispute (valid or invalid)"""
__tablename__ = "dispute_votes"
id: Mapped[int] = mapped_column(primary_key=True)
dispute_id: Mapped[int] = mapped_column(ForeignKey("disputes.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
vote: Mapped[bool] = mapped_column(Boolean, nullable=False) # True = valid, False = invalid
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Unique constraint: one vote per user per dispute
__table_args__ = (
UniqueConstraint("dispute_id", "user_id", name="uq_dispute_vote_user"),
)
# Relationships
dispute: Mapped["Dispute"] = relationship("Dispute", back_populates="votes")
user: Mapped["User"] = relationship("User")

View File

@@ -67,6 +67,16 @@ from app.schemas.common import (
ErrorResponse, ErrorResponse,
PaginationParams, PaginationParams,
) )
from app.schemas.dispute import (
DisputeCreate,
DisputeCommentCreate,
DisputeVoteCreate,
DisputeCommentResponse,
DisputeVoteResponse,
DisputeResponse,
AssignmentDetailResponse,
ReturnedAssignmentResponse,
)
__all__ = [ __all__ = [
# User # User
@@ -130,4 +140,13 @@ __all__ = [
"MessageResponse", "MessageResponse",
"ErrorResponse", "ErrorResponse",
"PaginationParams", "PaginationParams",
# Dispute
"DisputeCreate",
"DisputeCommentCreate",
"DisputeVoteCreate",
"DisputeCommentResponse",
"DisputeVoteResponse",
"DisputeResponse",
"AssignmentDetailResponse",
"ReturnedAssignmentResponse",
] ]

View File

@@ -0,0 +1,91 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.user import UserPublic
from app.schemas.challenge import ChallengeResponse
class DisputeCreate(BaseModel):
"""Request to create a dispute"""
reason: str = Field(..., min_length=10, max_length=1000)
class DisputeCommentCreate(BaseModel):
"""Request to add a comment to a dispute"""
text: str = Field(..., min_length=1, max_length=500)
class DisputeVoteCreate(BaseModel):
"""Request to vote on a dispute"""
vote: bool # True = valid (proof is OK), False = invalid (proof is not OK)
class DisputeCommentResponse(BaseModel):
"""Comment in a dispute discussion"""
id: int
user: UserPublic
text: str
created_at: datetime
class Config:
from_attributes = True
class DisputeVoteResponse(BaseModel):
"""Vote in a dispute"""
user: UserPublic
vote: bool # True = valid, False = invalid
created_at: datetime
class Config:
from_attributes = True
class DisputeResponse(BaseModel):
"""Full dispute information"""
id: int
raised_by: UserPublic
reason: str
status: str # "open", "valid", "invalid"
comments: list[DisputeCommentResponse]
votes: list[DisputeVoteResponse]
votes_valid: int
votes_invalid: int
my_vote: bool | None # Current user's vote, None if not voted
expires_at: datetime
created_at: datetime
resolved_at: datetime | None
class Config:
from_attributes = True
class AssignmentDetailResponse(BaseModel):
"""Detailed assignment information with proofs and dispute"""
id: int
challenge: ChallengeResponse
participant: UserPublic
status: str
proof_url: str | None # External URL (YouTube, etc.)
proof_image_url: str | None # Uploaded file URL
proof_comment: str | None
points_earned: int
streak_at_completion: int | None
started_at: datetime
completed_at: datetime | None
can_dispute: bool # True if <24h since completion and not own assignment
dispute: DisputeResponse | None
class Config:
from_attributes = True
class ReturnedAssignmentResponse(BaseModel):
"""Returned assignment that needs to be redone"""
id: int
challenge: ChallengeResponse
original_completed_at: datetime
dispute_reason: str
class Config:
from_attributes = True

View File

@@ -0,0 +1,89 @@
"""
Dispute Scheduler for automatic dispute resolution after 24 hours.
"""
import asyncio
from datetime import datetime, timedelta
from sqlalchemy import select
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
# Configuration
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
DISPUTE_WINDOW_HOURS = 24 # Disputes auto-resolve after 24 hours
class DisputeScheduler:
"""Background scheduler for automatic dispute resolution."""
def __init__(self):
self._running = False
self._task: asyncio.Task | None = None
async def start(self, session_factory) -> None:
"""Start the scheduler background task."""
if self._running:
return
self._running = True
self._task = asyncio.create_task(self._run_loop(session_factory))
print("[DisputeScheduler] Started")
async def stop(self) -> None:
"""Stop the scheduler."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
print("[DisputeScheduler] Stopped")
async def _run_loop(self, session_factory) -> None:
"""Main scheduler loop."""
while self._running:
try:
async with session_factory() as db:
await self._process_expired_disputes(db)
except Exception as e:
print(f"[DisputeScheduler] Error in loop: {e}")
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
async def _process_expired_disputes(self, db: AsyncSession) -> None:
"""Process and resolve expired disputes."""
cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS)
# Find all open disputes that have expired
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant),
)
.where(
Dispute.status == DisputeStatus.OPEN.value,
Dispute.created_at < cutoff_time,
)
)
expired_disputes = result.scalars().all()
for dispute in expired_disputes:
try:
result_status, votes_valid, votes_invalid = await dispute_service.resolve_dispute(
db, dispute.id
)
print(
f"[DisputeScheduler] Auto-resolved dispute {dispute.id}: "
f"{result_status} (valid: {votes_valid}, invalid: {votes_invalid})"
)
except Exception as e:
print(f"[DisputeScheduler] Failed to resolve dispute {dispute.id}: {e}")
# Global scheduler instance
dispute_scheduler = DisputeScheduler()

View File

@@ -0,0 +1,103 @@
"""
Dispute resolution service.
"""
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import (
Dispute, DisputeStatus, DisputeVote,
Assignment, AssignmentStatus, Participant,
)
class DisputeService:
"""Service for dispute resolution logic"""
async def resolve_dispute(self, db: AsyncSession, dispute_id: int) -> tuple[str, int, int]:
"""
Resolve a dispute based on votes.
Returns:
Tuple of (result_status, votes_valid, votes_invalid)
"""
# Get dispute with votes and assignment
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise ValueError(f"Dispute {dispute_id} not found")
if dispute.status != DisputeStatus.OPEN.value:
raise ValueError(f"Dispute {dispute_id} is already resolved")
# 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)
# Determine result: tie goes to the accused (valid)
if votes_invalid > votes_valid:
# Proof is invalid - mark assignment as RETURNED
result_status = DisputeStatus.RESOLVED_INVALID.value
await self._handle_invalid_proof(db, dispute)
else:
# Proof is valid (or tie)
result_status = DisputeStatus.RESOLVED_VALID.value
# Update dispute
dispute.status = result_status
dispute.resolved_at = datetime.utcnow()
await db.commit()
return result_status, votes_valid, votes_invalid
async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None:
"""
Handle the case when proof is determined to be invalid.
- Mark assignment as RETURNED
- Subtract points from participant
- Reset streak if it was affected
"""
assignment = dispute.assignment
participant = assignment.participant
# Subtract points that were earned
points_to_subtract = assignment.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract)
# Reset assignment
assignment.status = AssignmentStatus.RETURNED.value
assignment.points_earned = 0
# Keep proof data so it can be reviewed
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"""
from datetime import timedelta
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute(
select(Dispute)
.where(
Dispute.status == DisputeStatus.OPEN.value,
Dispute.created_at < cutoff_time,
)
)
return list(result.scalars().all())
# Global service instance
dispute_service = DisputeService()

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

View File

@@ -15,6 +15,7 @@ import { LobbyPage } from '@/pages/LobbyPage'
import { PlayPage } from '@/pages/PlayPage' import { PlayPage } from '@/pages/PlayPage'
import { LeaderboardPage } from '@/pages/LeaderboardPage' import { LeaderboardPage } from '@/pages/LeaderboardPage'
import { InvitePage } from '@/pages/InvitePage' import { InvitePage } from '@/pages/InvitePage'
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
// Protected route wrapper // Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -118,6 +119,15 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="assignments/:id"
element={
<ProtectedRoute>
<AssignmentDetailPage />
</ProtectedRoute>
}
/>
</Route> </Route>
</Routes> </Routes>
) )

View File

@@ -0,0 +1,34 @@
import client from './client'
import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment } from '@/types'
export const assignmentsApi = {
// Get detailed assignment info with proofs and dispute
getDetail: async (assignmentId: number): Promise<AssignmentDetail> => {
const response = await client.get<AssignmentDetail>(`/assignments/${assignmentId}`)
return response.data
},
// Create a dispute against an assignment
createDispute: async (assignmentId: number, reason: string): Promise<Dispute> => {
const response = await client.post<Dispute>(`/assignments/${assignmentId}/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 })
return response.data
},
// Vote on a dispute (true = valid/proof is OK, false = invalid/proof is not OK)
vote: async (disputeId: number, vote: boolean): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(`/disputes/${disputeId}/vote`, { vote })
return response.data
},
// Get current user's returned assignments
getReturnedAssignments: async (marathonId: number): Promise<ReturnedAssignment[]> => {
const response = await client.get<ReturnedAssignment[]>(`/marathons/${marathonId}/returned-assignments`)
return response.data
},
}

View File

@@ -6,3 +6,4 @@ export { feedApi } from './feed'
export { adminApi } from './admin' export { adminApi } from './admin'
export { eventsApi } from './events' export { eventsApi } from './events'
export { challengesApi } from './challenges' export { challengesApi } from './challenges'
export { assignmentsApi } from './assignments'

View File

@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react' import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { feedApi } from '@/api' import { feedApi } from '@/api'
import type { Activity, ActivityType } from '@/types' import type { Activity, ActivityType } from '@/types'
import { Loader2, ChevronDown, Bell } from 'lucide-react' import { Loader2, ChevronDown, Bell, ExternalLink } from 'lucide-react'
import { import {
formatRelativeTime, formatRelativeTime,
getActivityIcon, getActivityIcon,
@@ -169,12 +170,18 @@ interface ActivityItemProps {
} }
function ActivityItem({ activity }: ActivityItemProps) { function ActivityItem({ activity }: ActivityItemProps) {
const navigate = useNavigate()
const Icon = getActivityIcon(activity.type) const Icon = getActivityIcon(activity.type)
const iconColor = getActivityColor(activity.type) const iconColor = getActivityColor(activity.type)
const bgClass = getActivityBgClass(activity.type) const bgClass = getActivityBgClass(activity.type)
const isEvent = isEventActivity(activity.type) const isEvent = isEventActivity(activity.type)
const { title, details, extra } = formatActivityMessage(activity) const { title, details, extra } = formatActivityMessage(activity)
// Get assignment_id for complete activities
const assignmentId = activity.type === 'complete' && activity.data
? (activity.data as { assignment_id?: number }).assignment_id
: null
if (isEvent) { if (isEvent) {
return ( return (
<div className={`px-4 py-3 ${bgClass} border-l-2 ${activity.type === 'event_start' ? 'border-l-yellow-500' : 'border-l-gray-600'}`}> <div className={`px-4 py-3 ${bgClass} border-l-2 ${activity.type === 'event_start' ? 'border-l-yellow-500' : 'border-l-gray-600'}`}>
@@ -240,6 +247,16 @@ function ActivityItem({ activity }: ActivityItemProps) {
{extra} {extra}
</div> </div>
)} )}
{/* Details button for complete activities */}
{assignmentId && (
<button
onClick={() => navigate(`/assignments/${assignmentId}`)}
className="mt-2 text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
Детали
</button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,481 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { assignmentsApi } from '@/api'
import type { AssignmentDetail } from '@/types'
import { Card, CardContent, Button } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import {
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
Send, Flag
} from 'lucide-react'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
export function AssignmentDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const [assignment, setAssignment] = useState<AssignmentDetail | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Dispute creation
const [showDisputeForm, setShowDisputeForm] = useState(false)
const [disputeReason, setDisputeReason] = useState('')
const [isCreatingDispute, setIsCreatingDispute] = useState(false)
// Comment
const [commentText, setCommentText] = useState('')
const [isAddingComment, setIsAddingComment] = useState(false)
// Voting
const [isVoting, setIsVoting] = useState(false)
useEffect(() => {
loadAssignment()
}, [id])
const loadAssignment = async () => {
if (!id) return
setIsLoading(true)
setError(null)
try {
const data = await assignmentsApi.getDetail(parseInt(id))
setAssignment(data)
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || 'Не удалось загрузить данные')
} finally {
setIsLoading(false)
}
}
const handleCreateDispute = async () => {
if (!id || !disputeReason.trim()) return
setIsCreatingDispute(true)
try {
await assignmentsApi.createDispute(parseInt(id), disputeReason)
setDisputeReason('')
setShowDisputeForm(false)
await loadAssignment()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось создать оспаривание')
} finally {
setIsCreatingDispute(false)
}
}
const handleVote = async (vote: boolean) => {
if (!assignment?.dispute) return
setIsVoting(true)
try {
await assignmentsApi.vote(assignment.dispute.id, vote)
await loadAssignment()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось проголосовать')
} finally {
setIsVoting(false)
}
}
const handleAddComment = async () => {
if (!assignment?.dispute || !commentText.trim()) return
setIsAddingComment(true)
try {
await assignmentsApi.addComment(assignment.dispute.id, commentText)
setCommentText('')
await loadAssignment()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось добавить комментарий')
} finally {
setIsAddingComment(false)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
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 'completed':
return (
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm flex items-center gap-1">
<CheckCircle className="w-4 h-4" /> Выполнено
</span>
)
case 'dropped':
return (
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm flex items-center gap-1">
<XCircle className="w-4 h-4" /> Пропущено
</span>
)
case 'returned':
return (
<span className="px-3 py-1 bg-orange-500/20 text-orange-400 rounded-full text-sm flex items-center gap-1">
<AlertTriangle className="w-4 h-4" /> Возвращено
</span>
)
default:
return (
<span className="px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full text-sm">
Активно
</span>
)
}
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
if (error || !assignment) {
return (
<div className="max-w-2xl mx-auto text-center py-12">
<p className="text-red-400 mb-4">{error || 'Задание не найдено'}</p>
<Button onClick={() => navigate(-1)}>Назад</Button>
</div>
)
}
const dispute = assignment.dispute
return (
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<button onClick={() => navigate(-1)} className="text-gray-400 hover:text-white">
<ArrowLeft className="w-6 h-6" />
</button>
<h1 className="text-xl font-bold text-white">Детали выполнения</h1>
</div>
{/* Challenge info */}
<Card className="mb-6">
<CardContent>
<div className="flex items-start justify-between mb-4">
<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>
</div>
{getStatusBadge(assignment.status)}
</div>
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
<div className="flex flex-wrap gap-2 mb-4">
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm">
+{assignment.challenge.points} очков
</span>
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full text-sm">
{assignment.challenge.difficulty}
</span>
{assignment.challenge.estimated_time && (
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full text-sm">
~{assignment.challenge.estimated_time} мин
</span>
)}
</div>
<div className="text-sm text-gray-400 space-y-1">
<p>
<strong>Выполнил:</strong> {assignment.participant.nickname}
</p>
{assignment.completed_at && (
<p>
<strong>Дата:</strong> {formatDate(assignment.completed_at)}
</p>
)}
{assignment.points_earned > 0 && (
<p>
<strong>Получено очков:</strong> {assignment.points_earned}
</p>
)}
</div>
</CardContent>
</Card>
{/* Proof section */}
<Card className="mb-6">
<CardContent>
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<Image className="w-5 h-5" />
Доказательство
</h3>
{/* Proof image */}
{assignment.proof_image_url && (
<div className="mb-4">
<img
src={`${API_URL}${assignment.proof_image_url}`}
alt="Proof"
className="w-full rounded-lg max-h-96 object-contain bg-gray-900"
/>
</div>
)}
{/* Proof URL */}
{assignment.proof_url && (
<div className="mb-4">
<a
href={assignment.proof_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-400 hover:text-primary-300"
>
<ExternalLink className="w-4 h-4" />
{assignment.proof_url}
</a>
</div>
)}
{/* Proof comment */}
{assignment.proof_comment && (
<div className="p-3 bg-gray-900 rounded-lg">
<p className="text-sm text-gray-400 mb-1">Комментарий:</p>
<p className="text-white">{assignment.proof_comment}</p>
</div>
)}
{!assignment.proof_image_url && !assignment.proof_url && (
<p className="text-gray-500 text-center py-4">Пруф не предоставлен</p>
)}
</CardContent>
</Card>
{/* Dispute button */}
{assignment.can_dispute && !dispute && !showDisputeForm && (
<Button
variant="danger"
className="w-full mb-6"
onClick={() => setShowDisputeForm(true)}
>
<Flag className="w-4 h-4 mr-2" />
Оспорить выполнение
</Button>
)}
{/* Dispute creation form */}
{showDisputeForm && !dispute && (
<Card className="mb-6 border-red-500/50">
<CardContent>
<h3 className="text-lg font-bold text-red-400 mb-4 flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Оспорить выполнение
</h3>
<p className="text-gray-400 text-sm mb-4">
Опишите причину оспаривания. После создания у участников будет 24 часа для голосования.
</p>
<textarea
className="input w-full min-h-[100px] resize-none mb-4"
placeholder="Причина оспаривания (минимум 10 символов)..."
value={disputeReason}
onChange={(e) => setDisputeReason(e.target.value)}
/>
<div className="flex gap-3">
<Button
variant="danger"
className="flex-1"
onClick={handleCreateDispute}
isLoading={isCreatingDispute}
disabled={disputeReason.trim().length < 10}
>
Оспорить
</Button>
<Button
variant="secondary"
onClick={() => {
setShowDisputeForm(false)
setDisputeReason('')
}}
>
Отмена
</Button>
</div>
</CardContent>
</Card>
)}
{/* Dispute section */}
{dispute && (
<Card className={`mb-6 ${dispute.status === 'open' ? 'border-yellow-500/50' : ''}`}>
<CardContent>
<div className="flex items-start justify-between mb-4">
<h3 className="text-lg font-bold text-yellow-400 flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Оспаривание
</h3>
{dispute.status === 'open' ? (
<span className="px-3 py-1 bg-yellow-500/20 text-yellow-400 rounded-full text-sm flex items-center gap-1">
<Clock className="w-4 h-4" />
{getTimeRemaining(dispute.expires_at)}
</span>
) : dispute.status === 'valid' ? (
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm flex items-center gap-1">
<CheckCircle className="w-4 h-4" />
Пруф валиден
</span>
) : (
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm flex items-center gap-1">
<XCircle className="w-4 h-4" />
Пруф невалиден
</span>
)}
</div>
<div className="mb-4">
<p className="text-sm text-gray-400">
Открыл: <span className="text-white">{dispute.raised_by.nickname}</span>
</p>
<p className="text-sm text-gray-400">
Дата: <span className="text-white">{formatDate(dispute.created_at)}</span>
</p>
</div>
<div className="p-3 bg-gray-900 rounded-lg mb-4">
<p className="text-sm text-gray-400 mb-1">Причина:</p>
<p className="text-white">{dispute.reason}</p>
</div>
{/* Voting section */}
{dispute.status === 'open' && (
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-300 mb-3">Голосование</h4>
<div className="flex items-center gap-4 mb-3">
<div className="flex items-center gap-2">
<ThumbsUp className="w-5 h-5 text-green-500" />
<span className="text-green-400 font-medium">{dispute.votes_valid}</span>
<span className="text-gray-500 text-sm">валидно</span>
</div>
<div className="flex items-center gap-2">
<ThumbsDown className="w-5 h-5 text-red-500" />
<span className="text-red-400 font-medium">{dispute.votes_invalid}</span>
<span className="text-gray-500 text-sm">невалидно</span>
</div>
</div>
<div className="flex gap-3">
<Button
variant={dispute.my_vote === true ? 'primary' : 'secondary'}
className="flex-1"
onClick={() => handleVote(true)}
isLoading={isVoting}
disabled={isVoting}
>
<ThumbsUp className="w-4 h-4 mr-2" />
Валидно
</Button>
<Button
variant={dispute.my_vote === false ? 'danger' : 'secondary'}
className="flex-1"
onClick={() => handleVote(false)}
isLoading={isVoting}
disabled={isVoting}
>
<ThumbsDown className="w-4 h-4 mr-2" />
Невалидно
</Button>
</div>
{dispute.my_vote !== null && (
<p className="text-sm text-gray-500 mt-2 text-center">
Вы проголосовали: {dispute.my_vote ? 'валидно' : 'невалидно'}
</p>
)}
</div>
)}
{/* Comments section */}
<div>
<h4 className="text-sm font-medium text-gray-300 mb-3 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Обсуждение ({dispute.comments.length})
</h4>
{dispute.comments.length > 0 && (
<div className="space-y-3 mb-4 max-h-60 overflow-y-auto">
{dispute.comments.map((comment) => (
<div key={comment.id} className="p-3 bg-gray-900 rounded-lg">
<div className="flex items-center justify-between mb-1">
<span className={`font-medium ${comment.user.id === user?.id ? 'text-primary-400' : 'text-white'}`}>
{comment.user.nickname}
{comment.user.id === user?.id && ' (Вы)'}
</span>
<span className="text-xs text-gray-500">
{formatDate(comment.created_at)}
</span>
</div>
<p className="text-gray-300 text-sm">{comment.text}</p>
</div>
))}
</div>
)}
{/* Add comment form */}
{dispute.status === 'open' && (
<div className="flex gap-2">
<input
type="text"
className="input flex-1"
placeholder="Написать комментарий..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleAddComment()
}
}}
/>
<Button
onClick={handleAddComment}
isLoading={isAddingComment}
disabled={!commentText.trim()}
>
<Send className="w-4 h-4" />
</Button>
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -1,11 +1,11 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { marathonsApi, wheelApi, gamesApi, eventsApi } from '@/api' import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi } from '@/api'
import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges } from '@/types' import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment } from '@/types'
import { Button, Card, CardContent } from '@/components/ui' import { Button, Card, CardContent } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel' import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner' import { EventBanner } from '@/components/EventBanner'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft } from 'lucide-react' import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle } from 'lucide-react'
export function PlayPage() { export function PlayPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -53,6 +53,9 @@ export function PlayPage() {
const [eventComment, setEventComment] = useState('') const [eventComment, setEventComment] = useState('')
const [isEventCompleting, setIsEventCompleting] = useState(false) const [isEventCompleting, setIsEventCompleting] = useState(false)
// Returned assignments state
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const eventFileInputRef = useRef<HTMLInputElement>(null) const eventFileInputRef = useRef<HTMLInputElement>(null)
@@ -138,18 +141,20 @@ export function PlayPage() {
const loadData = async () => { const loadData = async () => {
if (!id) return if (!id) return
try { try {
const [marathonData, assignment, gamesData, eventData, eventAssignmentData] = await Promise.all([ const [marathonData, assignment, gamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
marathonsApi.get(parseInt(id)), marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)), wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.list(parseInt(id), 'approved'), gamesApi.list(parseInt(id), 'approved'),
eventsApi.getActive(parseInt(id)), eventsApi.getActive(parseInt(id)),
eventsApi.getEventAssignment(parseInt(id)), eventsApi.getEventAssignment(parseInt(id)),
assignmentsApi.getReturnedAssignments(parseInt(id)),
]) ])
setMarathon(marathonData) setMarathon(marathonData)
setCurrentAssignment(assignment) setCurrentAssignment(assignment)
setGames(gamesData) setGames(gamesData)
setActiveEvent(eventData) setActiveEvent(eventData)
setEventAssignment(eventAssignmentData) setEventAssignment(eventAssignmentData)
setReturnedAssignments(returnedData)
} catch (error) { } catch (error) {
console.error('Failed to load data:', error) console.error('Failed to load data:', error)
} finally { } finally {
@@ -427,6 +432,45 @@ export function PlayPage() {
</div> </div>
)} )}
{/* Returned assignments warning */}
{returnedAssignments.length > 0 && (
<Card className="mb-6 border-orange-500/50">
<CardContent>
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-bold text-orange-400">Возвращённые задания</h3>
<span className="ml-auto px-2 py-0.5 bg-orange-500/20 text-orange-400 text-sm rounded">
{returnedAssignments.length}
</span>
</div>
<p className="text-gray-400 text-sm mb-4">
Эти задания были оспорены. После текущего задания вам нужно будет их переделать.
</p>
<div className="space-y-2">
{returnedAssignments.map((ra) => (
<div
key={ra.id}
className="p-3 bg-orange-500/10 border border-orange-500/20 rounded-lg"
>
<div className="flex items-start justify-between">
<div>
<p className="text-white font-medium">{ra.challenge.title}</p>
<p className="text-gray-400 text-sm">{ra.challenge.game.title}</p>
</div>
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded">
+{ra.challenge.points}
</span>
</div>
<p className="text-orange-300 text-xs mt-2">
Причина: {ra.dispute_reason}
</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Tabs for Common Enemy event */} {/* Tabs for Common Enemy event */}
{activeEvent?.event?.type === 'common_enemy' && ( {activeEvent?.event?.type === 'common_enemy' && (
<div className="flex gap-2 mb-6"> <div className="flex gap-2 mb-6">

View File

@@ -158,7 +158,7 @@ export interface ChallengesPreviewResponse {
} }
// Assignment types // Assignment types
export type AssignmentStatus = 'active' | 'completed' | 'dropped' export type AssignmentStatus = 'active' | 'completed' | 'dropped' | 'returned'
export interface Assignment { export interface Assignment {
id: number id: number
@@ -404,3 +404,57 @@ export interface PlatformStats {
games_count: number games_count: number
total_participations: number total_participations: number
} }
// Dispute types
export type DisputeStatus = 'open' | 'valid' | 'invalid'
export interface DisputeComment {
id: number
user: User
text: string
created_at: string
}
export interface DisputeVote {
user: User
vote: boolean // true = valid, false = invalid
created_at: string
}
export interface Dispute {
id: number
raised_by: User
reason: string
status: DisputeStatus
comments: DisputeComment[]
votes: DisputeVote[]
votes_valid: number
votes_invalid: number
my_vote: boolean | null
expires_at: string
created_at: string
resolved_at: string | null
}
export interface AssignmentDetail {
id: number
challenge: Challenge
participant: User
status: AssignmentStatus
proof_url: string | null
proof_image_url: string | null
proof_comment: string | null
points_earned: number
streak_at_completion: number | null
started_at: string
completed_at: string | null
can_dispute: boolean
dispute: Dispute | null
}
export interface ReturnedAssignment {
id: number
challenge: Challenge
original_completed_at: string
dispute_reason: string
}