diff --git a/backend/alembic/versions/009_add_disputes.py b/backend/alembic/versions/009_add_disputes.py new file mode 100644 index 0000000..770d48e --- /dev/null +++ b/backend/alembic/versions/009_add_disputes.py @@ -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') diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 10ccbe7..3caaa34 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -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) diff --git a/backend/app/api/v1/assignments.py b/backend/app/api/v1/assignments.py new file mode 100644 index 0000000..f35cd3b --- /dev/null +++ b/backend/app/api/v1/assignments.py @@ -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 + ] diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 1679d46..09ad2f4 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -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, diff --git a/backend/app/api/v1/wheel.py b/backend/app/api/v1/wheel.py index 1d61d80..7b209c4 100644 --- a/backend/app/api/v1/wheel.py +++ b/backend/app/api/v1/wheel.py @@ -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, diff --git a/backend/app/main.py b/backend/app/main.py index c858888..4903c09 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e88ea9a..8a44015 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/assignment.py b/backend/app/models/assignment.py index 21a9bb0..76fab8e 100644 --- a/backend/app/models/assignment.py +++ b/backend/app/models/assignment.py @@ -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) diff --git a/backend/app/models/dispute.py b/backend/app/models/dispute.py new file mode 100644 index 0000000..e833ad8 --- /dev/null +++ b/backend/app/models/dispute.py @@ -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") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 69415de..4896989 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -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", ] diff --git a/backend/app/schemas/dispute.py b/backend/app/schemas/dispute.py new file mode 100644 index 0000000..0acf1d2 --- /dev/null +++ b/backend/app/schemas/dispute.py @@ -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 diff --git a/backend/app/services/dispute_scheduler.py b/backend/app/services/dispute_scheduler.py new file mode 100644 index 0000000..83e9757 --- /dev/null +++ b/backend/app/services/dispute_scheduler.py @@ -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() diff --git a/backend/app/services/disputes.py b/backend/app/services/disputes.py new file mode 100644 index 0000000..65b90b4 --- /dev/null +++ b/backend/app/services/disputes.py @@ -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() diff --git a/backend/uploads/proofs/74_a2655aa0a2134b1ba4d859e34a836916.jpg b/backend/uploads/proofs/74_a2655aa0a2134b1ba4d859e34a836916.jpg new file mode 100644 index 0000000..e811e8e Binary files /dev/null and b/backend/uploads/proofs/74_a2655aa0a2134b1ba4d859e34a836916.jpg differ diff --git a/backend/uploads/proofs/78_f1afea81917e4ce69a0ddd84260385a4.png b/backend/uploads/proofs/78_f1afea81917e4ce69a0ddd84260385a4.png new file mode 100644 index 0000000..a2c204a Binary files /dev/null and b/backend/uploads/proofs/78_f1afea81917e4ce69a0ddd84260385a4.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2988e0b..442dac6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + + + + + } + /> ) diff --git a/frontend/src/api/assignments.ts b/frontend/src/api/assignments.ts new file mode 100644 index 0000000..2922fde --- /dev/null +++ b/frontend/src/api/assignments.ts @@ -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 => { + const response = await client.get(`/assignments/${assignmentId}`) + return response.data + }, + + // Create a dispute against an assignment + createDispute: async (assignmentId: number, reason: string): Promise => { + const response = await client.post(`/assignments/${assignmentId}/dispute`, { reason }) + return response.data + }, + + // Add a comment to a dispute + addComment: async (disputeId: number, text: string): Promise => { + const response = await client.post(`/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 => { + const response = await client.get(`/marathons/${marathonId}/returned-assignments`) + return response.data + }, +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 7ef67dc..b76cd5e 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -6,3 +6,4 @@ export { feedApi } from './feed' export { adminApi } from './admin' export { eventsApi } from './events' export { challengesApi } from './challenges' +export { assignmentsApi } from './assignments' diff --git a/frontend/src/components/ActivityFeed.tsx b/frontend/src/components/ActivityFeed.tsx index 330b3df..790a34e 100644 --- a/frontend/src/components/ActivityFeed.tsx +++ b/frontend/src/components/ActivityFeed.tsx @@ -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 (
@@ -240,6 +247,16 @@ function ActivityItem({ activity }: ActivityItemProps) { {extra}
)} + {/* Details button for complete activities */} + {assignmentId && ( + + )} diff --git a/frontend/src/pages/AssignmentDetailPage.tsx b/frontend/src/pages/AssignmentDetailPage.tsx new file mode 100644 index 0000000..a982d8e --- /dev/null +++ b/frontend/src/pages/AssignmentDetailPage.tsx @@ -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(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( + + Выполнено + + ) + case 'dropped': + return ( + + Пропущено + + ) + case 'returned': + return ( + + Возвращено + + ) + default: + return ( + + Активно + + ) + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error || !assignment) { + return ( +
+

{error || 'Задание не найдено'}

+ +
+ ) + } + + const dispute = assignment.dispute + + return ( +
+ {/* Header */} +
+ +

Детали выполнения

+
+ + {/* Challenge info */} + + +
+
+

{assignment.challenge.game.title}

+

{assignment.challenge.title}

+
+ {getStatusBadge(assignment.status)} +
+ +

{assignment.challenge.description}

+ +
+ + +{assignment.challenge.points} очков + + + {assignment.challenge.difficulty} + + {assignment.challenge.estimated_time && ( + + ~{assignment.challenge.estimated_time} мин + + )} +
+ +
+

+ Выполнил: {assignment.participant.nickname} +

+ {assignment.completed_at && ( +

+ Дата: {formatDate(assignment.completed_at)} +

+ )} + {assignment.points_earned > 0 && ( +

+ Получено очков: {assignment.points_earned} +

+ )} +
+
+
+ + {/* Proof section */} + + +

+ + Доказательство +

+ + {/* Proof image */} + {assignment.proof_image_url && ( +
+ Proof +
+ )} + + {/* Proof URL */} + {assignment.proof_url && ( + + )} + + {/* Proof comment */} + {assignment.proof_comment && ( +
+

Комментарий:

+

{assignment.proof_comment}

+
+ )} + + {!assignment.proof_image_url && !assignment.proof_url && ( +

Пруф не предоставлен

+ )} +
+
+ + {/* Dispute button */} + {assignment.can_dispute && !dispute && !showDisputeForm && ( + + )} + + {/* Dispute creation form */} + {showDisputeForm && !dispute && ( + + +

+ + Оспорить выполнение +

+ +

+ Опишите причину оспаривания. После создания у участников будет 24 часа для голосования. +

+ +