diff --git a/commands/draft/admin.py b/commands/draft/admin.py index 63b0ab9..e2031d7 100644 --- a/commands/draft/admin.py +++ b/commands/draft/admin.py @@ -336,6 +336,108 @@ class DraftAdminGroup(app_commands.Group): embed = EmbedTemplate.success("Deadline Reset", description) 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 " + + # 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") @league_admin_only() @logged_command("/draft-admin resync-sheet") diff --git a/commands/draft/picks.py b/commands/draft/picks.py index 9a89934..0463b20 100644 --- a/commands/draft/picks.py +++ b/commands/draft/picks.py @@ -160,6 +160,15 @@ class DraftPicksCog(commands.Cog): await interaction.followup.send(embed=embed) 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 current_pick = await draft_pick_service.get_pick( config.sba_season, diff --git a/models/draft_data.py b/models/draft_data.py index 2dcbe36..058f79e 100644 --- a/models/draft_data.py +++ b/models/draft_data.py @@ -15,6 +15,7 @@ class DraftData(SBABaseModel): currentpick: int = Field(0, description="Current pick number in progress") 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") 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") @@ -32,16 +33,26 @@ class DraftData(SBABaseModel): @property def is_draft_active(self) -> bool: - """Check if the draft is currently active.""" - return self.timer - + """Check if the draft is currently active (timer running and not paused).""" + return self.timer and not self.paused + @property def is_pick_expired(self) -> bool: """Check if the current pick deadline has passed.""" if not self.pick_deadline: return False 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): - 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)" \ No newline at end of file diff --git a/services/draft_service.py b/services/draft_service.py index d47f23a..991334a 100644 --- a/services/draft_service.py +++ b/services/draft_service.py @@ -338,6 +338,82 @@ class DraftService(BaseService[DraftData]): logger.error(f"Error resetting draft deadline: {e}") 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 draft_service = DraftService() diff --git a/tasks/draft_monitor.py b/tasks/draft_monitor.py index 74d38a0..65f20c7 100644 --- a/tasks/draft_monitor.py +++ b/tasks/draft_monitor.py @@ -100,6 +100,11 @@ class DraftMonitorTask: self.monitor_loop.cancel() 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 now = datetime.now() deadline = draft_data.pick_deadline diff --git a/tests/test_services_draft.py b/tests/test_services_draft.py index 9ab58f1..a3cc56e 100644 --- a/tests/test_services_draft.py +++ b/tests/test_services_draft.py @@ -39,13 +39,14 @@ def create_draft_data(**overrides) -> dict: """ 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 """ base_data = { 'id': 1, 'currentpick': 25, 'timer': True, + 'paused': False, # New field for draft pause feature 'pick_deadline': (datetime.now() + timedelta(minutes=10)).isoformat(), 'result_channel': '123456789012345678', # 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]['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 @@ -1362,6 +1517,72 @@ class TestDraftDataModel: assert active.is_draft_active is True 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): """Test is_pick_expired property.""" # Expired deadline diff --git a/views/draft_views.py b/views/draft_views.py index f070a11..d9f80dd 100644 --- a/views/draft_views.py +++ b/views/draft_views.py @@ -125,10 +125,17 @@ async def create_draft_status_embed( Returns: Discord embed with draft status """ - embed = EmbedTemplate.info( - title="Draft Status", - description=f"Currently on {format_pick_display(draft_data.currentpick)}" - ) + # Use warning color if paused + if draft_data.paused: + 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 if current_pick.owner: @@ -138,8 +145,13 @@ async def create_draft_status_embed( inline=True ) - # Timer status - timer_status = "✅ Active" if draft_data.timer else "⏹️ Inactive" + # Timer status (show paused state prominently) + if draft_data.paused: + timer_status = "⏸️ PAUSED" + elif draft_data.timer: + timer_status = "✅ Active" + else: + timer_status = "⏹️ Inactive" embed.add_field( name="Timer", value=f"{timer_status} ({draft_data.pick_minutes} min)", @@ -161,6 +173,14 @@ async def create_draft_status_embed( 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 embed.add_field( name="Lock Status", @@ -427,11 +447,19 @@ async def create_admin_draft_info_embed( Returns: Discord embed with admin information """ - embed = EmbedTemplate.create_base_embed( - title="⚙️ Draft Administration", - description="Current draft configuration and state", - color=EmbedColors.INFO - ) + # Use warning color if paused + if draft_data.paused: + embed = EmbedTemplate.create_base_embed( + 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 embed.add_field( @@ -440,11 +468,20 @@ async def create_admin_draft_info_embed( inline=True ) - # Timer status - timer_emoji = "✅" if draft_data.timer else "⏹️" + # Timer status (show paused prominently) + 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( name="Timer Status", - value=f"{timer_emoji} {'Active' if draft_data.timer else 'Inactive'}", + value=f"{timer_emoji} {timer_text}", inline=True ) @@ -455,6 +492,14 @@ async def create_admin_draft_info_embed( 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 ping_channel_value = f"<#{draft_data.ping_channel}>" if draft_data.ping_channel else "Not configured" embed.add_field(