Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования - Можно повторно оспаривать после разрешённых споров - Исправлены бонусные очки при перепрохождении после оспаривания - Сброс серии при невалидном пруфе - Колесо показывает только доступные игры - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
@@ -5,7 +5,10 @@ from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
||||
from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent
|
||||
from app.models import (
|
||||
User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent,
|
||||
Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus
|
||||
)
|
||||
from app.schemas import (
|
||||
UserPublic, MessageResponse,
|
||||
AdminUserResponse, BanUserRequest, AdminResetPasswordRequest, AdminLogResponse, AdminLogsListResponse,
|
||||
@@ -837,3 +840,273 @@ async def get_dashboard(current_user: CurrentUser, db: DbSession):
|
||||
for log in recent_logs
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ============ Disputes Management ============
|
||||
|
||||
class AdminDisputeResponse(BaseModel):
|
||||
id: int
|
||||
assignment_id: int | None
|
||||
bonus_assignment_id: int | None
|
||||
marathon_id: int
|
||||
marathon_title: str
|
||||
challenge_title: str
|
||||
participant_nickname: str
|
||||
raised_by_nickname: str
|
||||
reason: str
|
||||
status: str
|
||||
votes_valid: int
|
||||
votes_invalid: int
|
||||
created_at: str
|
||||
expires_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ResolveDisputeRequest(BaseModel):
|
||||
is_valid: bool = Field(..., description="True = proof is valid, False = proof is invalid")
|
||||
|
||||
|
||||
@router.get("/disputes", response_model=list[AdminDisputeResponse])
|
||||
async def list_disputes(
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
status: str = Query("pending", pattern="^(open|pending|all)$"),
|
||||
):
|
||||
"""List all disputes. Admin only.
|
||||
|
||||
Status filter:
|
||||
- pending: disputes waiting for admin decision (default)
|
||||
- open: disputes still in voting phase
|
||||
- all: all disputes
|
||||
"""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
from datetime import timedelta
|
||||
DISPUTE_WINDOW_HOURS = 24
|
||||
|
||||
query = (
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.raised_by),
|
||||
selectinload(Dispute.votes),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.game), # For playthrough
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.order_by(Dispute.created_at.desc())
|
||||
)
|
||||
|
||||
if status == "pending":
|
||||
# Disputes waiting for admin decision
|
||||
query = query.where(Dispute.status == DisputeStatus.PENDING_ADMIN.value)
|
||||
elif status == "open":
|
||||
# Disputes still in voting phase
|
||||
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
|
||||
|
||||
result = await db.execute(query)
|
||||
disputes = result.scalars().all()
|
||||
|
||||
response = []
|
||||
for dispute in disputes:
|
||||
# Get info based on dispute type
|
||||
if dispute.bonus_assignment_id:
|
||||
bonus = dispute.bonus_assignment
|
||||
main_assignment = bonus.main_assignment
|
||||
participant = main_assignment.participant
|
||||
challenge_title = f"Бонус: {bonus.challenge.title}"
|
||||
marathon_id = main_assignment.game.marathon_id
|
||||
|
||||
# Get marathon title
|
||||
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = marathon_result.scalar_one_or_none()
|
||||
marathon_title = marathon.title if marathon else "Unknown"
|
||||
else:
|
||||
assignment = dispute.assignment
|
||||
participant = assignment.participant
|
||||
if assignment.is_playthrough:
|
||||
challenge_title = f"Прохождение: {assignment.game.title}"
|
||||
marathon_id = assignment.game.marathon_id
|
||||
else:
|
||||
challenge_title = assignment.challenge.title
|
||||
marathon_id = assignment.challenge.game.marathon_id
|
||||
|
||||
# Get marathon title
|
||||
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = marathon_result.scalar_one_or_none()
|
||||
marathon_title = marathon.title if marathon else "Unknown"
|
||||
|
||||
# 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)
|
||||
|
||||
# Calculate expiry
|
||||
expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||
|
||||
response.append(AdminDisputeResponse(
|
||||
id=dispute.id,
|
||||
assignment_id=dispute.assignment_id,
|
||||
bonus_assignment_id=dispute.bonus_assignment_id,
|
||||
marathon_id=marathon_id,
|
||||
marathon_title=marathon_title,
|
||||
challenge_title=challenge_title,
|
||||
participant_nickname=participant.user.nickname,
|
||||
raised_by_nickname=dispute.raised_by.nickname,
|
||||
reason=dispute.reason,
|
||||
status=dispute.status,
|
||||
votes_valid=votes_valid,
|
||||
votes_invalid=votes_invalid,
|
||||
created_at=dispute.created_at.isoformat(),
|
||||
expires_at=expires_at.isoformat(),
|
||||
))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/disputes/{dispute_id}/resolve", response_model=MessageResponse)
|
||||
async def resolve_dispute(
|
||||
request: Request,
|
||||
dispute_id: int,
|
||||
data: ResolveDisputeRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Manually resolve a dispute. Admin only."""
|
||||
require_admin_with_2fa(current_user)
|
||||
|
||||
# Get dispute
|
||||
result = await db.execute(
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||
)
|
||||
.where(Dispute.id == dispute_id)
|
||||
)
|
||||
dispute = result.scalar_one_or_none()
|
||||
|
||||
if not dispute:
|
||||
raise HTTPException(status_code=404, detail="Dispute not found")
|
||||
|
||||
# Allow resolving disputes that are either open or pending admin decision
|
||||
if dispute.status not in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]:
|
||||
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||
|
||||
# Determine result
|
||||
if data.is_valid:
|
||||
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||
action_type = AdminActionType.DISPUTE_RESOLVE_VALID.value
|
||||
else:
|
||||
result_status = DisputeStatus.RESOLVED_INVALID.value
|
||||
action_type = AdminActionType.DISPUTE_RESOLVE_INVALID.value
|
||||
|
||||
# Handle invalid proof
|
||||
if dispute.bonus_assignment_id:
|
||||
# Reset bonus assignment
|
||||
bonus = dispute.bonus_assignment
|
||||
main_assignment = bonus.main_assignment
|
||||
participant = main_assignment.participant
|
||||
|
||||
# Only subtract points if main playthrough was already completed
|
||||
# (bonus points are added only when main playthrough is completed)
|
||||
if main_assignment.status == AssignmentStatus.COMPLETED.value:
|
||||
points_to_subtract = bonus.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
# Also reduce the points_earned on the main assignment
|
||||
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
|
||||
|
||||
bonus.status = BonusAssignmentStatus.PENDING.value
|
||||
bonus.proof_path = None
|
||||
bonus.proof_url = None
|
||||
bonus.proof_comment = None
|
||||
bonus.points_earned = 0
|
||||
bonus.completed_at = None
|
||||
else:
|
||||
# Reset main assignment
|
||||
assignment = dispute.assignment
|
||||
participant = assignment.participant
|
||||
|
||||
# Subtract points
|
||||
points_to_subtract = assignment.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
|
||||
# Reset streak - the completion was invalid
|
||||
participant.current_streak = 0
|
||||
|
||||
# Reset assignment
|
||||
assignment.status = AssignmentStatus.RETURNED.value
|
||||
assignment.points_earned = 0
|
||||
|
||||
# For playthrough: reset all bonus assignments
|
||||
if assignment.is_playthrough:
|
||||
bonus_result = await db.execute(
|
||||
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
|
||||
)
|
||||
for ba in bonus_result.scalars().all():
|
||||
ba.status = BonusAssignmentStatus.PENDING.value
|
||||
ba.proof_path = None
|
||||
ba.proof_url = None
|
||||
ba.proof_comment = None
|
||||
ba.points_earned = 0
|
||||
ba.completed_at = None
|
||||
|
||||
# Update dispute
|
||||
dispute.status = result_status
|
||||
dispute.resolved_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Get details for logging
|
||||
if dispute.bonus_assignment_id:
|
||||
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
|
||||
marathon_id = dispute.bonus_assignment.main_assignment.game.marathon_id
|
||||
elif dispute.assignment.is_playthrough:
|
||||
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
|
||||
marathon_id = dispute.assignment.game.marathon_id
|
||||
else:
|
||||
challenge_title = dispute.assignment.challenge.title
|
||||
marathon_id = dispute.assignment.challenge.game.marathon_id
|
||||
|
||||
# Log action
|
||||
await log_admin_action(
|
||||
db, current_user.id, action_type,
|
||||
"dispute", dispute_id,
|
||||
{
|
||||
"challenge_title": challenge_title,
|
||||
"marathon_id": marathon_id,
|
||||
"is_valid": data.is_valid,
|
||||
},
|
||||
request.client.host if request.client else None
|
||||
)
|
||||
|
||||
# Send notification
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
if dispute.bonus_assignment_id:
|
||||
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
|
||||
else:
|
||||
participant_user_id = dispute.assignment.participant.user_id
|
||||
|
||||
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||
marathon = marathon_result.scalar_one_or_none()
|
||||
|
||||
if marathon:
|
||||
await telegram_notifier.notify_dispute_resolved(
|
||||
db,
|
||||
user_id=participant_user_id,
|
||||
marathon_title=marathon.title,
|
||||
challenge_title=challenge_title,
|
||||
is_valid=data.is_valid
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user