Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Ask the repo owner for the invite link. The bot needs:
After inviting TyperBot, run `/admin panel`. First-time setup requires Discord `Administrator` or `Manage Server` permission and stores:

- admin role: who can use league admin actions
- league channel: where fixture announcements, threads, and reminders go
- league channel: where fixture announcements, threads, reminders, and public result/standings posts go

After setup, members with the configured admin role use `/admin panel` to create fixtures, enter results, calculate scores, review late partial predictions, edit scoring rules, and start new seasons.

Expand Down
101 changes: 89 additions & 12 deletions tests/test_admin_panel_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -913,9 +913,15 @@ async def test_unified_panel_calculate_scores_button_posts_results(
["2-1", "1-1", "0-2"],
False,
)
channel = MagicMock(spec=discord.TextChannel)
channel.send = AsyncMock()
mock_interaction_admin.channel = channel
command_channel = MagicMock(spec=discord.TextChannel)
command_channel.id = 999999
command_channel.send = AsyncMock()
league_channel = MagicMock(spec=discord.TextChannel)
league_channel.id = 123456
league_channel.send = AsyncMock()
mock_interaction_admin.channel = command_channel
admin_cog.bot.get_channel.return_value = None
admin_cog.bot.fetch_channel = AsyncMock(return_value=league_channel)
mock_interaction_admin.message = MagicMock()
mock_interaction_admin.message.edit = AsyncMock()
admin_cog._create_backup = AsyncMock()
Expand All @@ -935,16 +941,19 @@ async def test_unified_panel_calculate_scores_button_posts_results(
calculate_button = _get_button(view, "Calculate Scores")
await calculate_button.callback(mock_interaction_admin)

admin_cog.bot.get_channel.assert_called_with(123456)
admin_cog.bot.fetch_channel.assert_awaited_once_with(123456)
assert (
admin_cog.get_calculate_cooldown("111111", str(mock_interaction_admin.user.id))
is not None
)
channel.send.assert_awaited_once()
league_channel.send.assert_awaited_once()
command_channel.send.assert_not_awaited()
assert (
"Week 45 results calculated and posted"
"Week 45 results calculated and posted to the league channel"
in mock_interaction_admin.response_sent[-1]["content"]
)
assert "User One" in channel.send.call_args.args[0]
assert "User One" in league_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
Expand All @@ -953,6 +962,56 @@ async def test_unified_panel_calculate_scores_button_posts_results(
content=view.render_content(), view=view
)

@pytest.mark.asyncio
async def test_unified_panel_calculate_scores_button_rejects_unavailable_league_channel(
self,
admin_cog,
mock_interaction_admin,
sample_games,
):
fixture_id = await admin_cog.db.create_fixture(
"111111", 46, 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,
)
command_channel = MagicMock(spec=discord.TextChannel)
command_channel.send = AsyncMock()
mock_interaction_admin.channel = command_channel
admin_cog.bot.get_channel.return_value = None
admin_cog.bot.fetch_channel = AsyncMock(
side_effect=discord.InvalidData("unknown channel type")
)
mock_interaction_admin.message = MagicMock()
mock_interaction_admin.message.edit = AsyncMock()
admin_cog._create_backup = 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)

calculate_button = _get_button(view, "Calculate Scores")
await calculate_button.callback(mock_interaction_admin)

command_channel.send.assert_not_awaited()
assert (
"configured league channel is unavailable"
in mock_interaction_admin.response_sent[-1]["content"].lower()
)

@pytest.mark.asyncio
async def test_stale_calculate_scores_button_refreshes_when_fixture_already_scored(
self,
Expand Down Expand Up @@ -1112,9 +1171,14 @@ async def test_unified_panel_post_results_button_opens_confirmation(
admin_cog,
mock_interaction_admin,
):
channel = MagicMock(spec=discord.TextChannel)
channel.id = mock_interaction_admin.channel.id
mock_interaction_admin.channel = channel
command_channel = MagicMock(spec=discord.TextChannel)
command_channel.id = 999999
league_channel = MagicMock(spec=discord.TextChannel)
league_channel.id = 123456
league_channel.send = AsyncMock()
mock_interaction_admin.channel = command_channel
admin_cog.bot.get_channel.return_value = None
admin_cog.bot.fetch_channel = AsyncMock(return_value=league_channel)
admin_cog.db.get_last_fixture_scores = AsyncMock(
return_value={
"week_number": 1,
Expand Down Expand Up @@ -1154,7 +1218,11 @@ async def test_unified_panel_post_results_button_opens_confirmation(
post_button = _get_button(view, "Re-post Results")
await post_button.callback(mock_interaction_admin)

assert isinstance(mock_interaction_admin.response_sent[-1]["view"], PostResultsConfirmView)
admin_cog.bot.get_channel.assert_called_with(123456)
admin_cog.bot.fetch_channel.assert_awaited_once_with(123456)
confirm_view = mock_interaction_admin.response_sent[-1]["view"]
assert isinstance(confirm_view, PostResultsConfirmView)
assert confirm_view.channel is league_channel

@pytest.mark.asyncio
async def test_unified_panel_post_results_only_previews_current_guild_scores(
Expand All @@ -1164,7 +1232,9 @@ async def test_unified_panel_post_results_only_previews_current_guild_scores(
):
channel = MagicMock(spec=discord.TextChannel)
channel.id = mock_interaction_admin.channel.id
channel.send = AsyncMock()
mock_interaction_admin.channel = channel
admin_cog.bot.get_channel.return_value = channel
deadline = datetime.now(UTC) - timedelta(days=1)
current_fixture_id = await admin_cog.db.create_fixture(
"111111", 1, ["Team A - Team B"], deadline
Expand Down Expand Up @@ -1307,13 +1377,17 @@ async def test_unified_panel_jump_to_week_rejects_duplicate_open_weeks(
assert view.selection.fixture_id is None

@pytest.mark.asyncio
async def test_unified_panel_post_results_button_rejects_non_text_channel(
async def test_unified_panel_post_results_button_rejects_unavailable_league_channel(
self,
admin_cog,
mock_interaction_admin,
):
admin_cog.db.get_last_fixture_scores = AsyncMock(return_value={"scores": []})
admin_cog.db.get_standings = AsyncMock(return_value=[])
admin_cog.bot.get_channel.return_value = None
admin_cog.bot.fetch_channel = AsyncMock(
side_effect=discord.InvalidData("unknown channel type")
)

view = UnifiedAdminPanelView(
admin_cog.db,
Expand All @@ -1326,7 +1400,10 @@ async def test_unified_panel_post_results_button_rejects_non_text_channel(
post_button = _get_button(view, "Re-post Results")
await post_button.callback(mock_interaction_admin)

assert "text channels" in mock_interaction_admin.response_sent[-1]["content"]
assert (
"configured league channel is unavailable"
in mock_interaction_admin.response_sent[-1]["content"].lower()
)

@pytest.mark.asyncio
async def test_unified_panel_post_results_button_rejects_missing_scores(
Expand Down
31 changes: 27 additions & 4 deletions typer_bot/commands/admin_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,10 +344,33 @@ async def _post_calculation_to_channel(
interaction: discord.Interaction,
score_result: FixtureScoreResult,
) -> None:
channel = interaction.channel
if not isinstance(channel, (discord.TextChannel, discord.Thread, discord.DMChannel)):
if interaction.guild_id is None:
await interaction.response.send_message(
"Could not find channel to post in.", ephemeral=True
"Scores calculated but could not resolve this server.", ephemeral=True
)
return

config = await self.db.get_guild_config(str(interaction.guild_id))
channel = None
if config is not None:
try:
channel_id = int(config["league_channel_id"])
except (TypeError, ValueError):
channel_id = None
if channel_id is not None:
channel = self.bot.get_channel(channel_id)
if channel is None:
fetch_channel = getattr(self.bot, "fetch_channel", None)
if fetch_channel is not None:
try:
channel = await fetch_channel(channel_id)
except discord.DiscordException:
channel = None

if not isinstance(channel, discord.TextChannel):
await interaction.response.send_message(
"Scores calculated but the configured league channel is unavailable.",
ephemeral=True,
)
return

Expand All @@ -365,7 +388,7 @@ async def _post_calculation_to_channel(
try:
await channel.send(message)
await interaction.response.send_message(
f"Week {score_result.fixture['week_number']} results calculated and posted!",
f"Week {score_result.fixture['week_number']} results calculated and posted to the league channel!",
ephemeral=True,
)
except Exception as exc:
Expand Down
26 changes: 23 additions & 3 deletions typer_bot/commands/admin_panel/unified_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,13 +477,33 @@ async def callback(self, interaction: discord.Interaction):
"No completed fixtures found with scores!", ephemeral=True
)
return
if not isinstance(interaction.channel, discord.TextChannel):

config = await self.parent_view.db.get_guild_config(self.parent_view.guild_id)
channel = None
if config is not None and self.parent_view.bot is not None:
try:
channel_id = int(config["league_channel_id"])
except (TypeError, ValueError):
channel_id = None
if channel_id is not None:
channel = self.parent_view.bot.get_channel(channel_id)
if channel is None:
fetch_channel = getattr(self.parent_view.bot, "fetch_channel", None)
if fetch_channel is not None:
try:
channel = await fetch_channel(channel_id)
except discord.DiscordException:
channel = None

if not isinstance(channel, discord.TextChannel):
await interaction.response.send_message(
"This action can only be used in text channels.", ephemeral=True
"Configured league channel is unavailable. Run `/admin panel` again to update setup.",
ephemeral=True,
)
return

preview = format_standings(standings, fixture_data)
view = PostResultsConfirmView(fixture_data, standings, interaction.channel)
view = PostResultsConfirmView(fixture_data, standings, channel)
await interaction.response.send_message(
f"{preview}\n\nMention users in this post?",
view=view,
Expand Down
Loading