""" 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, Assignment, AssignmentStatus, Participant, Marathon, Challenge, Game, ) from app.services.telegram_notifier import telegram_notifier 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 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) ) 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 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) 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() # 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) return result_status, votes_valid, votes_invalid 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 result = await db.execute( select(Assignment) .options( selectinload(Assignment.participant), selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.game), # For playthrough ) .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 # Get marathon result = await db.execute( select(Marathon).where(Marathon.id == marathon_id) ) 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, 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. - 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 # 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 # 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 (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, ) ) return list(result.scalars().all()) # Global service instance dispute_service = DisputeService()