Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования - Можно повторно оспаривать после разрешённых споров - Исправлены бонусные очки при перепрохождении после оспаривания - Сброс серии при невалидном пруфе - Колесо показывает только доступные игры - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
@@ -20,6 +20,7 @@ optional_auth = HTTPBearer(auto_error=False)
|
||||
from app.models import (
|
||||
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus,
|
||||
)
|
||||
from app.schemas import (
|
||||
MarathonCreate,
|
||||
@@ -703,3 +704,260 @@ async def delete_marathon_cover(
|
||||
await db.commit()
|
||||
|
||||
return await get_marathon(marathon_id, current_user, db)
|
||||
|
||||
|
||||
# ============ Marathon Disputes (for organizers) ============
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class MarathonDisputeResponse(BaseModel):
|
||||
id: int
|
||||
assignment_id: int | None
|
||||
bonus_assignment_id: int | None
|
||||
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("/{marathon_id}/disputes", response_model=list[MarathonDisputeResponse])
|
||||
async def list_marathon_disputes(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
status_filter: str = "open",
|
||||
):
|
||||
"""List disputes in a marathon. Organizers only."""
|
||||
await require_organizer(db, current_user, marathon_id)
|
||||
marathon = await get_marathon_or_404(db, marathon_id)
|
||||
|
||||
from datetime import timedelta
|
||||
DISPUTE_WINDOW_HOURS = 24
|
||||
|
||||
# Get all assignments in this marathon (through games)
|
||||
games_result = await db.execute(
|
||||
select(Game.id).where(Game.marathon_id == marathon_id)
|
||||
)
|
||||
game_ids = [g[0] for g in games_result.all()]
|
||||
|
||||
if not game_ids:
|
||||
return []
|
||||
|
||||
# Get disputes for assignments in these games
|
||||
# Using selectinload for eager loading - no explicit joins needed
|
||||
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(Dispute.assignment).selectinload(Assignment.game),
|
||||
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_filter == "open":
|
||||
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
|
||||
|
||||
result = await db.execute(query)
|
||||
all_disputes = result.scalars().unique().all()
|
||||
|
||||
# Filter disputes that belong to this marathon's games
|
||||
response = []
|
||||
for dispute in all_disputes:
|
||||
# Check if dispute belongs to this marathon
|
||||
if dispute.bonus_assignment_id:
|
||||
bonus = dispute.bonus_assignment
|
||||
if not bonus or not bonus.main_assignment:
|
||||
continue
|
||||
if bonus.main_assignment.game_id not in game_ids:
|
||||
continue
|
||||
participant = bonus.main_assignment.participant
|
||||
challenge_title = f"Бонус: {bonus.challenge.title}"
|
||||
else:
|
||||
assignment = dispute.assignment
|
||||
if not assignment:
|
||||
continue
|
||||
if assignment.is_playthrough:
|
||||
if assignment.game_id not in game_ids:
|
||||
continue
|
||||
challenge_title = f"Прохождение: {assignment.game.title}"
|
||||
else:
|
||||
if not assignment.challenge or assignment.challenge.game_id not in game_ids:
|
||||
continue
|
||||
challenge_title = assignment.challenge.title
|
||||
participant = assignment.participant
|
||||
|
||||
# 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(MarathonDisputeResponse(
|
||||
id=dispute.id,
|
||||
assignment_id=dispute.assignment_id,
|
||||
bonus_assignment_id=dispute.bonus_assignment_id,
|
||||
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("/{marathon_id}/disputes/{dispute_id}/resolve", response_model=MessageResponse)
|
||||
async def resolve_marathon_dispute(
|
||||
marathon_id: int,
|
||||
dispute_id: int,
|
||||
data: ResolveDisputeRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Manually resolve a dispute in a marathon. Organizers only."""
|
||||
await require_organizer(db, current_user, marathon_id)
|
||||
marathon = await get_marathon_or_404(db, marathon_id)
|
||||
|
||||
# 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")
|
||||
|
||||
# Verify dispute belongs to this marathon
|
||||
if dispute.bonus_assignment_id:
|
||||
bonus = dispute.bonus_assignment
|
||||
if bonus.main_assignment.game.marathon_id != marathon_id:
|
||||
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||
else:
|
||||
assignment = dispute.assignment
|
||||
if assignment.is_playthrough:
|
||||
if assignment.game.marathon_id != marathon_id:
|
||||
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||
else:
|
||||
if assignment.challenge.game.marathon_id != marathon_id:
|
||||
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||
|
||||
if dispute.status != DisputeStatus.OPEN.value:
|
||||
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||
|
||||
# Determine result
|
||||
if data.is_valid:
|
||||
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||
else:
|
||||
result_status = DisputeStatus.RESOLVED_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()
|
||||
|
||||
# Send notification
|
||||
if dispute.bonus_assignment_id:
|
||||
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
|
||||
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
|
||||
elif dispute.assignment.is_playthrough:
|
||||
participant_user_id = dispute.assignment.participant.user_id
|
||||
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
|
||||
else:
|
||||
participant_user_id = dispute.assignment.participant.user_id
|
||||
challenge_title = dispute.assignment.challenge.title
|
||||
|
||||
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