Улучшение системы оспариваний и исправления

- Оспаривания теперь требуют решения админа после 24ч голосования
  - Можно повторно оспаривать после разрешённых споров
  - Исправлены бонусные очки при перепрохождении после оспаривания
  - Сброс серии при невалидном пруфе
  - Колесо показывает только доступные игры
  - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
2025-12-29 22:23:34 +03:00
parent 1cedfeb3ee
commit 89dbe2c018
42 changed files with 5426 additions and 313 deletions

View File

@@ -1,9 +1,10 @@
from app.models.user import User, UserRole
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode
from app.models.participant import Participant, ParticipantRole
from app.models.game import Game, GameStatus
from app.models.game import Game, GameStatus, GameType
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
from app.models.assignment import Assignment, AssignmentStatus
from app.models.bonus_assignment import BonusAssignment, BonusAssignmentStatus
from app.models.activity import Activity, ActivityType
from app.models.event import Event, EventType
from app.models.swap_request import SwapRequest, SwapRequestStatus
@@ -22,12 +23,15 @@ __all__ = [
"ParticipantRole",
"Game",
"GameStatus",
"GameType",
"Challenge",
"ChallengeType",
"Difficulty",
"ProofType",
"Assignment",
"AssignmentStatus",
"BonusAssignment",
"BonusAssignmentStatus",
"Activity",
"ActivityType",
"Event",

View File

@@ -30,6 +30,10 @@ class AdminActionType(str, Enum):
ADMIN_2FA_SUCCESS = "admin_2fa_success"
ADMIN_2FA_FAIL = "admin_2fa_fail"
# Dispute actions
DISPUTE_RESOLVE_VALID = "dispute_resolve_valid"
DISPUTE_RESOLVE_INVALID = "dispute_resolve_invalid"
class AdminLog(Base):
__tablename__ = "admin_logs"

View File

@@ -18,8 +18,12 @@ class Assignment(Base):
id: Mapped[int] = mapped_column(primary_key=True)
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True)
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
challenge_id: Mapped[int | None] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"), nullable=True) # None для playthrough
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
# Для прохождений (playthrough)
game_id: Mapped[int | None] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), nullable=True, index=True)
is_playthrough: Mapped[bool] = mapped_column(Boolean, default=False)
event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created
is_event_assignment: Mapped[bool] = mapped_column(Boolean, default=False, index=True) # True for Common Enemy assignments
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) # Link to event
@@ -33,6 +37,8 @@ class Assignment(Base):
# Relationships
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")
challenge: Mapped["Challenge | None"] = relationship("Challenge", back_populates="assignments")
game: Mapped["Game | None"] = relationship("Game", back_populates="playthrough_assignments", foreign_keys=[game_id])
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")
dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False, cascade="all, delete-orphan", passive_deletes=True)
bonus_assignments: Mapped[list["BonusAssignment"]] = relationship("BonusAssignment", back_populates="main_assignment", cascade="all, delete-orphan")

View File

@@ -0,0 +1,48 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class BonusAssignmentStatus(str, Enum):
PENDING = "pending"
COMPLETED = "completed"
class BonusAssignment(Base):
"""Бонусные челленджи для игр типа 'playthrough'"""
__tablename__ = "bonus_assignments"
id: Mapped[int] = mapped_column(primary_key=True)
main_assignment_id: Mapped[int] = mapped_column(
ForeignKey("assignments.id", ondelete="CASCADE"),
index=True
)
challenge_id: Mapped[int] = mapped_column(
ForeignKey("challenges.id", ondelete="CASCADE"),
index=True
)
status: Mapped[str] = mapped_column(
String(20),
default=BonusAssignmentStatus.PENDING.value
)
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
points_earned: Mapped[int] = mapped_column(Integer, default=0)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
main_assignment: Mapped["Assignment"] = relationship(
"Assignment",
back_populates="bonus_assignments"
)
challenge: Mapped["Challenge"] = relationship("Challenge")
dispute: Mapped["Dispute"] = relationship(
"Dispute",
back_populates="bonus_assignment",
uselist=False,
)

View File

@@ -8,16 +8,19 @@ from app.core.database import Base
class DisputeStatus(str, Enum):
OPEN = "open"
PENDING_ADMIN = "pending_admin" # Voting ended, waiting for admin decision
RESOLVED_VALID = "valid"
RESOLVED_INVALID = "invalid"
class Dispute(Base):
"""Dispute against a completed assignment's proof"""
"""Dispute against a completed assignment's or bonus assignment's proof"""
__tablename__ = "disputes"
id: Mapped[int] = mapped_column(primary_key=True)
assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), unique=True, index=True)
# Either assignment_id OR bonus_assignment_id should be set (not both)
assignment_id: Mapped[int | None] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=True, index=True)
bonus_assignment_id: Mapped[int | None] = mapped_column(ForeignKey("bonus_assignments.id", ondelete="CASCADE"), nullable=True, index=True)
raised_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
reason: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value)
@@ -26,6 +29,7 @@ class Dispute(Base):
# Relationships
assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="dispute")
bonus_assignment: Mapped["BonusAssignment"] = relationship("BonusAssignment", back_populates="dispute")
raised_by: Mapped["User"] = relationship("User", foreign_keys=[raised_by_id])
comments: Mapped[list["DisputeComment"]] = relationship("DisputeComment", back_populates="dispute", cascade="all, delete-orphan")
votes: Mapped[list["DisputeVote"]] = relationship("DisputeVote", back_populates="dispute", cascade="all, delete-orphan")

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, DateTime, ForeignKey, Text
from sqlalchemy import String, DateTime, ForeignKey, Text, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -12,6 +12,11 @@ class GameStatus(str, Enum):
REJECTED = "rejected" # Отклонена
class GameType(str, Enum):
PLAYTHROUGH = "playthrough" # Прохождение игры
CHALLENGES = "challenges" # Челленджи
class Game(Base):
__tablename__ = "games"
@@ -26,6 +31,15 @@ class Game(Base):
approved_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Тип игры
game_type: Mapped[str] = mapped_column(String(20), default=GameType.CHALLENGES.value, nullable=False)
# Поля для типа "Прохождение" (заполняются только для playthrough)
playthrough_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
playthrough_description: Mapped[str | None] = mapped_column(Text, nullable=True)
playthrough_proof_type: Mapped[str | None] = mapped_column(String(20), nullable=True) # screenshot, video, steam
playthrough_proof_hint: Mapped[str | None] = mapped_column(Text, nullable=True)
# Relationships
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games")
proposed_by: Mapped["User"] = relationship(
@@ -43,6 +57,12 @@ class Game(Base):
back_populates="game",
cascade="all, delete-orphan"
)
# Assignments для прохождений (playthrough)
playthrough_assignments: Mapped[list["Assignment"]] = relationship(
"Assignment",
back_populates="game",
foreign_keys="Assignment.game_id"
)
@property
def is_approved(self) -> bool:
@@ -51,3 +71,11 @@ class Game(Base):
@property
def is_pending(self) -> bool:
return self.status == GameStatus.PENDING.value
@property
def is_playthrough(self) -> bool:
return self.game_type == GameType.PLAYTHROUGH.value
@property
def is_challenges(self) -> bool:
return self.game_type == GameType.CHALLENGES.value