Files
game-marathon/backend/app/services/disputes.py

268 lines
10 KiB
Python
Raw Normal View History

2025-12-16 00:33:50 +07:00
"""
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,
2025-12-16 20:06:16 +07:00
Assignment, AssignmentStatus, Participant, Marathon, Challenge, Game,
2025-12-16 00:33:50 +07:00
)
2025-12-16 20:06:16 +07:00
from app.services.telegram_notifier import telegram_notifier
2025-12-16 00:33:50 +07:00
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)
"""
from app.models import BonusAssignment, BonusAssignmentStatus
# Get dispute with votes, assignment and bonus_assignment
2025-12-16 00:33:50 +07:00
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),
2025-12-16 00:33:50 +07:00
)
.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
2025-12-16 00:33:50 +07:00
result_status = DisputeStatus.RESOLVED_INVALID.value
if dispute.bonus_assignment_id:
await self._handle_invalid_bonus_proof(db, dispute)
else:
await self._handle_invalid_proof(db, dispute)
2025-12-16 00:33:50 +07:00
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()
2025-12-16 20:06:16 +07:00
# Send Telegram notification about dispute resolution
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)
2025-12-16 20:06:16 +07:00
2025-12-16 00:33:50 +07:00
return result_status, votes_valid, votes_invalid
2025-12-16 20:06:16 +07:00
async def _notify_dispute_resolved(
self,
db: AsyncSession,
dispute: Dispute,
is_valid: bool
) -> None:
"""Send notification about dispute resolution to the assignment owner."""
try:
# Get assignment with challenge/game and marathon info
2025-12-16 20:06:16 +07:00
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
2025-12-16 20:06:16 +07:00
)
.where(Assignment.id == dispute.assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
return
participant = assignment.participant
# 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
2025-12-16 20:06:16 +07:00
# Get marathon
result = await db.execute(
select(Marathon).where(Marathon.id == marathon_id)
2025-12-16 20:06:16 +07:00
)
marathon = result.scalar_one_or_none()
if marathon and participant:
await telegram_notifier.notify_dispute_resolved(
db,
user_id=participant.user_id,
marathon_title=marathon.title,
challenge_title=title,
2025-12-16 20:06:16 +07:00
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")
2025-12-16 00:33:50 +07:00
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
- For playthrough: also reset bonus assignments
2025-12-16 00:33:50 +07:00
"""
from app.models import BonusAssignment, BonusAssignmentStatus
2025-12-16 00:33:50 +07:00
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 streak - the completion was invalid so streak should be broken
participant.current_streak = 0
2025-12-16 00:33:50 +07:00
# 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}")
2025-12-16 00:33:50 +07:00
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 (both regular and bonus) older than specified hours"""
2025-12-16 00:33:50 +07:00
from datetime import timedelta
from app.models import BonusAssignment
2025-12-16 00:33:50 +07:00
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment),
selectinload(Dispute.bonus_assignment),
)
2025-12-16 00:33:50 +07:00
.where(
Dispute.status == DisputeStatus.OPEN.value,
Dispute.created_at < cutoff_time,
)
)
return list(result.scalars().all())
# Global service instance
dispute_service = DisputeService()