Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования - Можно повторно оспаривать после разрешённых споров - Исправлены бонусные очки при перепрохождении после оспаривания - Сброс серии при невалидном пруфе - Колесо показывает только доступные игры - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Dispute Scheduler for automatic dispute resolution after 24 hours.
|
||||
Dispute Scheduler - marks disputes as pending admin review after 24 hours.
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
@@ -8,16 +8,16 @@ 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
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
|
||||
# Configuration
|
||||
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
|
||||
DISPUTE_WINDOW_HOURS = 24 # Disputes auto-resolve after 24 hours
|
||||
DISPUTE_WINDOW_HOURS = 24 # Disputes need admin decision after 24 hours
|
||||
|
||||
|
||||
class DisputeScheduler:
|
||||
"""Background scheduler for automatic dispute resolution."""
|
||||
"""Background scheduler that marks expired disputes for admin review."""
|
||||
|
||||
def __init__(self):
|
||||
self._running = False
|
||||
@@ -55,7 +55,7 @@ class DisputeScheduler:
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
|
||||
async def _process_expired_disputes(self, db: AsyncSession) -> None:
|
||||
"""Process and resolve expired disputes."""
|
||||
"""Mark expired disputes as pending admin review."""
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||
|
||||
# Find all open disputes that have expired
|
||||
@@ -63,7 +63,6 @@ class DisputeScheduler:
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.votes),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||
)
|
||||
.where(
|
||||
Dispute.status == DisputeStatus.OPEN.value,
|
||||
@@ -74,15 +73,25 @@ class DisputeScheduler:
|
||||
|
||||
for dispute in expired_disputes:
|
||||
try:
|
||||
result_status, votes_valid, votes_invalid = await dispute_service.resolve_dispute(
|
||||
db, dispute.id
|
||||
)
|
||||
# Count votes for logging
|
||||
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)
|
||||
|
||||
# Mark as pending admin decision
|
||||
dispute.status = DisputeStatus.PENDING_ADMIN.value
|
||||
|
||||
print(
|
||||
f"[DisputeScheduler] Auto-resolved dispute {dispute.id}: "
|
||||
f"{result_status} (valid: {votes_valid}, invalid: {votes_invalid})"
|
||||
f"[DisputeScheduler] Dispute {dispute.id} marked as pending admin "
|
||||
f"(recommendation: {'invalid' if votes_invalid > votes_valid else 'valid'}, "
|
||||
f"votes: {votes_valid} valid, {votes_invalid} invalid)"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[DisputeScheduler] Failed to resolve dispute {dispute.id}: {e}")
|
||||
print(f"[DisputeScheduler] Failed to process dispute {dispute.id}: {e}")
|
||||
|
||||
if expired_disputes:
|
||||
await db.commit()
|
||||
# Notify admins about pending disputes
|
||||
await telegram_notifier.notify_admin_disputes_pending(db, len(expired_disputes))
|
||||
|
||||
|
||||
# Global scheduler instance
|
||||
|
||||
@@ -23,12 +23,15 @@ class DisputeService:
|
||||
Returns:
|
||||
Tuple of (result_status, votes_valid, votes_invalid)
|
||||
"""
|
||||
# Get dispute with votes and assignment
|
||||
from app.models import BonusAssignment, BonusAssignmentStatus
|
||||
|
||||
# Get dispute with votes, assignment and bonus_assignment
|
||||
result = await db.execute(
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.votes),
|
||||
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
|
||||
)
|
||||
.where(Dispute.id == dispute_id)
|
||||
)
|
||||
@@ -46,9 +49,12 @@ class DisputeService:
|
||||
|
||||
# Determine result: tie goes to the accused (valid)
|
||||
if votes_invalid > votes_valid:
|
||||
# Proof is invalid - mark assignment as RETURNED
|
||||
# Proof is invalid
|
||||
result_status = DisputeStatus.RESOLVED_INVALID.value
|
||||
await self._handle_invalid_proof(db, dispute)
|
||||
if dispute.bonus_assignment_id:
|
||||
await self._handle_invalid_bonus_proof(db, dispute)
|
||||
else:
|
||||
await self._handle_invalid_proof(db, dispute)
|
||||
else:
|
||||
# Proof is valid (or tie)
|
||||
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||
@@ -60,7 +66,11 @@ class DisputeService:
|
||||
await db.commit()
|
||||
|
||||
# Send Telegram notification about dispute resolution
|
||||
await self._notify_dispute_resolved(db, dispute, result_status == DisputeStatus.RESOLVED_INVALID.value)
|
||||
is_invalid = result_status == DisputeStatus.RESOLVED_INVALID.value
|
||||
if dispute.bonus_assignment_id:
|
||||
await self._notify_bonus_dispute_resolved(db, dispute, is_invalid)
|
||||
else:
|
||||
await self._notify_dispute_resolved(db, dispute, is_invalid)
|
||||
|
||||
return result_status, votes_valid, votes_invalid
|
||||
|
||||
@@ -72,12 +82,13 @@ class DisputeService:
|
||||
) -> None:
|
||||
"""Send notification about dispute resolution to the assignment owner."""
|
||||
try:
|
||||
# Get assignment with challenge and marathon info
|
||||
# Get assignment with challenge/game and marathon info
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.participant),
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||
selectinload(Assignment.game), # For playthrough
|
||||
)
|
||||
.where(Assignment.id == dispute.assignment_id)
|
||||
)
|
||||
@@ -86,12 +97,19 @@ class DisputeService:
|
||||
return
|
||||
|
||||
participant = assignment.participant
|
||||
challenge = assignment.challenge
|
||||
game = challenge.game if challenge else None
|
||||
|
||||
# Get title and marathon_id based on assignment type
|
||||
if assignment.is_playthrough:
|
||||
title = f"Прохождение: {assignment.game.title}"
|
||||
marathon_id = assignment.game.marathon_id
|
||||
else:
|
||||
challenge = assignment.challenge
|
||||
title = challenge.title if challenge else "Unknown"
|
||||
marathon_id = challenge.game.marathon_id if challenge and challenge.game else 0
|
||||
|
||||
# Get marathon
|
||||
result = await db.execute(
|
||||
select(Marathon).where(Marathon.id == game.marathon_id if game else 0)
|
||||
select(Marathon).where(Marathon.id == marathon_id)
|
||||
)
|
||||
marathon = result.scalar_one_or_none()
|
||||
|
||||
@@ -100,12 +118,86 @@ class DisputeService:
|
||||
db,
|
||||
user_id=participant.user_id,
|
||||
marathon_title=marathon.title,
|
||||
challenge_title=challenge.title if challenge else "Unknown",
|
||||
challenge_title=title,
|
||||
is_valid=is_valid
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[DisputeService] Failed to send notification: {e}")
|
||||
|
||||
async def _notify_bonus_dispute_resolved(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
dispute: Dispute,
|
||||
is_invalid: bool
|
||||
) -> None:
|
||||
"""Send notification about bonus dispute resolution to the assignment owner."""
|
||||
try:
|
||||
bonus_assignment = dispute.bonus_assignment
|
||||
main_assignment = bonus_assignment.main_assignment
|
||||
participant = main_assignment.participant
|
||||
|
||||
# Get marathon info
|
||||
result = await db.execute(
|
||||
select(Game).where(Game.id == main_assignment.game_id)
|
||||
)
|
||||
game = result.scalar_one_or_none()
|
||||
if not game:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(Marathon).where(Marathon.id == game.marathon_id)
|
||||
)
|
||||
marathon = result.scalar_one_or_none()
|
||||
|
||||
# Get challenge title
|
||||
result = await db.execute(
|
||||
select(Challenge).where(Challenge.id == bonus_assignment.challenge_id)
|
||||
)
|
||||
challenge = result.scalar_one_or_none()
|
||||
title = f"Бонус: {challenge.title}" if challenge else "Бонусный челлендж"
|
||||
|
||||
if marathon and participant:
|
||||
await telegram_notifier.notify_dispute_resolved(
|
||||
db,
|
||||
user_id=participant.user_id,
|
||||
marathon_title=marathon.title,
|
||||
challenge_title=title,
|
||||
is_valid=not is_invalid
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[DisputeService] Failed to send bonus dispute notification: {e}")
|
||||
|
||||
async def _handle_invalid_bonus_proof(self, db: AsyncSession, dispute: Dispute) -> None:
|
||||
"""
|
||||
Handle the case when bonus proof is determined to be invalid.
|
||||
|
||||
- Reset bonus assignment to PENDING
|
||||
- If main playthrough was already completed, subtract bonus points from participant
|
||||
"""
|
||||
from app.models import BonusAssignment, BonusAssignmentStatus, AssignmentStatus
|
||||
|
||||
bonus_assignment = dispute.bonus_assignment
|
||||
main_assignment = bonus_assignment.main_assignment
|
||||
participant = main_assignment.participant
|
||||
|
||||
# If main playthrough was already completed, we need to subtract the bonus points
|
||||
if main_assignment.status == AssignmentStatus.COMPLETED.value:
|
||||
points_to_subtract = bonus_assignment.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)
|
||||
print(f"[DisputeService] Subtracted {points_to_subtract} points from participant {participant.id}")
|
||||
|
||||
# Reset bonus assignment
|
||||
bonus_assignment.status = BonusAssignmentStatus.PENDING.value
|
||||
bonus_assignment.proof_path = None
|
||||
bonus_assignment.proof_url = None
|
||||
bonus_assignment.proof_comment = None
|
||||
bonus_assignment.points_earned = 0
|
||||
bonus_assignment.completed_at = None
|
||||
|
||||
print(f"[DisputeService] Bonus assignment {bonus_assignment.id} reset to PENDING due to invalid dispute")
|
||||
|
||||
async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None:
|
||||
"""
|
||||
Handle the case when proof is determined to be invalid.
|
||||
@@ -113,7 +205,10 @@ class DisputeService:
|
||||
- Mark assignment as RETURNED
|
||||
- Subtract points from participant
|
||||
- Reset streak if it was affected
|
||||
- For playthrough: also reset bonus assignments
|
||||
"""
|
||||
from app.models import BonusAssignment, BonusAssignmentStatus
|
||||
|
||||
assignment = dispute.assignment
|
||||
participant = assignment.participant
|
||||
|
||||
@@ -121,22 +216,45 @@ class DisputeService:
|
||||
points_to_subtract = assignment.points_earned
|
||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||
|
||||
# Reset streak - the completion was invalid so streak should be broken
|
||||
participant.current_streak = 0
|
||||
|
||||
# Reset assignment
|
||||
assignment.status = AssignmentStatus.RETURNED.value
|
||||
assignment.points_earned = 0
|
||||
# Keep proof data so it can be reviewed
|
||||
|
||||
# For playthrough: reset all bonus assignments
|
||||
if assignment.is_playthrough:
|
||||
result = await db.execute(
|
||||
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
|
||||
)
|
||||
bonus_assignments = result.scalars().all()
|
||||
for ba in bonus_assignments:
|
||||
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
|
||||
print(f"[DisputeService] Reset {len(bonus_assignments)} bonus assignments for playthrough {assignment.id}")
|
||||
|
||||
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"""
|
||||
"""Get all open disputes (both regular and bonus) older than specified hours"""
|
||||
from datetime import timedelta
|
||||
from app.models import BonusAssignment
|
||||
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(Dispute)
|
||||
.options(
|
||||
selectinload(Dispute.assignment),
|
||||
selectinload(Dispute.bonus_assignment),
|
||||
)
|
||||
.where(
|
||||
Dispute.status == DisputeStatus.OPEN.value,
|
||||
Dispute.created_at < cutoff_time,
|
||||
|
||||
@@ -312,6 +312,43 @@ class TelegramNotifier:
|
||||
)
|
||||
return await self.notify_user(db, user_id, message)
|
||||
|
||||
async def notify_admin_disputes_pending(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
count: int
|
||||
) -> bool:
|
||||
"""Notify admin about disputes waiting for decision."""
|
||||
if not settings.TELEGRAM_ADMIN_ID:
|
||||
logger.warning("[Notify] No TELEGRAM_ADMIN_ID configured")
|
||||
return False
|
||||
|
||||
admin_url = f"{settings.FRONTEND_URL}/admin/disputes"
|
||||
use_inline_button = admin_url.startswith("https://")
|
||||
|
||||
if use_inline_button:
|
||||
message = (
|
||||
f"⚠️ <b>{count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения</b>\n\n"
|
||||
f"Голосование завершено, требуется ваше решение."
|
||||
)
|
||||
reply_markup = {
|
||||
"inline_keyboard": [[
|
||||
{"text": "Открыть оспаривания", "url": admin_url}
|
||||
]]
|
||||
}
|
||||
else:
|
||||
message = (
|
||||
f"⚠️ <b>{count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения</b>\n\n"
|
||||
f"Голосование завершено, требуется ваше решение.\n\n"
|
||||
f"🔗 {admin_url}"
|
||||
)
|
||||
reply_markup = None
|
||||
|
||||
return await self.send_message(
|
||||
int(settings.TELEGRAM_ADMIN_ID),
|
||||
message,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
|
||||
|
||||
# Global instance
|
||||
telegram_notifier = TelegramNotifier()
|
||||
|
||||
Reference in New Issue
Block a user