Fix frozen flag bug and add Transaction Thaw Report for admins
Bug Fix: - Fixed /dropadd transactions being marked frozen=True during thaw period - Now uses current_state.freeze to set frozen flag correctly - Transactions entered Sat-Sun are now unfrozen and execute Monday New Feature - Transaction Thaw Report: - Added data structures for thaw reporting (ThawReport, ThawedMove, CancelledMove, ConflictResolution, ConflictContender) - Modified resolve_contested_transactions() to return conflict details - Added _post_thaw_report() to post formatted report to admin channel - Report shows thawed moves, cancelled moves, and conflict resolution - Handles Discord's 2000 char limit with _send_long_report() Tests: - Updated test_views_transaction_embed.py for frozen flag behavior - Added test for thaw period (freeze=False) scenario - Updated test_tasks_transaction_freeze.py for new return values - All tests passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fb76f1edb3
commit
f007c5b870
@ -39,10 +39,64 @@ class TransactionPriority:
|
|||||||
return self.tiebreaker < other.tiebreaker
|
return self.tiebreaker < other.tiebreaker
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConflictContender:
|
||||||
|
"""A team contending for a contested player."""
|
||||||
|
team_abbrev: str
|
||||||
|
wins: int
|
||||||
|
losses: int
|
||||||
|
win_pct: float
|
||||||
|
move_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConflictResolution:
|
||||||
|
"""Details of a conflict resolution for a contested player."""
|
||||||
|
player_name: str
|
||||||
|
player_swar: float
|
||||||
|
contenders: List[ConflictContender]
|
||||||
|
winner: ConflictContender
|
||||||
|
losers: List[ConflictContender]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ThawedMove:
|
||||||
|
"""A move that was successfully thawed (unfrozen)."""
|
||||||
|
move_id: str
|
||||||
|
team_abbrev: str
|
||||||
|
players: List[Tuple[str, float, str, str]] # (name, sWAR, old_team, new_team)
|
||||||
|
submitted_at: str # Extracted from moveid timestamp
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CancelledMove:
|
||||||
|
"""A move that was cancelled due to conflict."""
|
||||||
|
move_id: str
|
||||||
|
team_abbrev: str
|
||||||
|
players: List[Tuple[str, float, str, str]] # (name, sWAR, old_team, new_team)
|
||||||
|
lost_to: str # Team abbrev that won the contested player
|
||||||
|
contested_player: str # Name of player that caused the cancellation
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ThawReport:
|
||||||
|
"""Complete thaw report for admin review."""
|
||||||
|
week: int
|
||||||
|
season: int
|
||||||
|
timestamp: datetime
|
||||||
|
total_moves: int
|
||||||
|
thawed_count: int
|
||||||
|
cancelled_count: int
|
||||||
|
conflict_count: int
|
||||||
|
conflicts: List[ConflictResolution]
|
||||||
|
thawed_moves: List[ThawedMove]
|
||||||
|
cancelled_moves: List[CancelledMove]
|
||||||
|
|
||||||
|
|
||||||
async def resolve_contested_transactions(
|
async def resolve_contested_transactions(
|
||||||
transactions: List[Transaction],
|
transactions: List[Transaction],
|
||||||
season: int
|
season: int
|
||||||
) -> Tuple[List[str], List[str]]:
|
) -> Tuple[List[str], List[str], List[ConflictResolution]]:
|
||||||
"""
|
"""
|
||||||
Resolve contested transactions where multiple teams want the same player.
|
Resolve contested transactions where multiple teams want the same player.
|
||||||
|
|
||||||
@ -53,7 +107,7 @@ async def resolve_contested_transactions(
|
|||||||
season: Current season number
|
season: Current season number
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (winning_move_ids, losing_move_ids)
|
Tuple of (winning_move_ids, losing_move_ids, conflict_resolutions)
|
||||||
"""
|
"""
|
||||||
logger = get_contextual_logger(f'{__name__}.resolve_contested_transactions')
|
logger = get_contextual_logger(f'{__name__}.resolve_contested_transactions')
|
||||||
|
|
||||||
@ -84,9 +138,12 @@ async def resolve_contested_transactions(
|
|||||||
# Resolve contests using team priority (win% + random tiebreaker)
|
# Resolve contests using team priority (win% + random tiebreaker)
|
||||||
winning_move_ids: Set[str] = set()
|
winning_move_ids: Set[str] = set()
|
||||||
losing_move_ids: Set[str] = set()
|
losing_move_ids: Set[str] = set()
|
||||||
|
conflict_resolutions: List[ConflictResolution] = []
|
||||||
|
|
||||||
for player_name, contested_transactions in contested_players.items():
|
for player_name, contested_transactions in contested_players.items():
|
||||||
priorities: List[TransactionPriority] = []
|
priorities: List[TransactionPriority] = []
|
||||||
|
# Track standings data for each team for report
|
||||||
|
team_standings_data: Dict[str, Tuple[int, int, float]] = {} # abbrev -> (wins, losses, win_pct)
|
||||||
|
|
||||||
for transaction in contested_transactions:
|
for transaction in contested_transactions:
|
||||||
# Get team for priority calculation
|
# Get team for priority calculation
|
||||||
@ -103,8 +160,12 @@ async def resolve_contested_transactions(
|
|||||||
if standings and standings.wins is not None and standings.losses is not None:
|
if standings and standings.wins is not None and standings.losses is not None:
|
||||||
total_games = standings.wins + standings.losses
|
total_games = standings.wins + standings.losses
|
||||||
win_pct = standings.wins / total_games if total_games > 0 else 0.0
|
win_pct = standings.wins / total_games if total_games > 0 else 0.0
|
||||||
|
team_standings_data[transaction.newteam.abbrev] = (
|
||||||
|
standings.wins, standings.losses, win_pct
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
win_pct = 0.0
|
win_pct = 0.0
|
||||||
|
team_standings_data[transaction.newteam.abbrev] = (0, 0, 0.0)
|
||||||
logger.warning(f"Could not get standings for {team_abbrev}, using 0.0 win%")
|
logger.warning(f"Could not get standings for {team_abbrev}, using 0.0 win%")
|
||||||
|
|
||||||
# Add small random component for tiebreaking (5 decimal precision)
|
# Add small random component for tiebreaking (5 decimal precision)
|
||||||
@ -119,6 +180,7 @@ async def resolve_contested_transactions(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error calculating priority for {team_abbrev}: {e}")
|
logger.error(f"Error calculating priority for {team_abbrev}: {e}")
|
||||||
|
team_standings_data[transaction.newteam.abbrev] = (0, 0, 0.0)
|
||||||
# Give them 0.0 priority on error
|
# Give them 0.0 priority on error
|
||||||
priorities.append(TransactionPriority(
|
priorities.append(TransactionPriority(
|
||||||
transaction=transaction,
|
transaction=transaction,
|
||||||
@ -134,6 +196,20 @@ async def resolve_contested_transactions(
|
|||||||
winner = priorities[0]
|
winner = priorities[0]
|
||||||
winning_move_ids.add(winner.transaction.moveid)
|
winning_move_ids.add(winner.transaction.moveid)
|
||||||
|
|
||||||
|
# Build conflict resolution record
|
||||||
|
winner_abbrev = winner.transaction.newteam.abbrev
|
||||||
|
winner_standings = team_standings_data.get(winner_abbrev, (0, 0, 0.0))
|
||||||
|
winner_contender = ConflictContender(
|
||||||
|
team_abbrev=winner_abbrev,
|
||||||
|
wins=winner_standings[0],
|
||||||
|
losses=winner_standings[1],
|
||||||
|
win_pct=winner_standings[2],
|
||||||
|
move_id=winner.transaction.moveid
|
||||||
|
)
|
||||||
|
|
||||||
|
loser_contenders: List[ConflictContender] = []
|
||||||
|
all_contenders: List[ConflictContender] = [winner_contender]
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Contest resolved for {player_name}: {winner.transaction.newteam.abbrev} wins "
|
f"Contest resolved for {player_name}: {winner.transaction.newteam.abbrev} wins "
|
||||||
f"(win%: {winner.team_win_percentage:.3f}, tiebreaker: {winner.tiebreaker:.8f})"
|
f"(win%: {winner.team_win_percentage:.3f}, tiebreaker: {winner.tiebreaker:.8f})"
|
||||||
@ -141,15 +217,37 @@ async def resolve_contested_transactions(
|
|||||||
|
|
||||||
for loser in priorities[1:]:
|
for loser in priorities[1:]:
|
||||||
losing_move_ids.add(loser.transaction.moveid)
|
losing_move_ids.add(loser.transaction.moveid)
|
||||||
|
loser_abbrev = loser.transaction.newteam.abbrev
|
||||||
|
loser_standings = team_standings_data.get(loser_abbrev, (0, 0, 0.0))
|
||||||
|
loser_contender = ConflictContender(
|
||||||
|
team_abbrev=loser_abbrev,
|
||||||
|
wins=loser_standings[0],
|
||||||
|
losses=loser_standings[1],
|
||||||
|
win_pct=loser_standings[2],
|
||||||
|
move_id=loser.transaction.moveid
|
||||||
|
)
|
||||||
|
loser_contenders.append(loser_contender)
|
||||||
|
all_contenders.append(loser_contender)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Contest lost for {player_name}: {loser.transaction.newteam.abbrev} "
|
f"Contest lost for {player_name}: {loser.transaction.newteam.abbrev} "
|
||||||
f"(win%: {loser.team_win_percentage:.3f}, tiebreaker: {loser.tiebreaker:.8f})"
|
f"(win%: {loser.team_win_percentage:.3f}, tiebreaker: {loser.tiebreaker:.8f})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get player info from first transaction (they all have same player)
|
||||||
|
player = contested_transactions[0].player
|
||||||
|
conflict_resolutions.append(ConflictResolution(
|
||||||
|
player_name=player.name,
|
||||||
|
player_swar=player.wara,
|
||||||
|
contenders=all_contenders,
|
||||||
|
winner=winner_contender,
|
||||||
|
losers=loser_contenders
|
||||||
|
))
|
||||||
|
|
||||||
# Add non-contested moves to winners
|
# Add non-contested moves to winners
|
||||||
winning_move_ids.update(non_contested_moves)
|
winning_move_ids.update(non_contested_moves)
|
||||||
|
|
||||||
return list(winning_move_ids), list(losing_move_ids)
|
return list(winning_move_ids), list(losing_move_ids), conflict_resolutions
|
||||||
|
|
||||||
|
|
||||||
class TransactionFreezeTask:
|
class TransactionFreezeTask:
|
||||||
@ -408,6 +506,7 @@ class TransactionFreezeTask:
|
|||||||
2. Resolve contested transactions (multiple teams want same player)
|
2. Resolve contested transactions (multiple teams want same player)
|
||||||
3. Cancel losing transactions
|
3. Cancel losing transactions
|
||||||
4. Unfreeze and post winning transactions
|
4. Unfreeze and post winning transactions
|
||||||
|
5. Generate and post admin thaw report
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get all frozen transactions for current week via service
|
# Get all frozen transactions for current week via service
|
||||||
@ -419,16 +518,38 @@ class TransactionFreezeTask:
|
|||||||
|
|
||||||
if not transactions:
|
if not transactions:
|
||||||
self.logger.warning(f"No frozen transactions to process for week {current.week}")
|
self.logger.warning(f"No frozen transactions to process for week {current.week}")
|
||||||
|
# Still post an empty report for visibility
|
||||||
|
empty_report = ThawReport(
|
||||||
|
week=current.week,
|
||||||
|
season=current.season,
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
total_moves=0,
|
||||||
|
thawed_count=0,
|
||||||
|
cancelled_count=0,
|
||||||
|
conflict_count=0,
|
||||||
|
conflicts=[],
|
||||||
|
thawed_moves=[],
|
||||||
|
cancelled_moves=[]
|
||||||
|
)
|
||||||
|
await self._post_thaw_report(empty_report)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.logger.info(f"Processing {len(transactions)} frozen transactions for week {current.week}")
|
self.logger.info(f"Processing {len(transactions)} frozen transactions for week {current.week}")
|
||||||
|
|
||||||
# Resolve contested transactions
|
# Resolve contested transactions
|
||||||
winning_move_ids, losing_move_ids = await resolve_contested_transactions(
|
winning_move_ids, losing_move_ids, conflict_resolutions = await resolve_contested_transactions(
|
||||||
transactions,
|
transactions,
|
||||||
current.season
|
current.season
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Build mapping from conflict player to winner for cancelled move tracking
|
||||||
|
conflict_player_to_winner: Dict[str, str] = {}
|
||||||
|
for conflict in conflict_resolutions:
|
||||||
|
conflict_player_to_winner[conflict.player_name.lower()] = conflict.winner.team_abbrev
|
||||||
|
|
||||||
|
# Track cancelled moves for report
|
||||||
|
cancelled_moves_report: List[CancelledMove] = []
|
||||||
|
|
||||||
# Cancel losing transactions via service
|
# Cancel losing transactions via service
|
||||||
for losing_move_id in losing_move_ids:
|
for losing_move_id in losing_move_ids:
|
||||||
try:
|
try:
|
||||||
@ -452,6 +573,29 @@ class TransactionFreezeTask:
|
|||||||
|
|
||||||
await self._notify_gm_of_cancellation(first_move, team_for_notification)
|
await self._notify_gm_of_cancellation(first_move, team_for_notification)
|
||||||
|
|
||||||
|
# Find which player caused the conflict
|
||||||
|
contested_player = ""
|
||||||
|
lost_to = ""
|
||||||
|
for move in losing_moves:
|
||||||
|
player_key = move.player.name.lower()
|
||||||
|
if player_key in conflict_player_to_winner:
|
||||||
|
contested_player = move.player.name
|
||||||
|
lost_to = conflict_player_to_winner[player_key]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Build report entry
|
||||||
|
players = [
|
||||||
|
(move.player.name, move.player.wara, move.oldteam.abbrev, move.newteam.abbrev)
|
||||||
|
for move in losing_moves
|
||||||
|
]
|
||||||
|
cancelled_moves_report.append(CancelledMove(
|
||||||
|
move_id=losing_move_id,
|
||||||
|
team_abbrev=team_for_notification.abbrev,
|
||||||
|
players=players,
|
||||||
|
lost_to=lost_to,
|
||||||
|
contested_player=contested_player
|
||||||
|
))
|
||||||
|
|
||||||
contested_players = [move.player.name for move in losing_moves]
|
contested_players = [move.player.name for move in losing_moves]
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"Cancelled transaction {losing_move_id} due to contested players: "
|
f"Cancelled transaction {losing_move_id} due to contested players: "
|
||||||
@ -461,6 +605,9 @@ class TransactionFreezeTask:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error cancelling transaction {losing_move_id}: {e}")
|
self.logger.error(f"Error cancelling transaction {losing_move_id}: {e}")
|
||||||
|
|
||||||
|
# Track thawed moves for report
|
||||||
|
thawed_moves_report: List[ThawedMove] = []
|
||||||
|
|
||||||
# Unfreeze winning transactions and post to log via service
|
# Unfreeze winning transactions and post to log via service
|
||||||
for winning_move_id in winning_move_ids:
|
for winning_move_id in winning_move_ids:
|
||||||
try:
|
try:
|
||||||
@ -476,11 +623,53 @@ class TransactionFreezeTask:
|
|||||||
# Post to transaction log
|
# Post to transaction log
|
||||||
await self._post_transaction_to_log(winning_move_id, transactions)
|
await self._post_transaction_to_log(winning_move_id, transactions)
|
||||||
|
|
||||||
|
# Build report entry
|
||||||
|
if winning_moves:
|
||||||
|
first_move = winning_moves[0]
|
||||||
|
# Extract timestamp from moveid (format: Season-XXX-Week-XX-DD-HH:MM:SS)
|
||||||
|
try:
|
||||||
|
parts = winning_move_id.split('-')
|
||||||
|
submitted_at = parts[-1] if len(parts) >= 6 else "Unknown"
|
||||||
|
except Exception:
|
||||||
|
submitted_at = "Unknown"
|
||||||
|
|
||||||
|
# Determine team abbrev
|
||||||
|
if first_move.newteam.abbrev.upper() != 'FA':
|
||||||
|
team_abbrev = first_move.newteam.abbrev
|
||||||
|
else:
|
||||||
|
team_abbrev = first_move.oldteam.abbrev
|
||||||
|
|
||||||
|
players = [
|
||||||
|
(move.player.name, move.player.wara, move.oldteam.abbrev, move.newteam.abbrev)
|
||||||
|
for move in winning_moves
|
||||||
|
]
|
||||||
|
thawed_moves_report.append(ThawedMove(
|
||||||
|
move_id=winning_move_id,
|
||||||
|
team_abbrev=team_abbrev,
|
||||||
|
players=players,
|
||||||
|
submitted_at=submitted_at
|
||||||
|
))
|
||||||
|
|
||||||
self.logger.info(f"Processed successful transaction {winning_move_id}")
|
self.logger.info(f"Processed successful transaction {winning_move_id}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error processing winning transaction {winning_move_id}: {e}")
|
self.logger.error(f"Error processing winning transaction {winning_move_id}: {e}")
|
||||||
|
|
||||||
|
# Generate and post thaw report
|
||||||
|
thaw_report = ThawReport(
|
||||||
|
week=current.week,
|
||||||
|
season=current.season,
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
total_moves=len(set(t.moveid for t in transactions)),
|
||||||
|
thawed_count=len(winning_move_ids),
|
||||||
|
cancelled_count=len(losing_move_ids),
|
||||||
|
conflict_count=len(conflict_resolutions),
|
||||||
|
conflicts=conflict_resolutions,
|
||||||
|
thawed_moves=thawed_moves_report,
|
||||||
|
cancelled_moves=cancelled_moves_report
|
||||||
|
)
|
||||||
|
await self._post_thaw_report(thaw_report)
|
||||||
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"Freeze processing complete: {len(winning_move_ids)} successful transactions, "
|
f"Freeze processing complete: {len(winning_move_ids)} successful transactions, "
|
||||||
f"{len(losing_move_ids)} cancelled transactions"
|
f"{len(losing_move_ids)} cancelled transactions"
|
||||||
@ -770,6 +959,118 @@ class TransactionFreezeTask:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error notifying GM of cancellation: {e}")
|
self.logger.error(f"Error notifying GM of cancellation: {e}")
|
||||||
|
|
||||||
|
async def _post_thaw_report(self, report: ThawReport):
|
||||||
|
"""
|
||||||
|
Post the thaw report to the admin channel for league admins.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report: ThawReport containing all thaw processing details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config = get_config()
|
||||||
|
guild = self.bot.get_guild(config.guild_id)
|
||||||
|
if not guild:
|
||||||
|
self.logger.warning("Could not find guild for thaw report")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try to find admin channel (admin, bot-admin, or bot-logs)
|
||||||
|
admin_channel = None
|
||||||
|
for channel_name in ['bot-admin', 'admin', 'bot-logs']:
|
||||||
|
admin_channel = discord.utils.get(guild.text_channels, name=channel_name)
|
||||||
|
if admin_channel:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not admin_channel:
|
||||||
|
self.logger.warning("Could not find admin channel for thaw report")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build the report content
|
||||||
|
report_lines = []
|
||||||
|
|
||||||
|
# Header with summary
|
||||||
|
timestamp_str = report.timestamp.strftime('%B %d, %Y %H:%M UTC')
|
||||||
|
report_lines.append(f"# Transaction Thaw Report")
|
||||||
|
report_lines.append(f"**Week {report.week}** | **Season {report.season}** | {timestamp_str}")
|
||||||
|
report_lines.append(f"**Total:** {report.total_moves} moves | **Thawed:** {report.thawed_count} | **Cancelled:** {report.cancelled_count} | **Conflicts:** {report.conflict_count}")
|
||||||
|
report_lines.append("")
|
||||||
|
|
||||||
|
# Conflict Resolution section (if any)
|
||||||
|
if report.conflicts:
|
||||||
|
report_lines.append("## Conflict Resolution")
|
||||||
|
for conflict in report.conflicts:
|
||||||
|
report_lines.append(f"**{conflict.player_name}** (sWAR: {conflict.player_swar:.1f})")
|
||||||
|
contenders_str = " vs ".join([
|
||||||
|
f"{c.team_abbrev} ({c.wins}-{c.losses})"
|
||||||
|
for c in conflict.contenders
|
||||||
|
])
|
||||||
|
report_lines.append(f"- Contested by: {contenders_str}")
|
||||||
|
report_lines.append(f"- **Awarded to: {conflict.winner.team_abbrev}** (worst record wins)")
|
||||||
|
report_lines.append("")
|
||||||
|
|
||||||
|
# Thawed Moves section
|
||||||
|
report_lines.append("## Thawed Moves")
|
||||||
|
if report.thawed_moves:
|
||||||
|
for move in report.thawed_moves:
|
||||||
|
report_lines.append(f"**{move.move_id}** | {move.team_abbrev}")
|
||||||
|
for player_name, swar, old_team, new_team in move.players:
|
||||||
|
report_lines.append(f" - {player_name} ({swar:.1f}): {old_team} → {new_team}")
|
||||||
|
else:
|
||||||
|
report_lines.append("*No moves thawed*")
|
||||||
|
report_lines.append("")
|
||||||
|
|
||||||
|
# Cancelled Moves section
|
||||||
|
report_lines.append("## Cancelled Moves")
|
||||||
|
if report.cancelled_moves:
|
||||||
|
for move in report.cancelled_moves:
|
||||||
|
lost_info = f" (lost {move.contested_player} to {move.lost_to})" if move.lost_to else ""
|
||||||
|
report_lines.append(f"**{move.move_id}** | {move.team_abbrev}{lost_info}")
|
||||||
|
for player_name, swar, old_team, new_team in move.players:
|
||||||
|
report_lines.append(f" - ❌ {player_name} ({swar:.1f}): {old_team} → {new_team}")
|
||||||
|
else:
|
||||||
|
report_lines.append("*No moves cancelled*")
|
||||||
|
|
||||||
|
# Combine into content
|
||||||
|
report_content = "\n".join(report_lines)
|
||||||
|
|
||||||
|
# Discord has a 2000 character limit, so we may need to split
|
||||||
|
if len(report_content) <= 2000:
|
||||||
|
await admin_channel.send(report_content)
|
||||||
|
else:
|
||||||
|
# Split into multiple messages
|
||||||
|
await self._send_long_report(admin_channel, report_lines)
|
||||||
|
|
||||||
|
self.logger.info(f"Thaw report posted to {admin_channel.name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error posting thaw report: {e}", exc_info=True)
|
||||||
|
|
||||||
|
async def _send_long_report(self, channel, report_lines: List[str]):
|
||||||
|
"""
|
||||||
|
Send a long report by splitting into multiple messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: Discord channel to send to
|
||||||
|
report_lines: List of report lines to send
|
||||||
|
"""
|
||||||
|
current_chunk = []
|
||||||
|
current_length = 0
|
||||||
|
|
||||||
|
for line in report_lines:
|
||||||
|
line_length = len(line) + 1 # +1 for newline
|
||||||
|
|
||||||
|
if current_length + line_length > 1900: # Leave buffer for safety
|
||||||
|
# Send current chunk
|
||||||
|
await channel.send("\n".join(current_chunk))
|
||||||
|
current_chunk = [line]
|
||||||
|
current_length = line_length
|
||||||
|
else:
|
||||||
|
current_chunk.append(line)
|
||||||
|
current_length += line_length
|
||||||
|
|
||||||
|
# Send remaining chunk
|
||||||
|
if current_chunk:
|
||||||
|
await channel.send("\n".join(current_chunk))
|
||||||
|
|
||||||
async def _send_owner_notification(self, message: str):
|
async def _send_owner_notification(self, message: str):
|
||||||
"""
|
"""
|
||||||
Send error notification to bot owner.
|
Send error notification to bot owner.
|
||||||
|
|||||||
@ -343,7 +343,7 @@ class TestResolveContestedTransactions:
|
|||||||
with patch('tasks.transaction_freeze.standings_service') as mock_standings:
|
with patch('tasks.transaction_freeze.standings_service') as mock_standings:
|
||||||
mock_standings.get_team_standings = AsyncMock(return_value=None)
|
mock_standings.get_team_standings = AsyncMock(return_value=None)
|
||||||
|
|
||||||
winning_ids, losing_ids = await resolve_contested_transactions(transactions, 12)
|
winning_ids, losing_ids, conflicts = await resolve_contested_transactions(transactions, 12)
|
||||||
|
|
||||||
# Single transaction should win automatically
|
# Single transaction should win automatically
|
||||||
assert sample_transaction.moveid in winning_ids
|
assert sample_transaction.moveid in winning_ids
|
||||||
@ -369,7 +369,7 @@ class TestResolveContestedTransactions:
|
|||||||
|
|
||||||
# Mock random for deterministic testing
|
# Mock random for deterministic testing
|
||||||
with patch('tasks.transaction_freeze.random.randint', return_value=50000):
|
with patch('tasks.transaction_freeze.random.randint', return_value=50000):
|
||||||
winning_ids, losing_ids = await resolve_contested_transactions(
|
winning_ids, losing_ids, conflicts = await resolve_contested_transactions(
|
||||||
contested_transactions, 12
|
contested_transactions, 12
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -439,7 +439,7 @@ class TestResolveContestedTransactions:
|
|||||||
|
|
||||||
# Mock random for deterministic testing
|
# Mock random for deterministic testing
|
||||||
with patch('tasks.transaction_freeze.random.randint', return_value=50000):
|
with patch('tasks.transaction_freeze.random.randint', return_value=50000):
|
||||||
winning_ids, losing_ids = await resolve_contested_transactions(transactions, 12)
|
winning_ids, losing_ids, conflicts = await resolve_contested_transactions(transactions, 12)
|
||||||
|
|
||||||
# Should have called with "WV" (stripped "MiL" suffix)
|
# Should have called with "WV" (stripped "MiL" suffix)
|
||||||
# Will be called twice (once for WVMiL, once for NY)
|
# Will be called twice (once for WVMiL, once for NY)
|
||||||
@ -477,7 +477,7 @@ class TestResolveContestedTransactions:
|
|||||||
|
|
||||||
transactions = [drop_tx]
|
transactions = [drop_tx]
|
||||||
|
|
||||||
winning_ids, losing_ids = await resolve_contested_transactions(transactions, 12)
|
winning_ids, losing_ids, conflicts = await resolve_contested_transactions(transactions, 12)
|
||||||
|
|
||||||
# FA drops are not winners or losers (they're not acquisitions)
|
# FA drops are not winners or losers (they're not acquisitions)
|
||||||
assert len(winning_ids) == 0
|
assert len(winning_ids) == 0
|
||||||
@ -492,7 +492,7 @@ class TestResolveContestedTransactions:
|
|||||||
|
|
||||||
# Mock random for deterministic testing
|
# Mock random for deterministic testing
|
||||||
with patch('tasks.transaction_freeze.random.randint', return_value=50000):
|
with patch('tasks.transaction_freeze.random.randint', return_value=50000):
|
||||||
winning_ids, losing_ids = await resolve_contested_transactions(
|
winning_ids, losing_ids, conflicts = await resolve_contested_transactions(
|
||||||
contested_transactions, 12
|
contested_transactions, 12
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -564,7 +564,7 @@ class TestResolveContestedTransactions:
|
|||||||
mock_standings.get_team_standings = AsyncMock(side_effect=get_standings)
|
mock_standings.get_team_standings = AsyncMock(side_effect=get_standings)
|
||||||
|
|
||||||
with patch('tasks.transaction_freeze.random.randint', return_value=50000):
|
with patch('tasks.transaction_freeze.random.randint', return_value=50000):
|
||||||
winning_ids, losing_ids = await resolve_contested_transactions(transactions, 12)
|
winning_ids, losing_ids, conflicts = await resolve_contested_transactions(transactions, 12)
|
||||||
|
|
||||||
# Only one winner
|
# Only one winner
|
||||||
assert len(winning_ids) == 1
|
assert len(winning_ids) == 1
|
||||||
@ -809,9 +809,11 @@ class TestProcessFrozenTransactions:
|
|||||||
mock_tx_service.unfreeze_transaction = AsyncMock(return_value=True)
|
mock_tx_service.unfreeze_transaction = AsyncMock(return_value=True)
|
||||||
|
|
||||||
with patch('tasks.transaction_freeze.resolve_contested_transactions') as mock_resolve:
|
with patch('tasks.transaction_freeze.resolve_contested_transactions') as mock_resolve:
|
||||||
mock_resolve.return_value = ([sample_transaction.moveid], [])
|
# Returns (winning_ids, losing_ids, conflict_resolutions)
|
||||||
|
mock_resolve.return_value = ([sample_transaction.moveid], [], [])
|
||||||
|
|
||||||
task._post_transaction_to_log = AsyncMock()
|
task._post_transaction_to_log = AsyncMock()
|
||||||
|
task._post_thaw_report = AsyncMock()
|
||||||
|
|
||||||
await task._process_frozen_transactions(frozen_state)
|
await task._process_frozen_transactions(frozen_state)
|
||||||
|
|
||||||
@ -844,11 +846,12 @@ class TestProcessFrozenTransactions:
|
|||||||
mock_tx_service.unfreeze_transaction = AsyncMock(return_value=True)
|
mock_tx_service.unfreeze_transaction = AsyncMock(return_value=True)
|
||||||
|
|
||||||
with patch('tasks.transaction_freeze.resolve_contested_transactions') as mock_resolve:
|
with patch('tasks.transaction_freeze.resolve_contested_transactions') as mock_resolve:
|
||||||
# tx1 wins, tx2 loses
|
# tx1 wins, tx2 loses - returns (winning_ids, losing_ids, conflict_resolutions)
|
||||||
mock_resolve.return_value = ([tx1.moveid], [tx2.moveid])
|
mock_resolve.return_value = ([tx1.moveid], [tx2.moveid], [])
|
||||||
|
|
||||||
task._post_transaction_to_log = AsyncMock()
|
task._post_transaction_to_log = AsyncMock()
|
||||||
task._notify_gm_of_cancellation = AsyncMock()
|
task._notify_gm_of_cancellation = AsyncMock()
|
||||||
|
task._post_thaw_report = AsyncMock()
|
||||||
|
|
||||||
await task._process_frozen_transactions(frozen_state)
|
await task._process_frozen_transactions(frozen_state)
|
||||||
|
|
||||||
@ -870,9 +873,15 @@ class TestProcessFrozenTransactions:
|
|||||||
with patch('tasks.transaction_freeze.transaction_service') as mock_tx_service:
|
with patch('tasks.transaction_freeze.transaction_service') as mock_tx_service:
|
||||||
mock_tx_service.get_frozen_transactions_by_week = AsyncMock(return_value=None)
|
mock_tx_service.get_frozen_transactions_by_week = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
# Mock the thaw report posting (empty report is still posted for visibility)
|
||||||
|
task._post_thaw_report = AsyncMock()
|
||||||
|
|
||||||
# Should not raise error
|
# Should not raise error
|
||||||
await task._process_frozen_transactions(frozen_state)
|
await task._process_frozen_transactions(frozen_state)
|
||||||
|
|
||||||
|
# Verify empty report was posted
|
||||||
|
task._post_thaw_report.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_frozen_transaction_error_recovery(
|
async def test_process_frozen_transaction_error_recovery(
|
||||||
self,
|
self,
|
||||||
@ -892,9 +901,11 @@ class TestProcessFrozenTransactions:
|
|||||||
mock_tx_service.unfreeze_transaction = AsyncMock(return_value=False)
|
mock_tx_service.unfreeze_transaction = AsyncMock(return_value=False)
|
||||||
|
|
||||||
with patch('tasks.transaction_freeze.resolve_contested_transactions') as mock_resolve:
|
with patch('tasks.transaction_freeze.resolve_contested_transactions') as mock_resolve:
|
||||||
mock_resolve.return_value = ([sample_transaction.moveid], [])
|
# Returns (winning_ids, losing_ids, conflict_resolutions)
|
||||||
|
mock_resolve.return_value = ([sample_transaction.moveid], [], [])
|
||||||
|
|
||||||
task._post_transaction_to_log = AsyncMock()
|
task._post_transaction_to_log = AsyncMock()
|
||||||
|
task._post_thaw_report = AsyncMock()
|
||||||
|
|
||||||
# Should not raise error
|
# Should not raise error
|
||||||
await task._process_frozen_transactions(frozen_state)
|
await task._process_frozen_transactions(frozen_state)
|
||||||
|
|||||||
@ -255,6 +255,7 @@ class TestSubmitConfirmationModal:
|
|||||||
with patch('services.league_service.league_service') as mock_league_service:
|
with patch('services.league_service.league_service') as mock_league_service:
|
||||||
mock_current_state = MagicMock()
|
mock_current_state = MagicMock()
|
||||||
mock_current_state.week = 10
|
mock_current_state.week = 10
|
||||||
|
mock_current_state.freeze = True # Simulate Monday-Friday freeze period
|
||||||
mock_league_service.get_current_state = AsyncMock(return_value=mock_current_state)
|
mock_league_service.get_current_state = AsyncMock(return_value=mock_current_state)
|
||||||
|
|
||||||
modal.builder.submit_transaction = AsyncMock(return_value=[mock_transaction])
|
modal.builder.submit_transaction = AsyncMock(return_value=[mock_transaction])
|
||||||
@ -278,7 +279,7 @@ class TestSubmitConfirmationModal:
|
|||||||
# Should submit transaction for next week
|
# Should submit transaction for next week
|
||||||
modal.builder.submit_transaction.assert_called_once_with(week=11)
|
modal.builder.submit_transaction.assert_called_once_with(week=11)
|
||||||
|
|
||||||
# Should mark transaction as frozen
|
# Should mark transaction as frozen (based on current_state.freeze)
|
||||||
assert mock_transaction.frozen is True
|
assert mock_transaction.frozen is True
|
||||||
|
|
||||||
# Should POST to database
|
# Should POST to database
|
||||||
@ -293,6 +294,45 @@ class TestSubmitConfirmationModal:
|
|||||||
assert "Transaction Submitted Successfully" in call_args[0][0]
|
assert "Transaction Submitted Successfully" in call_args[0][0]
|
||||||
assert mock_transaction.moveid in call_args[0][0]
|
assert mock_transaction.moveid in call_args[0][0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modal_submit_during_thaw_period(self, mock_builder, mock_interaction):
|
||||||
|
"""Test modal submission during thaw period (Saturday-Sunday) sets frozen=False.
|
||||||
|
|
||||||
|
When freeze=False (Saturday-Sunday), transactions should NOT be frozen
|
||||||
|
because they should execute immediately on Monday morning without waiting
|
||||||
|
for the Saturday conflict resolution process.
|
||||||
|
"""
|
||||||
|
modal = SubmitConfirmationModal(mock_builder)
|
||||||
|
# Mock the TextInput values
|
||||||
|
modal.confirmation = MagicMock()
|
||||||
|
modal.confirmation.value = 'CONFIRM'
|
||||||
|
|
||||||
|
mock_transaction = MagicMock()
|
||||||
|
mock_transaction.moveid = 'Season-012-Week-11-123456789'
|
||||||
|
mock_transaction.week = 11
|
||||||
|
mock_transaction.frozen = True # Will be set to False during thaw
|
||||||
|
|
||||||
|
with patch('services.league_service.league_service') as mock_league_service:
|
||||||
|
mock_current_state = MagicMock()
|
||||||
|
mock_current_state.week = 10
|
||||||
|
mock_current_state.freeze = False # Simulate Saturday-Sunday thaw period
|
||||||
|
mock_league_service.get_current_state = AsyncMock(return_value=mock_current_state)
|
||||||
|
|
||||||
|
modal.builder.submit_transaction = AsyncMock(return_value=[mock_transaction])
|
||||||
|
|
||||||
|
with patch('services.transaction_service.transaction_service') as mock_transaction_service:
|
||||||
|
# Mock the create_transaction_batch call
|
||||||
|
mock_transaction_service.create_transaction_batch = AsyncMock(return_value=[mock_transaction])
|
||||||
|
|
||||||
|
with patch('utils.transaction_logging.post_transaction_to_log') as mock_post_log:
|
||||||
|
mock_post_log.return_value = AsyncMock()
|
||||||
|
|
||||||
|
with patch('services.transaction_builder.clear_transaction_builder') as mock_clear:
|
||||||
|
await modal.on_submit(mock_interaction)
|
||||||
|
|
||||||
|
# Should mark transaction as NOT frozen (thaw period)
|
||||||
|
assert mock_transaction.frozen is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modal_submit_no_current_state(self, mock_builder, mock_interaction):
|
async def test_modal_submit_no_current_state(self, mock_builder, mock_interaction):
|
||||||
"""Test modal submission when current state unavailable."""
|
"""Test modal submission when current state unavailable."""
|
||||||
|
|||||||
@ -240,9 +240,11 @@ class SubmitConfirmationModal(discord.ui.Modal):
|
|||||||
# Submit the transaction for NEXT week
|
# Submit the transaction for NEXT week
|
||||||
transactions = await self.builder.submit_transaction(week=current_state.week + 1)
|
transactions = await self.builder.submit_transaction(week=current_state.week + 1)
|
||||||
|
|
||||||
# Mark transactions as frozen for weekly processing
|
# Set frozen flag based on current freeze state:
|
||||||
|
# - During freeze (Mon-Fri): frozen=True -> wait for Saturday conflict resolution
|
||||||
|
# - During thaw (Sat-Sun): frozen=False -> execute next Monday immediately
|
||||||
for txn in transactions:
|
for txn in transactions:
|
||||||
txn.frozen = True
|
txn.frozen = current_state.freeze
|
||||||
|
|
||||||
# POST transactions to database
|
# POST transactions to database
|
||||||
created_transactions = await transaction_service.create_transaction_batch(transactions)
|
created_transactions = await transaction_service.create_transaction_batch(transactions)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user