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:
Cal Corum 2026-01-20 13:35:48 -06:00
parent fb76f1edb3
commit f007c5b870
4 changed files with 371 additions and 17 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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."""

View File

@ -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)