diff --git a/tests/test_admin_panel_fixtures.py b/tests/test_admin_panel_fixtures.py index ded31cd..c26be6b 100644 --- a/tests/test_admin_panel_fixtures.py +++ b/tests/test_admin_panel_fixtures.py @@ -13,6 +13,7 @@ DeleteConfirmView, EnterResultsModal, FixturesPanelView, + NewSeasonModal, PostResultsConfirmView, PredictionsPanelView, ResultsPanelView, @@ -172,6 +173,191 @@ async def test_unified_panel_create_fixture_button_opens_modal( assert isinstance(mock_interaction_admin.modal_sent["modal"], CreateFixtureModal) + @pytest.mark.asyncio + async def test_unified_panel_shows_active_season_and_new_season_button( + self, + admin_cog, + mock_interaction_admin, + ): + view = UnifiedAdminPanelView( + admin_cog.db, + admin_cog.service, + str(mock_interaction_admin.user.id), + "111111", + admin_commands=admin_cog, + bot=admin_cog.bot, + ) + await view.load_fixture_options() + + assert "Active season: Default Season" in view.render_content() + assert _has_button(view, "New Season") is True + + @pytest.mark.asyncio + async def test_unified_panel_hides_contextual_actions_until_fixture_selection( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + view = UnifiedAdminPanelView( + admin_cog.db, + admin_cog.service, + str(mock_interaction_admin.user.id), + "111111", + admin_commands=admin_cog, + bot=admin_cog.bot, + ) + await view.load_fixture_options() + + assert _has_button(view, "Enter Results") is False + assert _has_button(view, "Calculate Scores") is False + assert _has_button(view, "Correct Results") is False + assert _has_button(view, "Delete Fixture") is False + assert _has_button(view, "Replace Prediction") is False + assert _has_button(view, "Toggle Late Waiver") is False + + view.fixture_select._values = [str(fixture_id)] + await view.fixture_select.callback(mock_interaction_admin) + + assert _has_button(view, "Enter Results") is True + assert _has_button(view, "Calculate Scores") is True + assert _has_button(view, "Correct Results") is False + assert _has_button(view, "Delete Fixture") is True + assert _has_button(view, "Replace Prediction") is False + assert _has_button(view, "Toggle Late Waiver") is False + + await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"]) + view.fixture_select._values = [str(fixture_id)] + await view.fixture_select.callback(mock_interaction_admin) + + assert _has_button(view, "Enter Results") is False + assert _has_button(view, "Calculate Scores") is True + assert _has_button(view, "Correct Results") is True + + await admin_cog.db.save_scores( + fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + view.fixture_select._values = [str(fixture_id)] + await view.fixture_select.callback(mock_interaction_admin) + + assert _has_button(view, "Enter Results") is False + assert _has_button(view, "Calculate Scores") is False + assert _has_button(view, "Correct Results") is True + assert _has_button(view, "Delete Fixture") is False + + @pytest.mark.asyncio + async def test_unified_panel_new_season_button_opens_modal( + self, + admin_cog, + mock_interaction_admin, + ): + view = UnifiedAdminPanelView( + admin_cog.db, + admin_cog.service, + str(mock_interaction_admin.user.id), + "111111", + admin_commands=admin_cog, + bot=admin_cog.bot, + ) + new_season_button = _get_button(view, "New Season") + + await new_season_button.callback(mock_interaction_admin) + + assert isinstance(mock_interaction_admin.modal_sent["modal"], NewSeasonModal) + + @pytest.mark.asyncio + async def test_new_season_modal_blocks_open_fixtures( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + await admin_cog.db.create_fixture( + "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + view = UnifiedAdminPanelView( + admin_cog.db, + admin_cog.service, + str(mock_interaction_admin.user.id), + "111111", + admin_commands=admin_cog, + bot=admin_cog.bot, + ) + await view.load_fixture_options() + view.current_prediction = {"pending_partial_approval": True} + view.has_user_overflow = True + modal = NewSeasonModal(view) + modal.name_input._value = "2026/27" + + await modal.on_submit(mock_interaction_admin) + + assert "Close all open fixtures" in mock_interaction_admin.response_sent[-1]["content"] + assert (await admin_cog.db.get_active_season("111111"))["name"] == "Default Season" + + @pytest.mark.asyncio + async def test_new_season_modal_starts_season_and_refreshes_panel( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_scores( + fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + view = UnifiedAdminPanelView( + admin_cog.db, + admin_cog.service, + str(mock_interaction_admin.user.id), + "111111", + admin_commands=admin_cog, + bot=admin_cog.bot, + ) + await view.load_fixture_options() + view.fixture_select._values = [str(fixture_id)] + await view.fixture_select.callback(mock_interaction_admin) + modal = NewSeasonModal(view) + modal.name_input._value = "2026/27" + + await modal.on_submit(mock_interaction_admin) + _new_fixture_id, new_week = await admin_cog.db.create_next_fixture( + "111111", sample_games, datetime.now(UTC) + timedelta(days=1) + ) + + content = mock_interaction_admin.response_sent[-1]["content"] + assert "Active season: 2026/27" in content + assert "Started new active season: 2026/27" in content + assert view.current_prediction is None + assert view.has_user_overflow is False + assert _has_button(view, "Enter Results") is False + assert _has_button(view, "Calculate Scores") is False + assert _has_button(view, "Correct Results") is False + assert _has_button(view, "Delete Fixture") is False + assert new_week == 1 + @pytest.mark.asyncio async def test_unified_panel_hides_review_pending_button_without_pending_partials( self, @@ -265,6 +451,7 @@ async def test_unified_panel_review_pending_button_jumps_to_pending_submission( fixture_id = await admin_cog.db.create_fixture( "111111", 56, sample_games, datetime.now(UTC) + timedelta(days=1) ) + await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"]) await admin_cog.db.save_prediction( fixture_id, "111", @@ -292,6 +479,8 @@ async def test_unified_panel_review_pending_button_jumps_to_pending_submission( assert view.selection.user_id == "111" assert _has_button(view, "Approve Late") is True assert _has_button(view, "Reject Late") is True + assert _has_button(view, "Enter Results") is False + assert _has_button(view, "Correct Results") is True @pytest.mark.asyncio async def test_unified_panel_review_pending_button_cycles_pending_submissions( @@ -419,7 +608,7 @@ async def test_unified_panel_enter_results_button_opens_modal( assert isinstance(mock_interaction_admin.modal_sent["modal"], EnterResultsModal) @pytest.mark.asyncio - async def test_unified_panel_enter_results_button_rejects_existing_results( + async def test_unified_panel_hides_enter_results_button_after_results_are_saved( self, admin_cog, mock_interaction_admin, @@ -441,10 +630,8 @@ async def test_unified_panel_enter_results_button_rejects_existing_results( view.fixture_select._values = [str(fixture_id)] await view.fixture_select.callback(mock_interaction_admin) - enter_button = _get_button(view, "Enter Results") - await enter_button.callback(mock_interaction_admin) - - assert "Correct Results" in mock_interaction_admin.response_sent[-1]["content"] + assert _has_button(view, "Enter Results") is False + assert _has_button(view, "Correct Results") is True @pytest.mark.asyncio async def test_unified_panel_calculate_scores_button_posts_results( @@ -505,6 +692,7 @@ async def test_unified_panel_calculate_scores_button_rejects_active_cooldown( fixture_id = await admin_cog.db.create_fixture( "111111", 47, sample_games, datetime.now(UTC) + timedelta(days=1) ) + await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"]) admin_cog.record_calculate_cooldown( "111111", str(mock_interaction_admin.user.id), current_time=now().timestamp() ) @@ -537,9 +725,7 @@ async def test_unified_panel_calculate_scores_button_handles_service_error( fixture_id = await admin_cog.db.create_fixture( "111111", 48, sample_games, datetime.now(UTC) + timedelta(days=1) ) - admin_cog.service.calculate_fixture_scores = AsyncMock( - side_effect=ValueError("No results entered") - ) + await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"]) admin_cog._create_backup = AsyncMock() view = UnifiedAdminPanelView( @@ -557,7 +743,10 @@ async def test_unified_panel_calculate_scores_button_handles_service_error( calculate_button = _get_button(view, "Calculate Scores") await calculate_button.callback(mock_interaction_admin) - assert mock_interaction_admin.response_sent[-1]["content"] == "No results entered" + assert ( + mock_interaction_admin.response_sent[-1]["content"] + == "No predictions found for this fixture" + ) @pytest.mark.asyncio async def test_unified_panel_post_results_button_opens_confirmation( @@ -673,8 +862,13 @@ async def test_unified_panel_jump_to_week_reaches_older_open_fixture( sample_games, ): deadline = datetime.now(UTC) + timedelta(days=1) + first_fixture_id = None for week in range(1, 28): - await admin_cog.db.create_fixture("111111", week, sample_games, deadline) + fixture_id = await admin_cog.db.create_fixture("111111", week, sample_games, deadline) + if week == 1: + first_fixture_id = fixture_id + assert first_fixture_id is not None + await admin_cog.db.save_results(first_fixture_id, ["1-0", "1-1", "0-0"]) view = UnifiedAdminPanelView( admin_cog.db, @@ -697,6 +891,9 @@ async def test_unified_panel_jump_to_week_reaches_older_open_fixture( assert view.selection.fixture_label == "Week 1 [OPEN]" assert "Fixture: Week 1 [OPEN]" in mock_interaction_admin.response_sent[-1]["content"] + assert _has_button(view, "Enter Results") is False + assert _has_button(view, "Calculate Scores") is True + assert _has_button(view, "Correct Results") is True @pytest.mark.asyncio async def test_unified_panel_jump_to_week_rejects_invalid_input( diff --git a/tests/test_admin_panel_predictions.py b/tests/test_admin_panel_predictions.py index 3ff2c79..a2d76cd 100644 --- a/tests/test_admin_panel_predictions.py +++ b/tests/test_admin_panel_predictions.py @@ -75,8 +75,8 @@ async def test_prediction_panel_initializes_empty_user_select( await unified_view.load_fixture_options() assert unified_view.user_select.disabled is True - assert _get_button(unified_view, "Replace Prediction").disabled is True - assert _get_button(unified_view, "Toggle Late Waiver").disabled is True + assert _has_button(unified_view, "Replace Prediction") is False + assert _has_button(unified_view, "Toggle Late Waiver") is False @pytest.mark.asyncio async def test_prediction_panel_buttons_enable_as_selections_are_made( diff --git a/tests/test_database.py b/tests/test_database.py index 75e32ce..725af83 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -233,6 +233,151 @@ async def test_create_next_fixture_restarts_week_numbers_per_active_season(self, assert new_week == 1 assert new_fixture["season_id"] == active_season["id"] + @pytest.mark.asyncio + async def test_start_new_season_archives_previous_active_season(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.upsert_guild_config("111111", "role-1", "channel-1") + old_fixture_id = await db.create_fixture( + "111111", 7, ["Team A - Team B"], datetime.now(UTC) + ) + await db.save_scores( + old_fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + old_season = await db.get_active_season("111111") + + new_season = await db.start_new_season("111111", "2026/27") + config = await db.get_guild_config("111111") + seasons = await db.get_seasons("111111") + + assert old_season is not None + assert new_season["name"] == "2026/27" + assert new_season["status"] == "active" + assert config["active_season_id"] == new_season["id"] + assert [(season["id"], season["status"]) for season in seasons] == [ + (old_season["id"], "archived"), + (new_season["id"], "active"), + ] + assert seasons[0]["ended_at"] is not None + + @pytest.mark.asyncio + async def test_start_new_season_blocks_open_active_fixture(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) + old_season = await db.get_active_season("111111") + + with pytest.raises(ValueError, match="Close all open fixtures"): + await db.start_new_season("111111", "2026/27") + + assert await db.get_active_season("111111") == old_season + + @pytest.mark.asyncio + async def test_start_new_season_rejects_blank_name_without_mutating(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + old_fixture_id = await db.create_fixture( + "111111", 1, ["Team A - Team B"], datetime.now(UTC) + ) + await db.save_scores( + old_fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 1, + "exact_scores": 0, + "correct_results": 1, + } + ], + ) + old_season = await db.get_active_season("111111") + + with pytest.raises(ValueError, match="Season name is required"): + await db.start_new_season("111111", " ") + + assert await db.get_active_season("111111") == old_season + assert await db.get_seasons("111111") == [old_season] + + @pytest.mark.asyncio + async def test_start_new_season_rolls_back_when_new_season_insert_fails(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + old_fixture_id = await db.create_fixture( + "111111", 1, ["Team A - Team B"], datetime.now(UTC) + ) + await db.save_scores( + old_fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 1, + "exact_scores": 0, + "correct_results": 1, + } + ], + ) + old_season = await db.get_active_season("111111") + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute( + """ + CREATE TRIGGER fail_broken_season_insert + BEFORE INSERT ON seasons + WHEN NEW.name = 'Broken Season' + BEGIN + SELECT RAISE(FAIL, 'broken season insert'); + END + """ + ) + await conn.commit() + + with pytest.raises(aiosqlite.IntegrityError, match="broken season insert"): + await db.start_new_season("111111", "Broken Season") + + assert await db.get_active_season("111111") == old_season + assert await db.get_seasons("111111") == [old_season] + + @pytest.mark.asyncio + async def test_start_new_season_resets_next_fixture_week(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + old_fixture_id, old_week = await db.create_next_fixture( + "111111", ["Team A - Team B"], datetime.now(UTC) + ) + await db.save_scores( + old_fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 1, + "exact_scores": 0, + "correct_results": 1, + } + ], + ) + + await db.start_new_season("111111", "2026/27") + new_fixture_id, new_week = await db.create_next_fixture( + "111111", ["Team C - Team D"], datetime.now(UTC) + ) + new_fixture = await db.get_fixture_by_id(new_fixture_id, "111111") + active_season = await db.get_active_season("111111") + + assert old_week == 1 + assert new_week == 1 + assert new_fixture["season_id"] == active_season["id"] + @pytest.mark.asyncio async def test_fixture_queries_default_to_active_season(self, temp_db_path): db = Database(temp_db_path) diff --git a/typer_bot/commands/admin_panel/__init__.py b/typer_bot/commands/admin_panel/__init__.py index 90a9920..3c3b128 100644 --- a/typer_bot/commands/admin_panel/__init__.py +++ b/typer_bot/commands/admin_panel/__init__.py @@ -14,7 +14,7 @@ from .predictions import PredictionsPanelView from .results import ResultsPanelView from .unified import UnifiedAdminPanelView -from .unified_actions import PostResultsConfirmView +from .unified_actions import NewSeasonModal, PostResultsConfirmView __all__ = [ "CorrectResultsModal", @@ -26,6 +26,7 @@ "ReplacePredictionModal", "ResultsPanelView", "PostResultsConfirmView", + "NewSeasonModal", "UnifiedAdminPanelView", "_build_delete_confirmation_content", ] diff --git a/typer_bot/commands/admin_panel/base.py b/typer_bot/commands/admin_panel/base.py index f0a7ff3..d8342a7 100644 --- a/typer_bot/commands/admin_panel/base.py +++ b/typer_bot/commands/admin_panel/base.py @@ -114,6 +114,8 @@ class PanelSelectionState: fixture_id: int | None = None user_id: str | None = None fixture_label: str = "" + fixture_status: str | None = None + has_results: bool = False user_label: str = "" detail_lines: list[str] = field(default_factory=list) status_message: str = "" @@ -370,11 +372,13 @@ async def callback(self, interaction: discord.Interaction): self.parent_view.selection.user_id = None self.parent_view.selection.user_label = "" self.parent_view.selection.detail_lines = [] + self.parent_view.selection.has_results = False fixture = await self.parent_view.db.get_fixture_by_id(fixture_id, self.parent_view.guild_id) if fixture is None: self.parent_view.selection.fixture_id = None self.parent_view.selection.fixture_label = "" + self.parent_view.selection.fixture_status = None self.parent_view.selection.status_message = "Fixture no longer exists." load_fixture_options = getattr(self.parent_view, "load_fixture_options", None) if callable(load_fixture_options): @@ -382,6 +386,7 @@ async def callback(self, interaction: discord.Interaction): else: self.parent_view.selection.fixture_id = fixture_id self.parent_view.selection.fixture_label = _fixture_select_label(fixture) + self.parent_view.selection.fixture_status = fixture["status"] self.parent_view.selection.status_message = "" populate_fixture_details = getattr(self.parent_view, "populate_fixture_details", None) diff --git a/typer_bot/commands/admin_panel/partial_review.py b/typer_bot/commands/admin_panel/partial_review.py index 6b19606..3ca9d2a 100644 --- a/typer_bot/commands/admin_panel/partial_review.py +++ b/typer_bot/commands/admin_panel/partial_review.py @@ -18,9 +18,9 @@ class ApprovePartialButton(discord.ui.Button): - def __init__(self, parent_view: UnifiedAdminPanelView): + def __init__(self, parent_view: UnifiedAdminPanelView, row: int | None = None): self.parent_view = parent_view - super().__init__(label="Approve Late", style=discord.ButtonStyle.success, row=4) + super().__init__(label="Approve Late", style=discord.ButtonStyle.success, row=row) async def callback(self, interaction: discord.Interaction): fixture_id = self.parent_view.selection.fixture_id @@ -84,9 +84,9 @@ async def callback(self, interaction: discord.Interaction): class RejectPartialButton(discord.ui.Button): - def __init__(self, parent_view: UnifiedAdminPanelView): + def __init__(self, parent_view: UnifiedAdminPanelView, row: int | None = None): self.parent_view = parent_view - super().__init__(label="Reject Late", style=discord.ButtonStyle.danger, row=4) + super().__init__(label="Reject Late", style=discord.ButtonStyle.danger, row=row) async def callback(self, interaction: discord.Interaction): fixture_id = self.parent_view.selection.fixture_id @@ -144,9 +144,9 @@ async def callback(self, interaction: discord.Interaction): class ReviewPendingPartialsButton(discord.ui.Button): - def __init__(self, parent_view: UnifiedAdminPanelView): + def __init__(self, parent_view: UnifiedAdminPanelView, row: int | None = None): self.parent_view = parent_view - super().__init__(label="Review Late", style=discord.ButtonStyle.primary, row=4) + super().__init__(label="Review Late", style=discord.ButtonStyle.primary, row=row) async def callback(self, interaction: discord.Interaction): pending_predictions = await self.parent_view.db.get_pending_partial_predictions( @@ -179,10 +179,12 @@ async def callback(self, interaction: discord.Interaction): self.parent_view.selection.fixture_label = ( f"Week {fixture['week_number']} [{fixture['status'].upper()}]" ) + self.parent_view.selection.fixture_status = fixture["status"] self.parent_view.selection.user_id = next_prediction["user_id"] self.parent_view.selection.user_label = ( f"{next_prediction['user_name']} ({_prediction_status_text(next_prediction)})" ) + await self.parent_view.populate_fixture_details(fixture) self.parent_view.selection.detail_lines = _build_indexed_detail_lines( next_prediction["predicted_game_indexes"], fixture["games"], diff --git a/typer_bot/commands/admin_panel/unified.py b/typer_bot/commands/admin_panel/unified.py index 7eb5b78..14cac09 100644 --- a/typer_bot/commands/admin_panel/unified.py +++ b/typer_bot/commands/admin_panel/unified.py @@ -32,6 +32,8 @@ EnterResultsButton, JumpToWeekButton, JumpToWeekModal, + NewSeasonButton, + NewSeasonModal, PostResultsButton, PostResultsConfirmView, SetupBotButton, @@ -46,6 +48,8 @@ "EnterResultsButton", "JumpToWeekButton", "JumpToWeekModal", + "NewSeasonButton", + "NewSeasonModal", "PostResultsButton", "PostResultsConfirmView", "SetupBotButton", @@ -78,6 +82,7 @@ def __init__( self.has_user_overflow = False self.has_pending_partials = False self.current_prediction: dict | None = None + self.active_season: dict | None = None self.fixture_select = FixtureSelect(self) self.user_select = PredictionUserSelect(self) self.user_select.update_options([]) @@ -87,40 +92,50 @@ def _refresh_items(self) -> None: self.clear_items() self.add_item(self.fixture_select) self.add_item(self.user_select) - self.add_item(CreateFixtureButton(self)) - self.add_item(FixturesDeleteButton(self, disabled=self.selection.fixture_id is None, row=2)) - self.add_item(SetupBotButton(self)) - self.add_item(JumpToWeekButton(self)) - if self.has_pending_partials: - self.add_item(ReviewPendingPartialsButton(self)) - self.add_item(EnterResultsButton(self)) - self.add_item(CalculateScoresButton(self)) - self.add_item(CorrectResultsButton(self, disabled=self.selection.fixture_id is None, row=3)) - self.add_item(PostResultsButton(self)) + + selected_fixture_is_open = ( + self.selection.fixture_id is not None and self.selection.fixture_status == "open" + ) + self.add_item(CreateFixtureButton(self, row=2)) + if selected_fixture_is_open and not self.selection.has_results: + self.add_item(EnterResultsButton(self, row=2)) + if selected_fixture_is_open: + self.add_item(CalculateScoresButton(self, row=2)) + if self.selection.fixture_id is not None and self.selection.has_results: + self.add_item(CorrectResultsButton(self, row=2)) + self.add_item(PostResultsButton(self, row=2)) + if self.current_prediction and self.current_prediction.get("pending_partial_approval"): - self.add_item(ApprovePartialButton(self)) - self.add_item(RejectPartialButton(self)) - else: + self.add_item(ApprovePartialButton(self, row=3)) + self.add_item(RejectPartialButton(self, row=3)) + elif self.selection.fixture_id is not None and self.selection.user_id is not None: self.add_item( ReplacePredictionButton( self, - disabled=self.selection.fixture_id is None or self.selection.user_id is None, - row=4, + row=3, ) ) self.add_item( ToggleWaiverButton( self, - disabled=self.selection.fixture_id is None or self.selection.user_id is None, - row=4, + row=3, ) ) if self.has_user_overflow: self.add_item( - ViewPredictionsButton(self, disabled=self.selection.fixture_id is None, row=4) + ViewPredictionsButton(self, disabled=self.selection.fixture_id is None, row=3) ) + if selected_fixture_is_open: + self.add_item(FixturesDeleteButton(self, row=3)) + if self.has_pending_partials: + self.add_item(ReviewPendingPartialsButton(self, row=3)) + + self.add_item(JumpToWeekButton(self, row=4)) + self.add_item(NewSeasonButton(self, row=4)) + self.add_item(SetupBotButton(self, row=4)) async def load_fixture_options(self) -> None: + self.active_season = await self.db.get_or_create_active_season(self.guild_id) fixtures = await self.db.get_recent_fixtures(self.guild_id, MAX_SELECT_OPTIONS) self.fixture_select.update_options(fixtures) self.has_pending_partials = bool( @@ -153,15 +168,19 @@ async def set_selected_prediction(self) -> None: async def populate_fixture_details(self, fixture: dict | None) -> None: self.selection.detail_lines = [] + self.selection.has_results = False if fixture is None: return results = await self.db.get_results(fixture["id"]) if results: + self.selection.has_results = True self.selection.detail_lines = _build_detail_lines(fixture["games"], results) def render_content(self) -> str: lines = ["**Admin Panel**"] + if self.active_season is not None: + lines.append(f"Active season: {self.active_season['name']}") if self.selection.fixture_label: header = f"Fixture: {self.selection.fixture_label}" if self.selection.user_label: @@ -171,9 +190,6 @@ def render_content(self) -> str: lines.extend(["", self.selection.status_message]) if self.selection.detail_lines: lines.extend(["", *self.selection.detail_lines]) - else: - guidance = "Top row: fixture management. Middle row: results workflow. Bottom row: prediction and late-review actions. Use Jump To Week when the older open week you want is not in the quick list." - lines.extend(["", guidance]) if self.has_user_overflow: lines.extend( @@ -183,9 +199,6 @@ def render_content(self) -> str: ] ) else: - lines.append( - "Use the top row for fixture management, the middle row for results, and the bottom row for prediction and late-review actions." - ) if self.selection.status_message: lines.extend(["", self.selection.status_message]) return _render_panel_content(lines) diff --git a/typer_bot/commands/admin_panel/unified_actions.py b/typer_bot/commands/admin_panel/unified_actions.py index 39dcc3a..b5c9c4d 100644 --- a/typer_bot/commands/admin_panel/unified_actions.py +++ b/typer_bot/commands/admin_panel/unified_actions.py @@ -6,7 +6,7 @@ import discord -from typer_bot.utils import format_standings, has_setup_permission, now +from typer_bot.utils import format_standings, get_admin_permission_error, has_setup_permission, now from .modals import CreateFixtureModal, EnterResultsModal @@ -15,9 +15,9 @@ class SetupBotButton(discord.ui.Button): - def __init__(self, parent_view: UnifiedAdminPanelView): + def __init__(self, parent_view: UnifiedAdminPanelView, row: int | None = None): self.parent_view = parent_view - super().__init__(label="Setup TyperBot", style=discord.ButtonStyle.secondary, row=2) + super().__init__(label="Setup TyperBot", style=discord.ButtonStyle.secondary, row=row) async def callback(self, interaction: discord.Interaction): from typer_bot.commands.admin_commands import GuildSetupPromptView @@ -36,9 +36,9 @@ async def callback(self, interaction: discord.Interaction): class CreateFixtureButton(discord.ui.Button): - def __init__(self, parent_view: UnifiedAdminPanelView): + def __init__(self, parent_view: UnifiedAdminPanelView, row: int | None = None): self.parent_view = parent_view - super().__init__(label="Create Fixture", style=discord.ButtonStyle.success, row=2) + super().__init__(label="Create Fixture", style=discord.ButtonStyle.success, row=row) async def callback(self, interaction: discord.Interaction): channel = interaction.channel @@ -70,6 +70,70 @@ async def callback(self, interaction: discord.Interaction): await interaction.response.send_modal(modal) +class NewSeasonModal(discord.ui.Modal): + def __init__(self, parent_view: UnifiedAdminPanelView): + super().__init__(title="Start New Season") + self.parent_view = parent_view + self.name_input = discord.ui.TextInput( + label="Season Name", + placeholder="e.g. 2026/27", + required=True, + max_length=80, + ) + self.add_item(self.name_input) + + async def on_submit(self, interaction: discord.Interaction): + if str(interaction.user.id) != self.parent_view.owner_user_id: + await interaction.response.send_message( + "You don't have permission to do this!", ephemeral=True + ) + return + permission_error = await get_admin_permission_error(interaction, self.parent_view.db) + if permission_error is not None: + await interaction.response.send_message(permission_error, ephemeral=True) + return + if interaction.guild_id is None: + await interaction.response.send_message( + "Season management must be used in a server.", ephemeral=True + ) + return + + try: + season = await self.parent_view.db.start_new_season( + str(interaction.guild_id), + self.name_input.value, + ) + except ValueError as exc: + await interaction.response.send_message(str(exc), ephemeral=True) + return + + self.parent_view.selection.fixture_id = None + self.parent_view.selection.fixture_label = "" + self.parent_view.selection.fixture_status = None + self.parent_view.selection.has_results = False + self.parent_view.selection.user_id = None + self.parent_view.selection.user_label = "" + self.parent_view.selection.detail_lines = [] + self.parent_view.selection.status_message = f"Started new active season: {season['name']}" + self.parent_view.current_prediction = None + self.parent_view.has_user_overflow = False + self.parent_view.user_select.update_options([]) + await self.parent_view.load_fixture_options() + await interaction.response.edit_message( + content=self.parent_view.render_content(), + view=self.parent_view, + ) + + +class NewSeasonButton(discord.ui.Button): + def __init__(self, parent_view: UnifiedAdminPanelView, row: int | None = None): + self.parent_view = parent_view + super().__init__(label="New Season", style=discord.ButtonStyle.secondary, row=row) + + async def callback(self, interaction: discord.Interaction): + await interaction.response.send_modal(NewSeasonModal(self.parent_view)) + + class JumpToWeekModal(discord.ui.Modal): def __init__(self, parent_view: UnifiedAdminPanelView): super().__init__(title="Jump To Week") @@ -110,9 +174,11 @@ async def on_submit(self, interaction: discord.Interaction): self.parent_view.selection.fixture_label = ( f"Week {fixture['week_number']} [{fixture['status'].upper()}]" ) + self.parent_view.selection.fixture_status = fixture["status"] self.parent_view.selection.user_id = None self.parent_view.selection.user_label = "" self.parent_view.selection.detail_lines = [] + self.parent_view.selection.has_results = False self.parent_view.selection.status_message = "" await self.parent_view.populate_fixture_details(fixture) await self.parent_view.load_user_options() @@ -125,22 +191,22 @@ async def on_submit(self, interaction: discord.Interaction): class JumpToWeekButton(discord.ui.Button): - def __init__(self, parent_view: UnifiedAdminPanelView): + def __init__(self, parent_view: UnifiedAdminPanelView, row: int | None = None): self.parent_view = parent_view - super().__init__(label="Jump To Week", style=discord.ButtonStyle.secondary, row=2) + super().__init__(label="Jump To Week", style=discord.ButtonStyle.secondary, row=row) async def callback(self, interaction: discord.Interaction): await interaction.response.send_modal(JumpToWeekModal(self.parent_view)) class EnterResultsButton(discord.ui.Button): - def __init__(self, parent_view: UnifiedAdminPanelView): + def __init__(self, parent_view: UnifiedAdminPanelView, row: int | None = None): self.parent_view = parent_view super().__init__( label="Enter Results", style=discord.ButtonStyle.secondary, disabled=parent_view.selection.fixture_id is None, - row=3, + row=row, ) async def callback(self, interaction: discord.Interaction): @@ -165,13 +231,13 @@ async def callback(self, interaction: discord.Interaction): class CalculateScoresButton(discord.ui.Button): - def __init__(self, parent_view: UnifiedAdminPanelView): + def __init__(self, parent_view: UnifiedAdminPanelView, row: int | None = None): self.parent_view = parent_view super().__init__( label="Calculate Scores", style=discord.ButtonStyle.secondary, disabled=parent_view.selection.fixture_id is None, - row=3, + row=row, ) async def callback(self, interaction: discord.Interaction): @@ -265,9 +331,9 @@ async def with_mentions(self, interaction: discord.Interaction, _button: discord class PostResultsButton(discord.ui.Button): - def __init__(self, parent_view: UnifiedAdminPanelView): + def __init__(self, parent_view: UnifiedAdminPanelView, row: int | None = None): self.parent_view = parent_view - super().__init__(label="Re-post Results", style=discord.ButtonStyle.secondary, row=3) + super().__init__(label="Re-post Results", style=discord.ButtonStyle.secondary, row=row) async def callback(self, interaction: discord.Interaction): fixture_data = await self.parent_view.db.get_last_fixture_scores(self.parent_view.guild_id) diff --git a/typer_bot/database/connection.py b/typer_bot/database/connection.py index c21ff33..57570d7 100644 --- a/typer_bot/database/connection.py +++ b/typer_bot/database/connection.py @@ -309,6 +309,9 @@ async def get_or_create_active_season(self, guild_id): async def get_seasons(self, guild_id): return await self._seasons.get_seasons(guild_id) + async def start_new_season(self, guild_id, name): + return await self._seasons.start_new_season(guild_id, name) + async def create_fixture(self, guild_id, week_number, games, deadline): return await self._fixtures.create_fixture(guild_id, week_number, games, deadline) diff --git a/typer_bot/database/seasons.py b/typer_bot/database/seasons.py index 3c78a91..16152bd 100644 --- a/typer_bot/database/seasons.py +++ b/typer_bot/database/seasons.py @@ -4,6 +4,7 @@ DEFAULT_SEASON_NAME = "Default Season" ACTIVE_SEASON_STATUS = "active" +ARCHIVED_SEASON_STATUS = "archived" def _validate_guild_id(guild_id: str) -> None: @@ -156,3 +157,64 @@ async def get_seasons(self, guild_id: str) -> list[dict]: ) as cursor: rows = await cursor.fetchall() return [_row_to_season(row) for row in rows] + + async def start_new_season(self, guild_id: str, name: str) -> dict: + """Archive the current active season and create a new active season.""" + _validate_guild_id(guild_id) + season_name = name.strip() + if not season_name: + raise ValueError("Season name is required.") + + async with aiosqlite.connect(self.db_path) as db: + await db.execute("BEGIN IMMEDIATE") + try: + db.row_factory = aiosqlite.Row + active_season = await _get_active_season_in_connection(db, guild_id) + if active_season is not None: + async with db.execute( + "SELECT COUNT(*) FROM fixtures WHERE guild_id = ? AND season_id = ? AND status = 'open'", + (guild_id, active_season["id"]), + ) as cursor: + row = await cursor.fetchone() + open_count = int(row[0]) if row else 0 + if open_count: + message = "Close all open fixtures before starting a new season." + raise ValueError(message) + + await db.execute( + """ + UPDATE seasons + SET status = ?, ended_at = CURRENT_TIMESTAMP + WHERE id = ? AND guild_id = ? AND status = ? + """, + ( + ARCHIVED_SEASON_STATUS, + active_season["id"], + guild_id, + ACTIVE_SEASON_STATUS, + ), + ) + + cursor = await db.execute( + "INSERT INTO seasons (guild_id, name, status) VALUES (?, ?, ?)", + (guild_id, season_name, ACTIVE_SEASON_STATUS), + ) + if cursor.lastrowid is None: + raise RuntimeError("Failed to create season: lastrowid is None") + season_id = cursor.lastrowid + + await db.execute( + "UPDATE guild_config SET active_season_id = ?, updated_at = CURRENT_TIMESTAMP WHERE guild_id = ?", + (season_id, guild_id), + ) + + async with db.execute("SELECT * FROM seasons WHERE id = ?", (season_id,)) as cursor: + row = await cursor.fetchone() + if row is None: + raise RuntimeError("Created season disappeared") + + await db.commit() + return _row_to_season(row) + except Exception: + await db.rollback() + raise