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 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)
|
||||||
|
|||||||
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
|
# 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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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,
|
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",
|
||||||
]
|
]
|
||||||
|
|||||||
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 { 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>
|
||||||
)
|
)
|
||||||
|
|||||||
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 { adminApi } from './admin'
|
||||||
export { eventsApi } from './events'
|
export { eventsApi } from './events'
|
||||||
export { challengesApi } from './challenges'
|
export { challengesApi } from './challenges'
|
||||||
|
export { assignmentsApi } from './assignments'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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 { 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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user