Add draft pause/resume functionality

- Add paused field to DraftData model
- Add pause_draft() and resume_draft() methods to DraftService
- Add /draft-admin pause and /draft-admin resume commands
- Block picks in /draft command when draft is paused
- Skip auto-draft in draft_monitor when draft is paused
- Update status embeds to show paused state
- Add comprehensive tests for pause/resume

When paused:
- Timer is stopped (set to False)
- Deadline is set far in future
- All /draft picks are blocked
- Auto-draft monitor skips processing

When resumed:
- Timer is restarted with fresh deadline
- Picks are allowed again

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-12-11 19:58:37 -06:00
parent 647ae1ca15
commit ff62529ee3
7 changed files with 489 additions and 20 deletions

View File

@ -336,6 +336,108 @@ class DraftAdminGroup(app_commands.Group):
embed = EmbedTemplate.success("Deadline Reset", description) embed = EmbedTemplate.success("Deadline Reset", description)
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
@app_commands.command(name="pause", description="Pause the draft (block all picks)")
@league_admin_only()
@logged_command("/draft-admin pause")
async def draft_admin_pause(self, interaction: discord.Interaction):
"""Pause the draft, blocking all manual and auto-draft picks."""
await interaction.response.defer()
# Get draft data
draft_data = await draft_service.get_draft_data()
if not draft_data:
embed = EmbedTemplate.error(
"Draft Not Found",
"Could not retrieve draft configuration."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Check if already paused
if draft_data.paused:
embed = EmbedTemplate.warning(
"Already Paused",
"The draft is already paused."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Pause the draft
updated = await draft_service.pause_draft(draft_data.id)
if not updated:
embed = EmbedTemplate.error(
"Pause Failed",
"Failed to pause the draft."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Success message
description = (
"The draft has been **paused**.\n\n"
"**Effects:**\n"
"• All `/draft` picks are blocked\n"
"• Auto-draft will not fire\n"
"• Timer has been stopped\n\n"
"Use `/draft-admin resume` to restart the timer and allow picks."
)
embed = EmbedTemplate.warning("Draft Paused", description)
await interaction.followup.send(embed=embed)
@app_commands.command(name="resume", description="Resume the draft (allow picks)")
@league_admin_only()
@logged_command("/draft-admin resume")
async def draft_admin_resume(self, interaction: discord.Interaction):
"""Resume the draft, allowing manual and auto-draft picks again."""
await interaction.response.defer()
# Get draft data
draft_data = await draft_service.get_draft_data()
if not draft_data:
embed = EmbedTemplate.error(
"Draft Not Found",
"Could not retrieve draft configuration."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Check if already unpaused
if not draft_data.paused:
embed = EmbedTemplate.warning(
"Not Paused",
"The draft is not currently paused."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Resume the draft
updated = await draft_service.resume_draft(draft_data.id)
if not updated:
embed = EmbedTemplate.error(
"Resume Failed",
"Failed to resume the draft."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Build success message
description = "The draft has been **resumed**.\n\nPicks are now allowed."
# Add timer info if active
if updated.timer and updated.pick_deadline:
deadline_timestamp = int(updated.pick_deadline.timestamp())
description += f"\n\n⏱️ **Timer Active** - Current deadline <t:{deadline_timestamp}:R>"
# Ensure monitor is running
monitor_status = self._ensure_monitor_running()
description += monitor_status
embed = EmbedTemplate.success("Draft Resumed", description)
await interaction.followup.send(embed=embed)
@app_commands.command(name="resync-sheet", description="Resync all picks to Google Sheet") @app_commands.command(name="resync-sheet", description="Resync all picks to Google Sheet")
@league_admin_only() @league_admin_only()
@logged_command("/draft-admin resync-sheet") @logged_command("/draft-admin resync-sheet")

View File

@ -160,6 +160,15 @@ class DraftPicksCog(commands.Cog):
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
# Check if draft is paused
if draft_data.paused:
embed = await create_pick_illegal_embed(
"Draft Paused",
"The draft is currently paused. Please wait for an administrator to resume."
)
await interaction.followup.send(embed=embed)
return
# Get current pick # Get current pick
current_pick = await draft_pick_service.get_pick( current_pick = await draft_pick_service.get_pick(
config.sba_season, config.sba_season,

View File

@ -15,6 +15,7 @@ class DraftData(SBABaseModel):
currentpick: int = Field(0, description="Current pick number in progress") currentpick: int = Field(0, description="Current pick number in progress")
timer: bool = Field(False, description="Whether draft timer is active") timer: bool = Field(False, description="Whether draft timer is active")
paused: bool = Field(False, description="Whether draft is paused (blocks all picks)")
pick_deadline: Optional[datetime] = Field(None, description="Deadline for current pick") pick_deadline: Optional[datetime] = Field(None, description="Deadline for current pick")
result_channel: Optional[int] = Field(None, description="Discord channel ID for draft results") result_channel: Optional[int] = Field(None, description="Discord channel ID for draft results")
ping_channel: Optional[int] = Field(None, description="Discord channel ID for draft pings") ping_channel: Optional[int] = Field(None, description="Discord channel ID for draft pings")
@ -32,16 +33,26 @@ class DraftData(SBABaseModel):
@property @property
def is_draft_active(self) -> bool: def is_draft_active(self) -> bool:
"""Check if the draft is currently active.""" """Check if the draft is currently active (timer running and not paused)."""
return self.timer return self.timer and not self.paused
@property @property
def is_pick_expired(self) -> bool: def is_pick_expired(self) -> bool:
"""Check if the current pick deadline has passed.""" """Check if the current pick deadline has passed."""
if not self.pick_deadline: if not self.pick_deadline:
return False return False
return datetime.now() > self.pick_deadline return datetime.now() > self.pick_deadline
@property
def can_make_picks(self) -> bool:
"""Check if picks are allowed (not paused)."""
return not self.paused
def __str__(self): def __str__(self):
status = "Active" if self.is_draft_active else "Inactive" if self.paused:
status = "PAUSED"
elif self.timer:
status = "Active"
else:
status = "Inactive"
return f"Draft {status}: Pick {self.currentpick} ({self.pick_minutes}min timer)" return f"Draft {status}: Pick {self.currentpick} ({self.pick_minutes}min timer)"

View File

@ -338,6 +338,82 @@ class DraftService(BaseService[DraftData]):
logger.error(f"Error resetting draft deadline: {e}") logger.error(f"Error resetting draft deadline: {e}")
return None return None
async def pause_draft(self, draft_id: int) -> Optional[DraftData]:
"""
Pause the draft, blocking all picks (manual and auto) and stopping the timer.
When paused:
- /draft command will reject picks with "Draft is paused" message
- Auto-draft monitor will skip auto-drafting
- Timer is stopped (deadline set far in future)
- On resume, timer will restart with fresh deadline
Args:
draft_id: DraftData database ID
Returns:
Updated DraftData with paused=True and timer stopped
"""
try:
# Pause the draft AND stop the timer
# Set deadline far in future so it doesn't expire while paused
updates = {
'paused': True,
'timer': False,
'pick_deadline': datetime.now() + timedelta(days=690)
}
updated = await self.update_draft_data(draft_id, updates)
if updated:
logger.info("Draft paused - all picks blocked and timer stopped")
else:
logger.error("Failed to pause draft")
return updated
except Exception as e:
logger.error(f"Error pausing draft: {e}")
return None
async def resume_draft(self, draft_id: int) -> Optional[DraftData]:
"""
Resume the draft, allowing picks again and restarting the timer.
When resumed:
- Timer is restarted with fresh deadline based on pick_minutes
- All picks (manual and auto) are allowed again
Args:
draft_id: DraftData database ID
Returns:
Updated DraftData with paused=False and timer restarted
"""
try:
# Get current draft data to get pick_minutes setting
current_data = await self.get_draft_data()
pick_minutes = current_data.pick_minutes if current_data else 2
# Resume the draft AND restart the timer with fresh deadline
new_deadline = datetime.now() + timedelta(minutes=pick_minutes)
updates = {
'paused': False,
'timer': True,
'pick_deadline': new_deadline
}
updated = await self.update_draft_data(draft_id, updates)
if updated:
logger.info(f"Draft resumed - timer restarted with {pick_minutes}min deadline")
else:
logger.error("Failed to resume draft")
return updated
except Exception as e:
logger.error(f"Error resuming draft: {e}")
return None
# Global service instance # Global service instance
draft_service = DraftService() draft_service = DraftService()

View File

@ -100,6 +100,11 @@ class DraftMonitorTask:
self.monitor_loop.cancel() self.monitor_loop.cancel()
return return
# CRITICAL: Skip auto-draft if paused (but keep monitoring)
if draft_data.paused:
self.logger.debug("Draft is paused - skipping auto-draft actions")
return
# Check if we need to take action # Check if we need to take action
now = datetime.now() now = datetime.now()
deadline = draft_data.pick_deadline deadline = draft_data.pick_deadline

View File

@ -39,13 +39,14 @@ def create_draft_data(**overrides) -> dict:
""" """
Create complete draft data matching API response format. Create complete draft data matching API response format.
API returns: id, currentpick, timer, pick_deadline, result_channel, API returns: id, currentpick, timer, paused, pick_deadline, result_channel,
ping_channel, pick_minutes ping_channel, pick_minutes
""" """
base_data = { base_data = {
'id': 1, 'id': 1,
'currentpick': 25, 'currentpick': 25,
'timer': True, 'timer': True,
'paused': False, # New field for draft pause feature
'pick_deadline': (datetime.now() + timedelta(minutes=10)).isoformat(), 'pick_deadline': (datetime.now() + timedelta(minutes=10)).isoformat(),
'result_channel': '123456789012345678', # API returns as string 'result_channel': '123456789012345678', # API returns as string
'ping_channel': '987654321098765432', # API returns as string 'ping_channel': '987654321098765432', # API returns as string
@ -450,6 +451,160 @@ class TestDraftService:
assert patch_call[0][1]['ping_channel'] == 111111111111111111 assert patch_call[0][1]['ping_channel'] == 111111111111111111
assert patch_call[0][1]['result_channel'] == 222222222222222222 assert patch_call[0][1]['result_channel'] == 222222222222222222
# -------------------------------------------------------------------------
# pause_draft() tests
# -------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pause_draft_success(self, service, mock_client):
"""
Test successfully pausing the draft.
Verifies:
- PATCH is called with paused=True, timer=False, and far-future deadline
- Updated draft data with paused=True is returned
- Timer is stopped when draft is paused (prevents deadline expiry during pause)
"""
updated_data = create_draft_data(paused=True, timer=False)
mock_client.patch.return_value = updated_data
result = await service.pause_draft(draft_id=1)
assert result is not None
assert result.paused is True
assert result.timer is False
# Verify PATCH was called with all pause-related updates
patch_call = mock_client.patch.call_args
patch_data = patch_call[0][1]
assert patch_data['paused'] is True
assert patch_data['timer'] is False
assert 'pick_deadline' in patch_data # Far-future deadline set
@pytest.mark.asyncio
async def test_pause_draft_failure(self, service, mock_client):
"""
Test handling of failed pause operation.
Verifies service returns None when PATCH fails.
"""
mock_client.patch.return_value = None
result = await service.pause_draft(draft_id=1)
assert result is None
@pytest.mark.asyncio
async def test_pause_draft_api_error(self, service, mock_client):
"""
Test error handling when pause API call fails.
Verifies service returns None on exception rather than crashing.
"""
mock_client.patch.side_effect = Exception("API unavailable")
result = await service.pause_draft(draft_id=1)
assert result is None
# -------------------------------------------------------------------------
# resume_draft() tests
# -------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resume_draft_success(self, service, mock_client):
"""
Test successfully resuming the draft.
Verifies:
- Current draft data is fetched to get pick_minutes
- PATCH is called with paused=False, timer=True, and fresh deadline
- Timer is restarted when draft is resumed
"""
# First call: get_draft_data to fetch pick_minutes
current_data = create_draft_data(paused=True, timer=False, pick_minutes=5)
mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]}
# Second call: patch returns updated data
updated_data = create_draft_data(paused=False, timer=True, pick_minutes=5)
mock_client.patch.return_value = updated_data
result = await service.resume_draft(draft_id=1)
assert result is not None
assert result.paused is False
assert result.timer is True
# Verify PATCH was called with all resume-related updates
patch_call = mock_client.patch.call_args
patch_data = patch_call[0][1]
assert patch_data['paused'] is False
assert patch_data['timer'] is True
assert 'pick_deadline' in patch_data # Fresh deadline set
@pytest.mark.asyncio
async def test_resume_draft_failure(self, service, mock_client):
"""
Test handling of failed resume operation.
Verifies service returns None when PATCH fails.
"""
# First call: get_draft_data succeeds
current_data = create_draft_data(paused=True, timer=False)
mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]}
# PATCH fails
mock_client.patch.return_value = None
result = await service.resume_draft(draft_id=1)
assert result is None
@pytest.mark.asyncio
async def test_resume_draft_api_error(self, service, mock_client):
"""
Test error handling when resume API call fails.
Verifies service returns None on exception rather than crashing.
"""
# First call: get_draft_data succeeds
current_data = create_draft_data(paused=True, timer=False)
mock_client.get.return_value = {'count': 1, 'draftdata': [current_data]}
# PATCH fails with exception
mock_client.patch.side_effect = Exception("API unavailable")
result = await service.resume_draft(draft_id=1)
assert result is None
@pytest.mark.asyncio
async def test_pause_resume_roundtrip(self, service, mock_client):
"""
Test pausing and then resuming the draft.
Verifies the complete pause/resume workflow:
1. Pause stops the timer
2. Resume restarts the timer with fresh deadline
"""
# First pause - timer should be stopped
paused_data = create_draft_data(paused=True, timer=False)
mock_client.patch.return_value = paused_data
pause_result = await service.pause_draft(draft_id=1)
assert pause_result.paused is True
assert pause_result.timer is False
# Then resume - timer should be restarted
# resume_draft first fetches current data to get pick_minutes
mock_client.get.return_value = {'count': 1, 'draftdata': [paused_data]}
resumed_data = create_draft_data(paused=False, timer=True)
mock_client.patch.return_value = resumed_data
resume_result = await service.resume_draft(draft_id=1)
assert resume_result.paused is False
assert resume_result.timer is True
# ============================================================================= # =============================================================================
# DraftPickService Tests # DraftPickService Tests
@ -1362,6 +1517,72 @@ class TestDraftDataModel:
assert active.is_draft_active is True assert active.is_draft_active is True
assert inactive.is_draft_active is False assert inactive.is_draft_active is False
def test_is_draft_active_when_paused(self):
"""
Test that is_draft_active returns False when draft is paused.
Even if timer is True, is_draft_active should be False when paused
because no picks should be processed.
"""
paused_with_timer = DraftData(
id=1, currentpick=1, timer=True, paused=True, pick_minutes=2
)
paused_no_timer = DraftData(
id=1, currentpick=1, timer=False, paused=True, pick_minutes=2
)
active_not_paused = DraftData(
id=1, currentpick=1, timer=True, paused=False, pick_minutes=2
)
assert paused_with_timer.is_draft_active is False
assert paused_no_timer.is_draft_active is False
assert active_not_paused.is_draft_active is True
def test_can_make_picks_property(self):
"""
Test can_make_picks property correctly reflects pause state.
can_make_picks should be True only when not paused,
regardless of timer state.
"""
# Not paused - can make picks
not_paused = DraftData(
id=1, currentpick=1, timer=True, paused=False, pick_minutes=2
)
assert not_paused.can_make_picks is True
# Paused - cannot make picks
paused = DraftData(
id=1, currentpick=1, timer=True, paused=True, pick_minutes=2
)
assert paused.can_make_picks is False
# Not paused, timer off - can still make picks (manual draft)
manual_draft = DraftData(
id=1, currentpick=1, timer=False, paused=False, pick_minutes=2
)
assert manual_draft.can_make_picks is True
def test_draft_data_str_shows_paused_status(self):
"""
Test that __str__ displays paused status when draft is paused.
Users should clearly see when the draft is paused.
"""
paused = DraftData(
id=1, currentpick=25, timer=True, paused=True, pick_minutes=2
)
active = DraftData(
id=1, currentpick=25, timer=True, paused=False, pick_minutes=2
)
inactive = DraftData(
id=1, currentpick=25, timer=False, paused=False, pick_minutes=2
)
assert "PAUSED" in str(paused)
assert "Active" in str(active)
assert "Inactive" in str(inactive)
def test_is_pick_expired_property(self): def test_is_pick_expired_property(self):
"""Test is_pick_expired property.""" """Test is_pick_expired property."""
# Expired deadline # Expired deadline

View File

@ -125,10 +125,17 @@ async def create_draft_status_embed(
Returns: Returns:
Discord embed with draft status Discord embed with draft status
""" """
embed = EmbedTemplate.info( # Use warning color if paused
title="Draft Status", if draft_data.paused:
description=f"Currently on {format_pick_display(draft_data.currentpick)}" embed = EmbedTemplate.warning(
) title="Draft Status - PAUSED",
description=f"Currently on {format_pick_display(draft_data.currentpick)}"
)
else:
embed = EmbedTemplate.info(
title="Draft Status",
description=f"Currently on {format_pick_display(draft_data.currentpick)}"
)
# On the clock # On the clock
if current_pick.owner: if current_pick.owner:
@ -138,8 +145,13 @@ async def create_draft_status_embed(
inline=True inline=True
) )
# Timer status # Timer status (show paused state prominently)
timer_status = "✅ Active" if draft_data.timer else "⏹️ Inactive" if draft_data.paused:
timer_status = "⏸️ PAUSED"
elif draft_data.timer:
timer_status = "✅ Active"
else:
timer_status = "⏹️ Inactive"
embed.add_field( embed.add_field(
name="Timer", name="Timer",
value=f"{timer_status} ({draft_data.pick_minutes} min)", value=f"{timer_status} ({draft_data.pick_minutes} min)",
@ -161,6 +173,14 @@ async def create_draft_status_embed(
inline=True inline=True
) )
# Pause status (if paused, show prominent warning)
if draft_data.paused:
embed.add_field(
name="Pause Status",
value="🚫 **Draft is paused** - No picks allowed until admin resumes",
inline=False
)
# Lock status # Lock status
embed.add_field( embed.add_field(
name="Lock Status", name="Lock Status",
@ -427,11 +447,19 @@ async def create_admin_draft_info_embed(
Returns: Returns:
Discord embed with admin information Discord embed with admin information
""" """
embed = EmbedTemplate.create_base_embed( # Use warning color if paused
title="⚙️ Draft Administration", if draft_data.paused:
description="Current draft configuration and state", embed = EmbedTemplate.create_base_embed(
color=EmbedColors.INFO title="⚙️ Draft Administration - PAUSED",
) description="Current draft configuration and state",
color=EmbedColors.WARNING
)
else:
embed = EmbedTemplate.create_base_embed(
title="⚙️ Draft Administration",
description="Current draft configuration and state",
color=EmbedColors.INFO
)
# Current pick # Current pick
embed.add_field( embed.add_field(
@ -440,11 +468,20 @@ async def create_admin_draft_info_embed(
inline=True inline=True
) )
# Timer status # Timer status (show paused prominently)
timer_emoji = "" if draft_data.timer else "⏹️" if draft_data.paused:
timer_emoji = "⏸️"
timer_text = "PAUSED"
elif draft_data.timer:
timer_emoji = ""
timer_text = "Active"
else:
timer_emoji = "⏹️"
timer_text = "Inactive"
embed.add_field( embed.add_field(
name="Timer Status", name="Timer Status",
value=f"{timer_emoji} {'Active' if draft_data.timer else 'Inactive'}", value=f"{timer_emoji} {timer_text}",
inline=True inline=True
) )
@ -455,6 +492,14 @@ async def create_admin_draft_info_embed(
inline=True inline=True
) )
# Pause status (prominent if paused)
if draft_data.paused:
embed.add_field(
name="Pause Status",
value="🚫 **PAUSED** - No picks allowed\nUse `/draft-admin resume` to allow picks",
inline=False
)
# Channels # Channels
ping_channel_value = f"<#{draft_data.ping_channel}>" if draft_data.ping_channel else "Not configured" ping_channel_value = f"<#{draft_data.ping_channel}>" if draft_data.ping_channel else "Not configured"
embed.add_field( embed.add_field(