Add dispute system
This commit is contained in:
81
backend/alembic/versions/009_add_disputes.py
Normal file
81
backend/alembic/versions/009_add_disputes.py
Normal 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')
|
||||
@@ -1,6 +1,6 @@
|
||||
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")
|
||||
|
||||
@@ -13,3 +13,4 @@ router.include_router(wheel.router)
|
||||
router.include_router(feed.router)
|
||||
router.include_router(admin.router)
|
||||
router.include_router(events.router)
|
||||
router.include_router(assignments.router)
|
||||
|
||||
433
backend/app/api/v1/assignments.py
Normal file
433
backend/app/api/v1/assignments.py
Normal 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
|
||||
]
|
||||
@@ -1111,6 +1111,7 @@ async def complete_event_assignment(
|
||||
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"assignment_id": assignment.id,
|
||||
"game": challenge.game.title,
|
||||
"challenge": challenge.title,
|
||||
"difficulty": challenge.difficulty,
|
||||
|
||||
@@ -60,6 +60,39 @@ async def get_active_assignment(db, participant_id: int, is_event: bool = False)
|
||||
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)
|
||||
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||
"""Spin the wheel to get a random game and challenge"""
|
||||
@@ -347,6 +380,7 @@ async def complete_assignment(
|
||||
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"assignment_id": assignment.id,
|
||||
"game": full_challenge.game.title,
|
||||
"challenge": challenge.title,
|
||||
"difficulty": challenge.difficulty,
|
||||
@@ -407,6 +441,13 @@ async def complete_assignment(
|
||||
|
||||
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(
|
||||
points_earned=total_points,
|
||||
streak_bonus=streak_bonus,
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.core.config import settings
|
||||
from app.core.database import engine, Base, async_session_maker
|
||||
from app.api.v1 import router as api_router
|
||||
from app.services.event_scheduler import event_scheduler
|
||||
from app.services.dispute_scheduler import dispute_scheduler
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -23,13 +24,15 @@ async def lifespan(app: FastAPI):
|
||||
(upload_dir / "covers").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 dispute_scheduler.start(async_session_maker)
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
await event_scheduler.stop()
|
||||
await dispute_scheduler.stop()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.models.assignment import Assignment, AssignmentStatus
|
||||
from app.models.activity import Activity, ActivityType
|
||||
from app.models.event import Event, EventType
|
||||
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
||||
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -30,4 +31,8 @@ __all__ = [
|
||||
"EventType",
|
||||
"SwapRequest",
|
||||
"SwapRequestStatus",
|
||||
"Dispute",
|
||||
"DisputeStatus",
|
||||
"DisputeComment",
|
||||
"DisputeVote",
|
||||
]
|
||||
|
||||
@@ -10,6 +10,7 @@ class AssignmentStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
DROPPED = "dropped"
|
||||
RETURNED = "returned" # Disputed and needs to be redone
|
||||
|
||||
|
||||
class Assignment(Base):
|
||||
@@ -34,3 +35,4 @@ class Assignment(Base):
|
||||
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
|
||||
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")
|
||||
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")
|
||||
dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False)
|
||||
|
||||
66
backend/app/models/dispute.py
Normal file
66
backend/app/models/dispute.py
Normal 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")
|
||||
@@ -67,6 +67,16 @@ from app.schemas.common import (
|
||||
ErrorResponse,
|
||||
PaginationParams,
|
||||
)
|
||||
from app.schemas.dispute import (
|
||||
DisputeCreate,
|
||||
DisputeCommentCreate,
|
||||
DisputeVoteCreate,
|
||||
DisputeCommentResponse,
|
||||
DisputeVoteResponse,
|
||||
DisputeResponse,
|
||||
AssignmentDetailResponse,
|
||||
ReturnedAssignmentResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# User
|
||||
@@ -130,4 +140,13 @@ __all__ = [
|
||||
"MessageResponse",
|
||||
"ErrorResponse",
|
||||
"PaginationParams",
|
||||
# Dispute
|
||||
"DisputeCreate",
|
||||
"DisputeCommentCreate",
|
||||
"DisputeVoteCreate",
|
||||
"DisputeCommentResponse",
|
||||
"DisputeVoteResponse",
|
||||
"DisputeResponse",
|
||||
"AssignmentDetailResponse",
|
||||
"ReturnedAssignmentResponse",
|
||||
]
|
||||
|
||||
91
backend/app/schemas/dispute.py
Normal file
91
backend/app/schemas/dispute.py
Normal 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
|
||||
89
backend/app/services/dispute_scheduler.py
Normal file
89
backend/app/services/dispute_scheduler.py
Normal 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()
|
||||
103
backend/app/services/disputes.py
Normal file
103
backend/app/services/disputes.py
Normal 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()
|
||||
BIN
backend/uploads/proofs/74_a2655aa0a2134b1ba4d859e34a836916.jpg
Normal file
BIN
backend/uploads/proofs/74_a2655aa0a2134b1ba4d859e34a836916.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
BIN
backend/uploads/proofs/78_f1afea81917e4ce69a0ddd84260385a4.png
Normal file
BIN
backend/uploads/proofs/78_f1afea81917e4ce69a0ddd84260385a4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
@@ -15,6 +15,7 @@ import { LobbyPage } from '@/pages/LobbyPage'
|
||||
import { PlayPage } from '@/pages/PlayPage'
|
||||
import { LeaderboardPage } from '@/pages/LeaderboardPage'
|
||||
import { InvitePage } from '@/pages/InvitePage'
|
||||
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
|
||||
|
||||
// Protected route wrapper
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
@@ -118,6 +119,15 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="assignments/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AssignmentDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
|
||||
34
frontend/src/api/assignments.ts
Normal file
34
frontend/src/api/assignments.ts
Normal 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
|
||||
},
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export { feedApi } from './feed'
|
||||
export { adminApi } from './admin'
|
||||
export { eventsApi } from './events'
|
||||
export { challengesApi } from './challenges'
|
||||
export { assignmentsApi } from './assignments'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { feedApi } from '@/api'
|
||||
import type { Activity, ActivityType } from '@/types'
|
||||
import { Loader2, ChevronDown, Bell } from 'lucide-react'
|
||||
import { Loader2, ChevronDown, Bell, ExternalLink } from 'lucide-react'
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getActivityIcon,
|
||||
@@ -169,12 +170,18 @@ interface ActivityItemProps {
|
||||
}
|
||||
|
||||
function ActivityItem({ activity }: ActivityItemProps) {
|
||||
const navigate = useNavigate()
|
||||
const Icon = getActivityIcon(activity.type)
|
||||
const iconColor = getActivityColor(activity.type)
|
||||
const bgClass = getActivityBgClass(activity.type)
|
||||
const isEvent = isEventActivity(activity.type)
|
||||
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) {
|
||||
return (
|
||||
<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}
|
||||
</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>
|
||||
|
||||
481
frontend/src/pages/AssignmentDetailPage.tsx
Normal file
481
frontend/src/pages/AssignmentDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { marathonsApi, wheelApi, gamesApi, eventsApi } from '@/api'
|
||||
import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges } from '@/types'
|
||||
import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi } from '@/api'
|
||||
import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment } from '@/types'
|
||||
import { Button, Card, CardContent } from '@/components/ui'
|
||||
import { SpinWheel } from '@/components/SpinWheel'
|
||||
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() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
@@ -53,6 +53,9 @@ export function PlayPage() {
|
||||
const [eventComment, setEventComment] = useState('')
|
||||
const [isEventCompleting, setIsEventCompleting] = useState(false)
|
||||
|
||||
// Returned assignments state
|
||||
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const eventFileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -138,18 +141,20 @@ export function PlayPage() {
|
||||
const loadData = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const [marathonData, assignment, gamesData, eventData, eventAssignmentData] = await Promise.all([
|
||||
const [marathonData, assignment, gamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
|
||||
marathonsApi.get(parseInt(id)),
|
||||
wheelApi.getCurrentAssignment(parseInt(id)),
|
||||
gamesApi.list(parseInt(id), 'approved'),
|
||||
eventsApi.getActive(parseInt(id)),
|
||||
eventsApi.getEventAssignment(parseInt(id)),
|
||||
assignmentsApi.getReturnedAssignments(parseInt(id)),
|
||||
])
|
||||
setMarathon(marathonData)
|
||||
setCurrentAssignment(assignment)
|
||||
setGames(gamesData)
|
||||
setActiveEvent(eventData)
|
||||
setEventAssignment(eventAssignmentData)
|
||||
setReturnedAssignments(returnedData)
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error)
|
||||
} finally {
|
||||
@@ -427,6 +432,45 @@ export function PlayPage() {
|
||||
</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 */}
|
||||
{activeEvent?.event?.type === 'common_enemy' && (
|
||||
<div className="flex gap-2 mb-6">
|
||||
|
||||
@@ -158,7 +158,7 @@ export interface ChallengesPreviewResponse {
|
||||
}
|
||||
|
||||
// Assignment types
|
||||
export type AssignmentStatus = 'active' | 'completed' | 'dropped'
|
||||
export type AssignmentStatus = 'active' | 'completed' | 'dropped' | 'returned'
|
||||
|
||||
export interface Assignment {
|
||||
id: number
|
||||
@@ -404,3 +404,57 @@ export interface PlatformStats {
|
||||
games_count: 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user