From 70ef1dc3b69381260b787705d3ac88ac50cbfe7d Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 10 May 2026 18:54:48 +0200 Subject: [PATCH 1/3] feat: add admin scoring rule editor --- tests/test_admin_panel_fixtures.py | 260 ++++++++++++++++++ typer_bot/commands/admin_panel/__init__.py | 3 +- typer_bot/commands/admin_panel/unified.py | 16 ++ .../commands/admin_panel/unified_actions.py | 97 +++++++ 4 files changed, 375 insertions(+), 1 deletion(-) diff --git a/tests/test_admin_panel_fixtures.py b/tests/test_admin_panel_fixtures.py index c26be6b..fedc4c9 100644 --- a/tests/test_admin_panel_fixtures.py +++ b/tests/test_admin_panel_fixtures.py @@ -7,6 +7,7 @@ from tests.admin_panel_helpers import get_button as _get_button from tests.admin_panel_helpers import has_button as _has_button from tests.admin_panel_helpers import option_values as _option_values +from tests.conftest import MockInteraction, MockUser from typer_bot.commands.admin_commands import AdminCommands from typer_bot.commands.admin_panel import ( CreateFixtureModal, @@ -17,6 +18,7 @@ PostResultsConfirmView, PredictionsPanelView, ResultsPanelView, + ScoringRulesModal, UnifiedAdminPanelView, ) from typer_bot.commands.admin_panel.fixtures import _cleanup_discord_announcement @@ -190,8 +192,266 @@ async def test_unified_panel_shows_active_season_and_new_season_button( await view.load_fixture_options() assert "Active season: Default Season" in view.render_content() + assert "Scoring: exact 3, outcome 1, wrong 0, late 0" in view.render_content() + assert _has_button(view, "Scoring Rules") is True assert _has_button(view, "New Season") is True + @pytest.mark.asyncio + async def test_unified_panel_scoring_rules_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, + ) + await view.load_fixture_options() + scoring_button = _get_button(view, "Scoring Rules") + + await scoring_button.callback(mock_interaction_admin) + + modal = mock_interaction_admin.modal_sent["modal"] + assert isinstance(modal, ScoringRulesModal) + assert modal.exact_input.default == "3" + assert modal.outcome_input.default == "1" + assert modal.wrong_input.default == "0" + assert modal.late_input.default == "0" + + @pytest.mark.asyncio + async def test_scoring_rules_modal_updates_active_season_rules( + 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() + modal = ScoringRulesModal(view) + modal.exact_input._value = "5" + modal.outcome_input._value = "2" + modal.wrong_input._value = "1" + modal.late_input._value = "1" + + await modal.on_submit(mock_interaction_admin) + + assert await admin_cog.db.get_active_scoring_rules("111111") == { + "exact_score_points": 5, + "correct_outcome_points": 2, + "wrong_outcome_points": 1, + "late_prediction_points": 1, + } + content = mock_interaction_admin.response_sent[-1]["content"] + assert "Updated active-season scoring rules." in content + assert "Scoring: exact 5, outcome 2, wrong 1, late 1" in content + + @pytest.mark.asyncio + async def test_scoring_rules_button_refreshes_modal_defaults( + 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() + await admin_cog.db.update_active_scoring_rules("111111", {"exact_score_points": 5}) + + scoring_button = _get_button(view, "Scoring Rules") + await scoring_button.callback(mock_interaction_admin) + + modal = mock_interaction_admin.modal_sent["modal"] + assert modal.exact_input.default == "5" + + @pytest.mark.asyncio + async def test_scoring_rules_modal_rejects_stale_season_submit( + 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() + old_season_rules = view.active_season["scoring_rules"] + modal = ScoringRulesModal(view) + modal.exact_input._value = "5" + modal.outcome_input._value = "2" + modal.wrong_input._value = "1" + modal.late_input._value = "1" + await admin_cog.db.start_new_season("111111", "Next Season") + + await modal.on_submit(mock_interaction_admin) + + assert "active season changed" in mock_interaction_admin.response_sent[-1]["content"] + assert await admin_cog.db.get_active_scoring_rules("111111") == { + "exact_score_points": 3, + "correct_outcome_points": 1, + "wrong_outcome_points": 0, + "late_prediction_points": 0, + } + seasons = await admin_cog.db.get_seasons("111111") + assert seasons[0]["status"] == "archived" + assert seasons[0]["scoring_rules"] == old_season_rules + + @pytest.mark.asyncio + async def test_scoring_rules_modal_rejects_non_owner( + 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() + modal = ScoringRulesModal(view) + modal.exact_input._value = "5" + modal.outcome_input._value = "2" + modal.wrong_input._value = "1" + modal.late_input._value = "1" + outsider = MockInteraction( + user=MockUser(user_id="999999", name="Outsider"), + guild=mock_interaction_admin.guild, + channel=mock_interaction_admin.channel, + ) + + await modal.on_submit(outsider) + + assert "permission" in outsider.response_sent[-1]["content"] + assert await admin_cog.db.get_active_scoring_rules("111111") == { + "exact_score_points": 3, + "correct_outcome_points": 1, + "wrong_outcome_points": 0, + "late_prediction_points": 0, + } + + @pytest.mark.asyncio + async def test_scoring_rules_modal_rechecks_admin_permission( + 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() + modal = ScoringRulesModal(view) + modal.exact_input._value = "5" + modal.outcome_input._value = "2" + modal.wrong_input._value = "1" + modal.late_input._value = "1" + member = mock_interaction_admin.guild.get_member(mock_interaction_admin.user.id) + member.roles = [] + + await modal.on_submit(mock_interaction_admin) + + assert "no longer have permission" in mock_interaction_admin.response_sent[-1]["content"] + assert await admin_cog.db.get_active_scoring_rules("111111") == { + "exact_score_points": 3, + "correct_outcome_points": 1, + "wrong_outcome_points": 0, + "late_prediction_points": 0, + } + + @pytest.mark.asyncio + async def test_scoring_rules_modal_blocks_changes_after_scores_exist( + 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_results(fixture_id, ["2-1", "1-1", "0-0"]) + await admin_cog.db.save_prediction( + fixture_id, + "user-1", + "User One", + ["2-1", "1-1", "0-0"], + False, + ) + await admin_cog.db.recalculate_fixture_scores(fixture_id) + 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() + modal = ScoringRulesModal(view) + modal.exact_input._value = "5" + modal.outcome_input._value = "2" + modal.wrong_input._value = "1" + modal.late_input._value = "1" + + await modal.on_submit(mock_interaction_admin) + + assert "Cannot change scoring rules" in mock_interaction_admin.response_sent[-1]["content"] + assert await admin_cog.db.get_active_scoring_rules("111111") == { + "exact_score_points": 3, + "correct_outcome_points": 1, + "wrong_outcome_points": 0, + "late_prediction_points": 0, + } + + @pytest.mark.asyncio + async def test_scoring_rules_modal_rejects_invalid_values( + 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() + modal = ScoringRulesModal(view) + modal.exact_input._value = "many" + modal.outcome_input._value = "2" + modal.wrong_input._value = "1" + modal.late_input._value = "1" + + await modal.on_submit(mock_interaction_admin) + + assert "whole numbers" in mock_interaction_admin.response_sent[-1]["content"] + @pytest.mark.asyncio async def test_unified_panel_hides_contextual_actions_until_fixture_selection( self, diff --git a/typer_bot/commands/admin_panel/__init__.py b/typer_bot/commands/admin_panel/__init__.py index 3c3b128..7986e60 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 NewSeasonModal, PostResultsConfirmView +from .unified_actions import NewSeasonModal, PostResultsConfirmView, ScoringRulesModal __all__ = [ "CorrectResultsModal", @@ -27,6 +27,7 @@ "ResultsPanelView", "PostResultsConfirmView", "NewSeasonModal", + "ScoringRulesModal", "UnifiedAdminPanelView", "_build_delete_confirmation_content", ] diff --git a/typer_bot/commands/admin_panel/unified.py b/typer_bot/commands/admin_panel/unified.py index 14cac09..a1bd34d 100644 --- a/typer_bot/commands/admin_panel/unified.py +++ b/typer_bot/commands/admin_panel/unified.py @@ -36,6 +36,8 @@ NewSeasonModal, PostResultsButton, PostResultsConfirmView, + ScoringRulesButton, + ScoringRulesModal, SetupBotButton, ) @@ -52,11 +54,23 @@ "NewSeasonModal", "PostResultsButton", "PostResultsConfirmView", + "ScoringRulesButton", + "ScoringRulesModal", "SetupBotButton", "UnifiedAdminPanelView", ] +def _format_scoring_rules(rules: dict) -> str: + return ( + "Scoring: " + f"exact {rules['exact_score_points']}, " + f"outcome {rules['correct_outcome_points']}, " + f"wrong {rules['wrong_outcome_points']}, " + f"late {rules['late_prediction_points']}" + ) + + class UnifiedAdminPanelView(OwnerRestrictedView): """Single admin panel containing fixture, prediction, and results actions. @@ -131,6 +145,7 @@ def _refresh_items(self) -> None: self.add_item(ReviewPendingPartialsButton(self, row=3)) self.add_item(JumpToWeekButton(self, row=4)) + self.add_item(ScoringRulesButton(self, row=4)) self.add_item(NewSeasonButton(self, row=4)) self.add_item(SetupBotButton(self, row=4)) @@ -181,6 +196,7 @@ def render_content(self) -> str: lines = ["**Admin Panel**"] if self.active_season is not None: lines.append(f"Active season: {self.active_season['name']}") + lines.append(_format_scoring_rules(self.active_season["scoring_rules"])) if self.selection.fixture_label: header = f"Fixture: {self.selection.fixture_label}" if self.selection.user_label: diff --git a/typer_bot/commands/admin_panel/unified_actions.py b/typer_bot/commands/admin_panel/unified_actions.py index b5c9c4d..f5f578c 100644 --- a/typer_bot/commands/admin_panel/unified_actions.py +++ b/typer_bot/commands/admin_panel/unified_actions.py @@ -134,6 +134,103 @@ async def callback(self, interaction: discord.Interaction): await interaction.response.send_modal(NewSeasonModal(self.parent_view)) +class ScoringRulesModal(discord.ui.Modal): + def __init__(self, parent_view: UnifiedAdminPanelView): + super().__init__(title="Scoring Rules") + self.parent_view = parent_view + active_season = parent_view.active_season or {} + self.season_id = active_season.get("id") + rules = active_season.get("scoring_rules", {}) + self.exact_input = discord.ui.TextInput( + label="Exact Score Points", + default=str(rules.get("exact_score_points", 3)), + required=True, + max_length=4, + ) + self.outcome_input = discord.ui.TextInput( + label="Correct Outcome Points", + default=str(rules.get("correct_outcome_points", 1)), + required=True, + max_length=4, + ) + self.wrong_input = discord.ui.TextInput( + label="Wrong Outcome Points", + default=str(rules.get("wrong_outcome_points", 0)), + required=True, + max_length=4, + ) + self.late_input = discord.ui.TextInput( + label="Late Full Prediction Points", + default=str(rules.get("late_prediction_points", 0)), + required=True, + max_length=4, + ) + self.add_item(self.exact_input) + self.add_item(self.outcome_input) + self.add_item(self.wrong_input) + self.add_item(self.late_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( + "Scoring rules must be managed in a server.", ephemeral=True + ) + return + + active_season = await self.parent_view.db.get_or_create_active_season( + str(interaction.guild_id) + ) + if active_season["id"] != self.season_id: + await interaction.response.send_message( + "The active season changed. Reopen Scoring Rules and try again.", + ephemeral=True, + ) + return + + try: + rules = await self.parent_view.db.update_active_scoring_rules( + str(interaction.guild_id), + { + "exact_score_points": self.exact_input.value, + "correct_outcome_points": self.outcome_input.value, + "wrong_outcome_points": self.wrong_input.value, + "late_prediction_points": self.late_input.value, + }, + ) + except ValueError as exc: + await interaction.response.send_message(str(exc), ephemeral=True) + return + + self.parent_view.active_season = active_season | {"scoring_rules": rules} + self.parent_view.selection.status_message = "Updated active-season scoring rules." + await self.parent_view.load_fixture_options() + await interaction.response.edit_message( + content=self.parent_view.render_content(), + view=self.parent_view, + ) + + +class ScoringRulesButton(discord.ui.Button): + def __init__(self, parent_view: UnifiedAdminPanelView, row: int | None = None): + self.parent_view = parent_view + super().__init__(label="Scoring Rules", style=discord.ButtonStyle.secondary, row=row) + + async def callback(self, interaction: discord.Interaction): + self.parent_view.active_season = await self.parent_view.db.get_or_create_active_season( + self.parent_view.guild_id + ) + await interaction.response.send_modal(ScoringRulesModal(self.parent_view)) + + class JumpToWeekModal(discord.ui.Modal): def __init__(self, parent_view: UnifiedAdminPanelView): super().__init__(title="Jump To Week") From e41949ecc397f09647726bf5fcbde39295943a4f Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 10 May 2026 19:02:52 +0200 Subject: [PATCH 2/3] refactor: hide scoring editor after scoring starts --- tests/test_admin_panel_fixtures.py | 2 ++ tests/test_database.py | 3 +++ typer_bot/commands/admin_panel/unified.py | 5 ++++- typer_bot/database/connection.py | 3 +++ typer_bot/database/seasons.py | 20 ++++++++++++++++++++ 5 files changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/test_admin_panel_fixtures.py b/tests/test_admin_panel_fixtures.py index fedc4c9..de1696c 100644 --- a/tests/test_admin_panel_fixtures.py +++ b/tests/test_admin_panel_fixtures.py @@ -411,6 +411,8 @@ async def test_scoring_rules_modal_blocks_changes_after_scores_exist( bot=admin_cog.bot, ) await view.load_fixture_options() + assert _has_button(view, "Scoring Rules") is False + modal = ScoringRulesModal(view) modal.exact_input._value = "5" modal.outcome_input._value = "2" diff --git a/tests/test_database.py b/tests/test_database.py index 96d4b0c..11cf9be 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -807,11 +807,14 @@ async def test_scoring_rules_are_guild_isolated(self, temp_db_path): async def test_scoring_rule_changes_are_blocked_after_scores_exist(self, temp_db_path): db = Database(temp_db_path) await db.initialize() + assert await db.active_season_has_scores("111111") is False fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) await db.save_results(fixture_id, ["2-1"]) await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) await db.recalculate_fixture_scores(fixture_id) + assert await db.active_season_has_scores("111111") is True + with pytest.raises(ValueError, match="Cannot change scoring rules"): await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) diff --git a/typer_bot/commands/admin_panel/unified.py b/typer_bot/commands/admin_panel/unified.py index a1bd34d..ddb8fc0 100644 --- a/typer_bot/commands/admin_panel/unified.py +++ b/typer_bot/commands/admin_panel/unified.py @@ -95,6 +95,7 @@ def __init__( self.selection = PanelSelectionState() self.has_user_overflow = False self.has_pending_partials = False + self.active_season_has_scores = False self.current_prediction: dict | None = None self.active_season: dict | None = None self.fixture_select = FixtureSelect(self) @@ -145,12 +146,14 @@ def _refresh_items(self) -> None: self.add_item(ReviewPendingPartialsButton(self, row=3)) self.add_item(JumpToWeekButton(self, row=4)) - self.add_item(ScoringRulesButton(self, row=4)) + if not self.active_season_has_scores: + self.add_item(ScoringRulesButton(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) + self.active_season_has_scores = await self.db.active_season_has_scores(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( diff --git a/typer_bot/database/connection.py b/typer_bot/database/connection.py index 09af743..77f328d 100644 --- a/typer_bot/database/connection.py +++ b/typer_bot/database/connection.py @@ -331,6 +331,9 @@ async def get_seasons(self, guild_id): async def get_active_scoring_rules(self, guild_id): return await self._seasons.get_active_scoring_rules(guild_id) + async def active_season_has_scores(self, guild_id): + return await self._seasons.active_season_has_scores(guild_id) + async def update_active_scoring_rules(self, guild_id, rules): return await self._seasons.update_active_scoring_rules(guild_id, rules) diff --git a/typer_bot/database/seasons.py b/typer_bot/database/seasons.py index 0c903c0..0203001 100644 --- a/typer_bot/database/seasons.py +++ b/typer_bot/database/seasons.py @@ -205,6 +205,26 @@ async def get_active_scoring_rules(self, guild_id: str) -> dict | None: season = await self.get_active_season(guild_id) return season["scoring_rules"] if season else None + async def active_season_has_scores(self, guild_id: str) -> bool: + """Return whether the active season has calculated scores.""" + _validate_guild_id(guild_id) + async with aiosqlite.connect(self.db_path) as db: + db.row_factory = aiosqlite.Row + active_season = await _get_active_season_in_connection(db, guild_id) + if active_season is None: + return False + async with db.execute( + """ + SELECT 1 + FROM scores score + JOIN fixtures fixture ON fixture.id = score.fixture_id + WHERE fixture.guild_id = ? AND fixture.season_id = ? + LIMIT 1 + """, + (guild_id, active_season["id"]), + ) as cursor: + return await cursor.fetchone() is not None + async def update_active_scoring_rules(self, guild_id: str, rules: dict) -> dict: """Update active-season scoring rules unless stored scores already exist.""" _validate_guild_id(guild_id) From 4a64b1996109eb26a8f413fb6e4e5ea2fbb2fb70 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 10 May 2026 19:15:17 +0200 Subject: [PATCH 3/3] fix: refresh admin panel after scoring races --- tests/test_admin_panel_fixtures.py | 100 +++++++++++++++++- .../commands/admin_panel/unified_actions.py | 37 +++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/tests/test_admin_panel_fixtures.py b/tests/test_admin_panel_fixtures.py index de1696c..586b061 100644 --- a/tests/test_admin_panel_fixtures.py +++ b/tests/test_admin_panel_fixtures.py @@ -401,7 +401,6 @@ async def test_scoring_rules_modal_blocks_changes_after_scores_exist( ["2-1", "1-1", "0-0"], False, ) - await admin_cog.db.recalculate_fixture_scores(fixture_id) view = UnifiedAdminPanelView( admin_cog.db, admin_cog.service, @@ -411,13 +410,14 @@ async def test_scoring_rules_modal_blocks_changes_after_scores_exist( bot=admin_cog.bot, ) await view.load_fixture_options() - assert _has_button(view, "Scoring Rules") is False + assert _has_button(view, "Scoring Rules") is True modal = ScoringRulesModal(view) modal.exact_input._value = "5" modal.outcome_input._value = "2" modal.wrong_input._value = "1" modal.late_input._value = "1" + await admin_cog.db.recalculate_fixture_scores(fixture_id) await modal.on_submit(mock_interaction_admin) @@ -916,6 +916,8 @@ async def test_unified_panel_calculate_scores_button_posts_results( channel = MagicMock(spec=discord.TextChannel) channel.send = AsyncMock() mock_interaction_admin.channel = channel + mock_interaction_admin.message = MagicMock() + mock_interaction_admin.message.edit = AsyncMock() admin_cog._create_backup = AsyncMock() view = UnifiedAdminPanelView( @@ -943,6 +945,100 @@ async def test_unified_panel_calculate_scores_button_posts_results( in mock_interaction_admin.response_sent[-1]["content"] ) assert "User One" in channel.send.call_args.args[0] + assert view.selection.fixture_label == "Week 45 [CLOSED]" + assert _has_button(view, "Scoring Rules") is False + assert _has_button(view, "Calculate Scores") is False + assert _has_button(view, "Delete Fixture") is False + mock_interaction_admin.message.edit.assert_awaited_once_with( + content=view.render_content(), view=view + ) + + @pytest.mark.asyncio + async def test_stale_calculate_scores_button_refreshes_when_fixture_already_scored( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 45, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_results(fixture_id, ["2-1", "1-1", "0-2"]) + await admin_cog.db.save_prediction( + fixture_id, + "111", + "User One", + ["2-1", "1-1", "0-2"], + False, + ) + mock_interaction_admin.message = MagicMock() + mock_interaction_admin.message.edit = AsyncMock() + admin_cog._create_backup = AsyncMock() + admin_cog._post_calculation_to_channel = AsyncMock() + 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) + stale_button = _get_button(view, "Calculate Scores") + await admin_cog.db.recalculate_fixture_scores(fixture_id) + + await stale_button.callback(mock_interaction_admin) + + assert ( + mock_interaction_admin.response_sent[-1]["content"] == "That fixture is no longer open." + ) + admin_cog._create_backup.assert_not_awaited() + admin_cog._post_calculation_to_channel.assert_not_awaited() + assert view.selection.fixture_label == "Week 45 [CLOSED]" + assert _has_button(view, "Scoring Rules") is False + assert _has_button(view, "Calculate Scores") is False + assert _has_button(view, "Delete Fixture") is False + mock_interaction_admin.message.edit.assert_awaited_once_with( + content=view.render_content(), view=view + ) + + @pytest.mark.asyncio + async def test_stale_scoring_rules_button_refreshes_when_scores_now_exist( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 45, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_results(fixture_id, ["2-1", "1-1", "0-2"]) + await admin_cog.db.save_prediction( + fixture_id, + "111", + "User One", + ["2-1", "1-1", "0-2"], + False, + ) + 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() + stale_button = _get_button(view, "Scoring Rules") + await admin_cog.db.recalculate_fixture_scores(fixture_id) + + await stale_button.callback(mock_interaction_admin) + + assert not hasattr(mock_interaction_admin, "modal_sent") + assert "Scoring rules are locked" in mock_interaction_admin.response_sent[-1]["content"] + assert _has_button(view, "Scoring Rules") is False @pytest.mark.asyncio async def test_unified_panel_calculate_scores_button_rejects_active_cooldown( diff --git a/typer_bot/commands/admin_panel/unified_actions.py b/typer_bot/commands/admin_panel/unified_actions.py index f5f578c..bb23352 100644 --- a/typer_bot/commands/admin_panel/unified_actions.py +++ b/typer_bot/commands/admin_panel/unified_actions.py @@ -228,6 +228,19 @@ async def callback(self, interaction: discord.Interaction): self.parent_view.active_season = await self.parent_view.db.get_or_create_active_season( self.parent_view.guild_id ) + self.parent_view.active_season_has_scores = ( + await self.parent_view.db.active_season_has_scores(self.parent_view.guild_id) + ) + if self.parent_view.active_season_has_scores: + self.parent_view.selection.status_message = ( + "Scoring rules are locked because scores have been calculated for this season." + ) + await self.parent_view.load_fixture_options() + await interaction.response.edit_message( + content=self.parent_view.render_content(), + view=self.parent_view, + ) + return await interaction.response.send_modal(ScoringRulesModal(self.parent_view)) @@ -344,9 +357,11 @@ async def callback(self, interaction: discord.Interaction): return fixture = await self.parent_view.db.get_fixture_by_id(fixture_id, self.parent_view.guild_id) if fixture is None or fixture["status"] != "open": + await self._refresh_parent_panel(fixture_id) await interaction.response.send_message( "That fixture is no longer open.", ephemeral=True ) + await self._edit_parent_message(interaction) return admin_commands = self.parent_view.admin_commands @@ -382,6 +397,28 @@ async def callback(self, interaction: discord.Interaction): ) await admin_commands._create_backup() await admin_commands._post_calculation_to_channel(interaction, score_result) + await self._refresh_parent_panel(fixture_id) + await self._edit_parent_message(interaction) + + async def _edit_parent_message(self, interaction: discord.Interaction) -> None: + message = getattr(interaction, "message", None) + edit_message = getattr(message, "edit", None) + if callable(edit_message): + await edit_message(content=self.parent_view.render_content(), view=self.parent_view) + + async def _refresh_parent_panel(self, fixture_id: int) -> None: + fixture = await self.parent_view.db.get_fixture_by_id(fixture_id, self.parent_view.guild_id) + if fixture is not None: + self.parent_view.selection.fixture_status = fixture["status"] + self.parent_view.selection.fixture_label = ( + f"Week {fixture['week_number']} [{fixture['status'].upper()}]" + ) + await self.parent_view.populate_fixture_details(fixture) + await self.parent_view.load_user_options() + await self.parent_view.set_selected_prediction() + await self.parent_view.load_fixture_options() + self.parent_view.fixture_select.sync_selected_option() + self.parent_view._refresh_items() class PostResultsConfirmView(discord.ui.View):