diff --git a/commands/admin/league_management.py b/commands/admin/league_management.py index 248b6d6..04dda16 100644 --- a/commands/admin/league_management.py +++ b/commands/admin/league_management.py @@ -13,6 +13,7 @@ from discord import app_commands from config import get_config from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.permissions import league_admin_only from views.embeds import EmbedColors, EmbedTemplate from services.league_service import league_service from services.transaction_service import transaction_service @@ -40,6 +41,7 @@ class LeagueManagementCommands(commands.Cog): name="admin-freeze-begin", description="[ADMIN] Manually trigger freeze begin (increment week, set freeze)" ) + @league_admin_only() @logged_command("/admin-freeze-begin") async def admin_freeze_begin(self, interaction: discord.Interaction): """Manually trigger the freeze begin process.""" @@ -121,6 +123,7 @@ class LeagueManagementCommands(commands.Cog): name="admin-freeze-end", description="[ADMIN] Manually trigger freeze end (process transactions, unfreeze)" ) + @league_admin_only() @logged_command("/admin-freeze-end") async def admin_freeze_end(self, interaction: discord.Interaction): """Manually trigger the freeze end process.""" @@ -246,6 +249,7 @@ class LeagueManagementCommands(commands.Cog): @app_commands.describe( week="Week number to set (1-24)" ) + @league_admin_only() @logged_command("/admin-set-week") async def admin_set_week(self, interaction: discord.Interaction, week: int): """Manually set the current league week.""" @@ -326,6 +330,7 @@ class LeagueManagementCommands(commands.Cog): app_commands.Choice(name="Freeze (True)", value=1), app_commands.Choice(name="Unfreeze (False)", value=0) ]) + @league_admin_only() @logged_command("/admin-set-freeze") async def admin_set_freeze(self, interaction: discord.Interaction, freeze: int): """Manually toggle the freeze status.""" @@ -417,6 +422,7 @@ class LeagueManagementCommands(commands.Cog): week="Week to process transactions for (defaults to current week)", dry_run="Preview results without making changes (default: False)" ) + @league_admin_only() @logged_command("/admin-process-transactions") async def admin_process_transactions( self, diff --git a/commands/admin/management.py b/commands/admin/management.py index f4defc8..a159680 100644 --- a/commands/admin/management.py +++ b/commands/admin/management.py @@ -13,6 +13,7 @@ from config import get_config from utils.logging import get_contextual_logger from utils.decorators import logged_command from utils.discord_helpers import set_channel_visibility +from utils.permissions import league_admin_only from views.embeds import EmbedColors, EmbedTemplate @@ -45,6 +46,7 @@ class AdminCommands(commands.Cog): name="admin-status", description="Display bot status and system information" ) + @league_admin_only() @logged_command("/admin-status") async def admin_status(self, interaction: discord.Interaction): """Display comprehensive bot status information.""" @@ -99,6 +101,7 @@ class AdminCommands(commands.Cog): name="admin-help", description="Display available admin commands and their usage" ) + @league_admin_only() @logged_command("/admin-help") async def admin_help(self, interaction: discord.Interaction): """Display comprehensive admin help information.""" @@ -157,6 +160,7 @@ class AdminCommands(commands.Cog): @app_commands.describe( cog="Name of the cog to reload (e.g., 'commands.players.info')" ) + @league_admin_only() @logged_command("/admin-reload") async def admin_reload(self, interaction: discord.Interaction, cog: str): """Reload a specific cog for hot-swapping code changes.""" @@ -209,6 +213,7 @@ class AdminCommands(commands.Cog): local="Sync to this guild only (fast, for development)", clear_local="Clear locally synced commands (does not sync after clearing)" ) + @league_admin_only() @logged_command("/admin-sync") async def admin_sync( self, @@ -292,7 +297,7 @@ class AdminCommands(commands.Cog): await interaction.followup.send(embed=embed) @commands.command(name="admin-sync") - @commands.has_permissions(administrator=True) + @league_admin_only() async def admin_sync_prefix(self, ctx: commands.Context): """ Prefix command version of admin-sync for bootstrap scenarios. @@ -346,6 +351,7 @@ class AdminCommands(commands.Cog): @app_commands.describe( count="Number of messages to delete (1-100)" ) + @league_admin_only() @logged_command("/admin-clear") async def admin_clear(self, interaction: discord.Interaction, count: int): """Clear a specified number of messages from the channel.""" @@ -412,6 +418,7 @@ class AdminCommands(commands.Cog): message="Announcement message to send", mention_everyone="Whether to mention @everyone (default: False)" ) + @league_admin_only() @logged_command("/admin-announce") async def admin_announce( self, @@ -455,6 +462,7 @@ class AdminCommands(commands.Cog): app_commands.Choice(name="On", value="on"), app_commands.Choice(name="Off", value="off") ]) + @league_admin_only() @logged_command("/admin-maintenance") async def admin_maintenance(self, interaction: discord.Interaction, mode: str): """Toggle maintenance mode to prevent normal command usage.""" @@ -504,6 +512,7 @@ class AdminCommands(commands.Cog): name="admin-clear-scorecards", description="Manually clear the live scorebug channel and hide it from members" ) + @league_admin_only() @logged_command("/admin-clear-scorecards") async def admin_clear_scorecards(self, interaction: discord.Interaction): """ diff --git a/commands/admin/users.py b/commands/admin/users.py index 906f823..65520bf 100644 --- a/commands/admin/users.py +++ b/commands/admin/users.py @@ -13,6 +13,7 @@ from discord import app_commands from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.permissions import league_admin_only from views.embeds import EmbedColors, EmbedTemplate @@ -42,6 +43,7 @@ class UserManagementCommands(commands.Cog): duration="Duration in minutes (1-10080, max 7 days)", reason="Reason for the timeout" ) + @league_admin_only() @logged_command("/admin-timeout") async def admin_timeout( self, @@ -117,6 +119,7 @@ class UserManagementCommands(commands.Cog): user="User to remove timeout from", reason="Reason for removing the timeout" ) + @league_admin_only() @logged_command("/admin-untimeout") async def admin_untimeout( self, @@ -179,6 +182,7 @@ class UserManagementCommands(commands.Cog): user="User to kick", reason="Reason for the kick" ) + @league_admin_only() @logged_command("/admin-kick") async def admin_kick( self, @@ -255,6 +259,7 @@ class UserManagementCommands(commands.Cog): reason="Reason for the ban", delete_messages="Whether to delete user's messages (default: False)" ) + @league_admin_only() @logged_command("/admin-ban") async def admin_ban( self, @@ -340,6 +345,7 @@ class UserManagementCommands(commands.Cog): user_id="User ID to unban", reason="Reason for the unban" ) + @league_admin_only() @logged_command("/admin-unban") async def admin_unban( self, @@ -425,6 +431,7 @@ class UserManagementCommands(commands.Cog): @app_commands.describe( user="User to get information about" ) + @league_admin_only() @logged_command("/admin-userinfo") async def admin_userinfo( self, diff --git a/commands/custom_commands/main.py b/commands/custom_commands/main.py index 3e38e95..925f122 100644 --- a/commands/custom_commands/main.py +++ b/commands/custom_commands/main.py @@ -17,6 +17,7 @@ from services.custom_commands_service import ( from models.custom_command import CustomCommandSearchFilters from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.permissions import requires_team from views.embeds import EmbedTemplate, EmbedColors from views.custom_commands import ( CustomCommandCreateModal, @@ -101,6 +102,7 @@ class CustomCommandsCommands(commands.Cog): return [] @app_commands.command(name="cc-create", description="Create a new custom command") + @requires_team() @logged_command("/cc-create") async def create_custom_command(self, interaction: discord.Interaction): """Create a new custom command using an interactive modal.""" @@ -172,6 +174,7 @@ class CustomCommandsCommands(commands.Cog): @app_commands.command(name="cc-edit", description="Edit one of your custom commands") @app_commands.describe(name="Name of the command to edit") + @requires_team() @logged_command("/cc-edit") async def edit_custom_command(self, interaction: discord.Interaction, name: str): """Edit an existing custom command.""" @@ -266,6 +269,7 @@ class CustomCommandsCommands(commands.Cog): @app_commands.command(name="cc-delete", description="Delete one of your custom commands") @app_commands.describe(name="Name of the command to delete") + @requires_team() @logged_command("/cc-delete") async def delete_custom_command(self, interaction: discord.Interaction, name: str): """Delete a custom command with confirmation.""" @@ -342,6 +346,7 @@ class CustomCommandsCommands(commands.Cog): return [] @app_commands.command(name="cc-mine", description="View and manage your custom commands") + @requires_team() @logged_command("/cc-mine") async def my_custom_commands(self, interaction: discord.Interaction): """Show user's custom commands with management interface.""" diff --git a/commands/draft/admin.py b/commands/draft/admin.py index e780757..17d3af7 100644 --- a/commands/draft/admin.py +++ b/commands/draft/admin.py @@ -14,6 +14,7 @@ from services.draft_service import draft_service from services.draft_pick_service import draft_pick_service from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.permissions import league_admin_only from views.draft_views import create_admin_draft_info_embed from views.embeds import EmbedTemplate @@ -29,7 +30,7 @@ class DraftAdminGroup(app_commands.Group): self.logger = get_contextual_logger(f'{__name__}.DraftAdminGroup') @app_commands.command(name="info", description="View current draft configuration") - @app_commands.checks.has_permissions(administrator=True) + @league_admin_only() @logged_command("/draft-admin info") async def draft_admin_info(self, interaction: discord.Interaction): """Display current draft configuration and state.""" @@ -61,7 +62,7 @@ class DraftAdminGroup(app_commands.Group): enabled="Turn timer on or off", minutes="Minutes per pick (optional, default uses current setting)" ) - @app_commands.checks.has_permissions(administrator=True) + @league_admin_only() @logged_command("/draft-admin timer") async def draft_admin_timer( self, @@ -109,7 +110,7 @@ class DraftAdminGroup(app_commands.Group): @app_commands.describe( pick_number="Overall pick number to jump to (1-512)" ) - @app_commands.checks.has_permissions(administrator=True) + @league_admin_only() @logged_command("/draft-admin set-pick") async def draft_admin_set_pick( self, @@ -180,7 +181,7 @@ class DraftAdminGroup(app_commands.Group): ping_channel="Channel for 'on the clock' pings", result_channel="Channel for draft results" ) - @app_commands.checks.has_permissions(administrator=True) + @league_admin_only() @logged_command("/draft-admin channels") async def draft_admin_channels( self, @@ -238,7 +239,7 @@ class DraftAdminGroup(app_commands.Group): @app_commands.describe( minutes="Minutes to add (uses default if not provided)" ) - @app_commands.checks.has_permissions(administrator=True) + @league_admin_only() @logged_command("/draft-admin reset-deadline") async def draft_admin_reset_deadline( self, diff --git a/commands/draft/board.py b/commands/draft/board.py index 86fc372..a40ea37 100644 --- a/commands/draft/board.py +++ b/commands/draft/board.py @@ -12,6 +12,7 @@ from config import get_config from services.draft_pick_service import draft_pick_service from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.permissions import requires_team from views.draft_views import create_draft_board_embed from views.embeds import EmbedTemplate @@ -30,6 +31,7 @@ class DraftBoardCommands(commands.Cog): @discord.app_commands.describe( round_number="Round number to view (1-32)" ) + @requires_team() @logged_command("/draft-board") async def draft_board( self, diff --git a/commands/draft/list.py b/commands/draft/list.py index 4a4ebac..afe9fb6 100644 --- a/commands/draft/list.py +++ b/commands/draft/list.py @@ -15,6 +15,7 @@ from services.player_service import player_service from services.team_service import team_service from utils.logging import get_contextual_logger from utils.decorators import logged_command, requires_draft_period +from utils.permissions import requires_team from views.draft_views import create_draft_list_embed from views.embeds import EmbedTemplate @@ -62,6 +63,7 @@ class DraftListCommands(commands.Cog): description="View your team's auto-draft queue" ) @requires_draft_period + @requires_team() @logged_command("/draft-list") async def draft_list_view(self, interaction: discord.Interaction): """Display team's draft list.""" @@ -103,6 +105,7 @@ class DraftListCommands(commands.Cog): ) @discord.app_commands.autocomplete(player=fa_player_autocomplete) @requires_draft_period + @requires_team() @logged_command("/draft-list-add") async def draft_list_add( self, @@ -210,6 +213,7 @@ class DraftListCommands(commands.Cog): ) @discord.app_commands.autocomplete(player=fa_player_autocomplete) @requires_draft_period + @requires_team() @logged_command("/draft-list-remove") async def draft_list_remove( self, @@ -272,6 +276,7 @@ class DraftListCommands(commands.Cog): description="Clear your entire auto-draft queue" ) @requires_draft_period + @requires_team() @logged_command("/draft-list-clear") async def draft_list_clear(self, interaction: discord.Interaction): """Clear entire draft list.""" diff --git a/commands/draft/picks.py b/commands/draft/picks.py index d13d303..cf39cd1 100644 --- a/commands/draft/picks.py +++ b/commands/draft/picks.py @@ -18,6 +18,7 @@ from services.team_service import team_service from utils.logging import get_contextual_logger from utils.decorators import logged_command, requires_draft_period from utils.draft_helpers import validate_cap_space, format_pick_display +from utils.permissions import requires_team from views.draft_views import ( create_player_draft_card, create_pick_illegal_embed, @@ -78,6 +79,7 @@ class DraftPicksCog(commands.Cog): ) @discord.app_commands.autocomplete(player=fa_player_autocomplete) @requires_draft_period + @requires_team() @logged_command("/draft") async def draft_pick( self, diff --git a/commands/draft/status.py b/commands/draft/status.py index 0190ec1..9478379 100644 --- a/commands/draft/status.py +++ b/commands/draft/status.py @@ -11,6 +11,7 @@ from services.draft_service import draft_service from services.draft_pick_service import draft_pick_service from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.permissions import requires_team from views.draft_views import create_draft_status_embed, create_on_the_clock_embed from views.embeds import EmbedTemplate @@ -26,6 +27,7 @@ class DraftStatusCommands(commands.Cog): name="draft-status", description="View current draft state and timer information" ) + @requires_team() @logged_command("/draft-status") async def draft_status(self, interaction: discord.Interaction): """Display current draft state.""" @@ -77,6 +79,7 @@ class DraftStatusCommands(commands.Cog): name="draft-on-clock", description="View detailed 'on the clock' information" ) + @requires_team() @logged_command("/draft-on-clock") async def draft_on_clock(self, interaction: discord.Interaction): """Display detailed 'on the clock' information with recent and upcoming picks.""" diff --git a/commands/gameplay/scorebug.py b/commands/gameplay/scorebug.py index 95d902f..8030a2a 100644 --- a/commands/gameplay/scorebug.py +++ b/commands/gameplay/scorebug.py @@ -11,6 +11,7 @@ from services.scorebug_service import ScorebugData, ScorebugService from services.team_service import team_service from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.permissions import league_only from utils.scorebug_helpers import create_scorebug_embed from views.embeds import EmbedTemplate, EmbedColors from exceptions import SheetsException @@ -34,6 +35,7 @@ class ScorebugCommands(commands.Cog): @app_commands.describe( url="Full URL to the Google Sheets scorecard or just the sheet key" ) + @league_only() @logged_command("/publish-scorecard") async def publish_scorecard( self, @@ -147,6 +149,7 @@ class ScorebugCommands(commands.Cog): @app_commands.describe( full_length="Include full game details (defaults to True)" ) + @league_only() @logged_command("/scorebug") async def scorebug( self, diff --git a/commands/help/main.py b/commands/help/main.py index 64e1e85..f0bde14 100644 --- a/commands/help/main.py +++ b/commands/help/main.py @@ -16,6 +16,7 @@ from services.help_commands_service import ( ) from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.permissions import league_admin_only from views.embeds import EmbedTemplate, EmbedColors from views.help_commands import ( HelpCommandCreateModal, @@ -129,6 +130,7 @@ class HelpCommands(commands.Cog): await interaction.followup.send(embed=embed, ephemeral=True) @app_commands.command(name="help-create", description="Create a new help topic (admin/help editor only)") + @league_admin_only() @logged_command("/help-create") async def help_create(self, interaction: discord.Interaction): """Create a new help topic using an interactive modal.""" @@ -210,6 +212,7 @@ class HelpCommands(commands.Cog): @app_commands.command(name="help-edit", description="Edit an existing help topic (admin/help editor only)") @app_commands.describe(topic="Help topic to edit") @app_commands.autocomplete(topic=help_topic_autocomplete) + @league_admin_only() @logged_command("/help-edit") async def help_edit(self, interaction: discord.Interaction, topic: str): """Edit an existing help topic.""" @@ -285,6 +288,7 @@ class HelpCommands(commands.Cog): @app_commands.command(name="help-delete", description="Delete a help topic (admin/help editor only)") @app_commands.describe(topic="Help topic to delete") @app_commands.autocomplete(topic=help_topic_autocomplete) + @league_admin_only() @logged_command("/help-delete") async def help_delete(self, interaction: discord.Interaction, topic: str): """Delete a help topic with confirmation.""" diff --git a/commands/injuries/management.py b/commands/injuries/management.py index 90ac45a..2eea8af 100644 --- a/commands/injuries/management.py +++ b/commands/injuries/management.py @@ -29,6 +29,7 @@ from utils import team_utils from utils.logging import get_contextual_logger from utils.decorators import logged_command from utils.autocomplete import player_autocomplete +from utils.permissions import league_only from views.base import ConfirmationView from views.embeds import EmbedTemplate from views.modals import PitcherRestModal, BatterInjuryModal @@ -61,6 +62,7 @@ class InjuryGroup(app_commands.Group): @app_commands.command(name="roll", description="Roll for injury based on player's injury rating") @app_commands.describe(player_name="Player name") @app_commands.autocomplete(player_name=player_autocomplete) + @league_only() @logged_command("/injury roll") async def injury_roll(self, interaction: discord.Interaction, player_name: str): """Roll for injury using 3d6 dice and injury tables.""" @@ -343,6 +345,7 @@ class InjuryGroup(app_commands.Group): this_game="Current game number (1-4)", injury_games="Number of games player will be out" ) + @league_only() @logged_command("/injury set-new") async def injury_set_new( self, @@ -547,6 +550,7 @@ class InjuryGroup(app_commands.Group): @app_commands.command(name="clear", description="Clear a player's injury (requires SBA Players role)") @app_commands.describe(player_name="Player name to clear injury") @app_commands.autocomplete(player_name=player_autocomplete) + @league_only() @logged_command("/injury clear") async def injury_clear(self, interaction: discord.Interaction, player_name: str): """Clear a player's active injury.""" diff --git a/commands/league/info.py b/commands/league/info.py index 7e2f203..f5d4a78 100644 --- a/commands/league/info.py +++ b/commands/league/info.py @@ -12,6 +12,7 @@ from config import get_config from utils.logging import get_contextual_logger from utils.decorators import logged_command from exceptions import BotException +from utils.permissions import requires_team from views.embeds import EmbedTemplate class LeagueInfoCommands(commands.Cog): @@ -23,6 +24,7 @@ class LeagueInfoCommands(commands.Cog): self.logger.info("LeagueInfoCommands cog initialized") @discord.app_commands.command(name="league-metadata", description="Display current league metadata") + @requires_team() @logged_command("/league-metadata") async def league_info(self, interaction: discord.Interaction): """Display current league state and information.""" diff --git a/commands/league/schedule.py b/commands/league/schedule.py index 5998d8e..f8fbc2b 100644 --- a/commands/league/schedule.py +++ b/commands/league/schedule.py @@ -13,6 +13,7 @@ from config import get_config from services.schedule_service import schedule_service from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.permissions import requires_team from views.embeds import EmbedColors, EmbedTemplate @@ -32,6 +33,7 @@ class ScheduleCommands(commands.Cog): week="Week number to show (optional)", team="Team abbreviation to filter by (optional)" ) + @requires_team() @logged_command("/schedule") async def schedule( self, diff --git a/commands/league/standings.py b/commands/league/standings.py index 0614b5e..4fe381f 100644 --- a/commands/league/standings.py +++ b/commands/league/standings.py @@ -13,6 +13,7 @@ from models.team import Team from services.standings_service import standings_service from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.permissions import requires_team from views.embeds import EmbedColors, EmbedTemplate @@ -31,6 +32,7 @@ class StandingsCommands(commands.Cog): season="Season to show standings for (defaults to current season)", division="Show specific division only (optional)" ) + @requires_team() @logged_command("/standings") async def standings( self, @@ -57,6 +59,7 @@ class StandingsCommands(commands.Cog): @discord.app_commands.describe( season="Season to show playoff picture for (defaults to current season)" ) + @requires_team() @logged_command("/playoff-picture") async def playoff_picture( self, diff --git a/commands/teams/roster.py b/commands/teams/roster.py index 261cce8..f8b90fc 100644 --- a/commands/teams/roster.py +++ b/commands/teams/roster.py @@ -14,6 +14,7 @@ from models.team import Team from utils.logging import get_contextual_logger from utils.decorators import logged_command from exceptions import BotException +from utils.permissions import requires_team from views.embeds import EmbedTemplate, EmbedColors @@ -34,6 +35,7 @@ class TeamRosterCommands(commands.Cog): discord.app_commands.Choice(name="Current Week", value="current"), discord.app_commands.Choice(name="Next Week", value="next") ]) + @requires_team() @logged_command("/roster") async def team_roster(self, interaction: discord.Interaction, abbrev: str, roster_type: str = "current"): diff --git a/commands/transactions/dropadd.py b/commands/transactions/dropadd.py index b4851fd..f012852 100644 --- a/commands/transactions/dropadd.py +++ b/commands/transactions/dropadd.py @@ -13,6 +13,7 @@ from config import get_config from utils.logging import get_contextual_logger from utils.decorators import logged_command from utils.autocomplete import player_autocomplete +from utils.permissions import league_only from utils.team_utils import validate_user_has_team from services.transaction_builder import ( @@ -49,6 +50,7 @@ class DropAddCommands(commands.Cog): app_commands.Choice(name="Minor League", value="mil"), app_commands.Choice(name="Free Agency", value="fa") ]) + @league_only() @logged_command("/dropadd") async def dropadd( self, @@ -246,6 +248,7 @@ class DropAddCommands(commands.Cog): name="cleartransaction", description="Clear your current transaction builder" ) + @league_only() @logged_command("/cleartransaction") async def clear_transaction(self, interaction: discord.Interaction): """Clear the user's current transaction builder.""" diff --git a/commands/transactions/ilmove.py b/commands/transactions/ilmove.py index 64a88fe..d994e5d 100644 --- a/commands/transactions/ilmove.py +++ b/commands/transactions/ilmove.py @@ -17,6 +17,7 @@ from config import get_config from utils.logging import get_contextual_logger from utils.decorators import logged_command from utils.autocomplete import player_autocomplete +from utils.permissions import league_only from utils.team_utils import validate_user_has_team from services.transaction_builder import ( @@ -54,6 +55,7 @@ class ILMoveCommands(commands.Cog): app_commands.Choice(name="Injured List", value="il"), app_commands.Choice(name="Free Agency", value="fa") ]) + @league_only() @logged_command("/ilmove") async def ilmove( self, diff --git a/commands/transactions/management.py b/commands/transactions/management.py index 9d37d36..fb21a2c 100644 --- a/commands/transactions/management.py +++ b/commands/transactions/management.py @@ -13,6 +13,7 @@ from discord import app_commands from config import get_config from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.permissions import requires_team from utils.team_utils import get_user_major_league_team from views.embeds import EmbedColors, EmbedTemplate from views.base import PaginationView @@ -112,6 +113,7 @@ class TransactionCommands(commands.Cog): @app_commands.describe( show_cancelled="Include cancelled transactions in the display (default: False)" ) + @requires_team() @logged_command("/mymoves") async def my_moves( self, @@ -186,6 +188,7 @@ class TransactionCommands(commands.Cog): @app_commands.describe( team="Team abbreviation to check (defaults to your team)" ) + @requires_team() @logged_command("/legal") async def legal( self, diff --git a/commands/utilities/weather.py b/commands/utilities/weather.py index a12cd79..17e1c6e 100644 --- a/commands/utilities/weather.py +++ b/commands/utilities/weather.py @@ -15,6 +15,7 @@ from models.current import Current from models.game import Game from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.permissions import requires_team from utils.team_utils import get_user_major_league_team from views.embeds import EmbedTemplate, EmbedColors @@ -34,6 +35,7 @@ class WeatherCommands(commands.Cog): @discord.app_commands.describe( team_abbrev="Team abbreviation (optional - defaults to channel or your team)" ) + @requires_team() @logged_command("/weather") async def weather(self, interaction: discord.Interaction, team_abbrev: Optional[str] = None): """Display weather check for a team's ballpark.""" diff --git a/commands/voice/channels.py b/commands/voice/channels.py index 8feee6f..9fc6e73 100644 --- a/commands/voice/channels.py +++ b/commands/voice/channels.py @@ -16,6 +16,7 @@ from services.schedule_service import ScheduleService from services.league_service import league_service from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.permissions import league_only from views.embeds import EmbedTemplate from models.team import RosterType @@ -129,6 +130,7 @@ class VoiceChannelCommands(commands.Cog): return channel @voice_group.command(name="public", description="Create a public voice channel") + @league_only() @logged_command("/voice-channel public") async def create_public_channel(self, interaction: discord.Interaction): """Create a public voice channel for gameplay.""" @@ -188,6 +190,7 @@ class VoiceChannelCommands(commands.Cog): await interaction.followup.send(embed=embed, ephemeral=True) @voice_group.command(name="private", description="Create a private team vs team voice channel") + @league_only() @logged_command("/voice-channel private") async def create_private_channel(self, interaction: discord.Interaction): """Create a private voice channel for team matchup.""" @@ -347,6 +350,7 @@ class VoiceChannelCommands(commands.Cog): await interaction.followup.send(embed=embed, ephemeral=True) # Deprecated prefix commands with migration messages + @league_only() @commands.command(name="vc", aliases=["voice", "gameplay"]) async def deprecated_public_voice(self, ctx: commands.Context): """Deprecated command - redirect to new slash command.""" @@ -361,6 +365,7 @@ class VoiceChannelCommands(commands.Cog): embed.set_footer(text="💡 Tip: Type /voice-channel and see the available options!") await ctx.send(embed=embed) + @league_only() @commands.command(name="private") async def deprecated_private_voice(self, ctx: commands.Context): """Deprecated command - redirect to new slash command.""" diff --git a/utils/permissions.py b/utils/permissions.py new file mode 100644 index 0000000..15ef71f --- /dev/null +++ b/utils/permissions.py @@ -0,0 +1,261 @@ +""" +Flexible command permission system. + +This module provides decorators for controlling command access across different +servers and user types: +- @global_command: Available in all servers +- @league_only: Only available in the league server +- @requires_team: User must have a team (works with global commands) +""" +import logging +from functools import wraps +from typing import Optional, Callable + +import discord +from discord.ext import commands + +from config import get_config + +logger = logging.getLogger(__name__) + + +class PermissionError(Exception): + """Raised when a user doesn't have permission to use a command.""" + pass + + +async def get_user_team(user_id: int) -> Optional[dict]: + """ + Check if a user has a team in the league. + + This function is cached because TeamService.get_team_by_owner() is already + cached with a 30-minute TTL. The cached service method avoids repeated + API calls when the same user runs multiple commands. + + Args: + user_id: Discord user ID + + Returns: + Team data dict if user has a team, None otherwise + + Note: + The underlying service method uses @cached_single_item decorator, + so this function benefits from automatic caching without additional + implementation. + """ + # Import here to avoid circular imports + from services.team_service import team_service + + try: + # Get team by owner (Discord user ID) + # This call is automatically cached by TeamService + config = get_config() + team = await team_service.get_team_by_owner( + owner_id=user_id, + season=config.sba_current_season + ) + + if team: + logger.debug(f"User {user_id} has team: {team.lname}") + return { + 'id': team.id, + 'name': team.lname, + 'abbrev': team.team_abbrev, + 'season': team.season + } + + logger.debug(f"User {user_id} does not have a team") + return None + + except Exception as e: + logger.error(f"Error checking user team: {e}", exc_info=True) + return None + + +def is_league_server(guild_id: int) -> bool: + """Check if a guild is the league server.""" + config = get_config() + return guild_id == config.guild_id + + +def league_only(): + """ + Decorator to restrict a command to the league server only. + + Usage: + @discord.app_commands.command(name="team") + @league_only() + async def team_command(self, interaction: discord.Interaction): + # Only executes in league server + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(self, interaction: discord.Interaction, *args, **kwargs): + # Check if in a guild + if not interaction.guild: + await interaction.response.send_message( + "❌ This command can only be used in a server.", + ephemeral=True + ) + return + + # Check if in league server + if not is_league_server(interaction.guild.id): + await interaction.response.send_message( + "❌ This command is only available in the SBa league server.", + ephemeral=True + ) + return + + return await func(self, interaction, *args, **kwargs) + + return wrapper + return decorator + + +def requires_team(): + """ + Decorator to require a user to have a team in the league. + Can be used on global commands to restrict to league participants. + + Usage: + @discord.app_commands.command(name="mymoves") + @requires_team() + async def mymoves_command(self, interaction: discord.Interaction): + # Only executes if user has a team + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(self, interaction: discord.Interaction, *args, **kwargs): + # Check if user has a team + team = await get_user_team(interaction.user.id) + + if team is None: + await interaction.response.send_message( + "❌ This command requires you to have a team in the SBa league. Contact an admin if you believe this is an error.", + ephemeral=True + ) + return + + # Store team info in interaction for command to use + # This allows commands to access the team without another lookup + interaction.extras['user_team'] = team + + return await func(self, interaction, *args, **kwargs) + + return wrapper + return decorator + + +def global_command(): + """ + Decorator to explicitly mark a command as globally available. + This is mainly for documentation purposes - commands are global by default. + + Usage: + @discord.app_commands.command(name="roll") + @global_command() + async def roll_command(self, interaction: discord.Interaction): + # Available in all servers + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(self, interaction: discord.Interaction, *args, **kwargs): + return await func(self, interaction, *args, **kwargs) + + return wrapper + return decorator + + +def admin_only(): + """ + Decorator to restrict a command to server administrators. + Works in any server, but requires admin permissions. + + Usage: + @discord.app_commands.command(name="sync") + @admin_only() + async def sync_command(self, interaction: discord.Interaction): + # Only executes for admins + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(self, interaction: discord.Interaction, *args, **kwargs): + # Check if user is guild admin + if not interaction.guild: + await interaction.response.send_message( + "❌ This command can only be used in a server.", + ephemeral=True + ) + return + + # Check if user has admin permissions + if not isinstance(interaction.user, discord.Member): + await interaction.response.send_message( + "❌ Unable to verify permissions.", + ephemeral=True + ) + return + + if not interaction.user.guild_permissions.administrator: + await interaction.response.send_message( + "❌ This command requires administrator permissions.", + ephemeral=True + ) + return + + return await func(self, interaction, *args, **kwargs) + + return wrapper + return decorator + + +# Decorator can be combined for complex permissions +def league_admin_only(): + """ + Decorator requiring both league server AND admin permissions. + + Usage: + @discord.app_commands.command(name="force-sync") + @league_admin_only() + async def force_sync(self, interaction: discord.Interaction): + # Only league server admins can use this + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(self, interaction: discord.Interaction, *args, **kwargs): + # Check guild + if not interaction.guild: + await interaction.response.send_message( + "❌ This command can only be used in a server.", + ephemeral=True + ) + return + + # Check if league server + if not is_league_server(interaction.guild.id): + await interaction.response.send_message( + "❌ This command is only available in the SBa league server.", + ephemeral=True + ) + return + + # Check admin permissions + if not isinstance(interaction.user, discord.Member): + await interaction.response.send_message( + "❌ Unable to verify permissions.", + ephemeral=True + ) + return + + if not interaction.user.guild_permissions.administrator: + await interaction.response.send_message( + "❌ This command requires administrator permissions.", + ephemeral=True + ) + return + + return await func(self, interaction, *args, **kwargs) + + return wrapper + return decorator diff --git a/utils/permissions_examples.py b/utils/permissions_examples.py new file mode 100644 index 0000000..0feea2b --- /dev/null +++ b/utils/permissions_examples.py @@ -0,0 +1,224 @@ +""" +Permission Decorator Usage Examples + +This file demonstrates how to use the permission decorators for different +command access patterns across multiple servers. +""" +import discord +from discord.ext import commands +from discord import app_commands + +from utils.permissions import ( + global_command, + league_only, + requires_team, + admin_only, + league_admin_only +) +from utils.decorators import logged_command + + +# Example 1: Global Command (Available Everywhere) +# Use case: Dice rolling, utilities, weather, etc. +class GlobalCommandExample(commands.Cog): + """Commands available in all servers.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command(name="roll", description="Roll dice") + @global_command() # Optional - commands are global by default + @logged_command("/roll") + async def roll_dice(self, interaction: discord.Interaction, dice: str): + """ + Available in ALL servers. + Anyone can use this command. + """ + await interaction.response.defer() + # Dice rolling logic here + await interaction.followup.send(f"🎲 Rolled {dice}") + + +# Example 2: League-Only Command +# Use case: Team info, player stats, league standings, etc. +class LeagueCommandExample(commands.Cog): + """Commands only available in the league server.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command(name="team", description="View team information") + @league_only() # Restricts to league server + @logged_command("/team") + async def team_info(self, interaction: discord.Interaction, abbrev: str): + """ + Only works in the league server. + Shows error in other servers. + """ + await interaction.response.defer() + # Team lookup logic here + await interaction.followup.send(f"Team info for {abbrev}") + + +# Example 3: Global Command with Team Requirement +# Use case: User wants to use /mymoves from any server, but must have a team +class GlobalTeamCommandExample(commands.Cog): + """Global commands that require league participation.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command(name="mymoves", description="View your pending moves") + @requires_team() # Works anywhere, but user must have a team + @logged_command("/mymoves") + async def my_moves(self, interaction: discord.Interaction): + """ + Available in ALL servers. + But user must have a team in the league. + Team data is accessible via interaction.extras['user_team'] + """ + await interaction.response.defer() + + # Access the user's team data + user_team = interaction.extras.get('user_team') + if user_team: + team_name = user_team['name'] + await interaction.followup.send(f"Moves for {team_name}...") + else: + # This shouldn't happen - decorator handles it + await interaction.followup.send("Error: No team found") + + +# Example 4: Admin Command (Global but Requires Admin) +# Use case: Bot management commands that work in any server +class AdminCommandExample(commands.Cog): + """Admin commands available in any server.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command(name="sync", description="Sync bot commands") + @admin_only() # Requires server admin permissions + @logged_command("/sync") + async def sync_commands(self, interaction: discord.Interaction): + """ + Available in ALL servers. + But user must be a server administrator. + """ + await interaction.response.defer(ephemeral=True) + # Command sync logic here + await interaction.followup.send("✅ Commands synced", ephemeral=True) + + +# Example 5: League Admin Command +# Use case: League-specific admin functions +class LeagueAdminCommandExample(commands.Cog): + """Admin commands for league management.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command(name="force-process", description="Force transaction processing") + @league_admin_only() # Must be admin AND in league server + @logged_command("/force-process") + async def force_process(self, interaction: discord.Interaction): + """ + Only works in the league server. + User must be a server administrator. + """ + await interaction.response.defer(ephemeral=True) + # Transaction processing logic here + await interaction.followup.send("✅ Transactions processed", ephemeral=True) + + +# Example 6: Combining Multiple Decorators +# Use case: Complex permission requirements +class CombinedPermissionsExample(commands.Cog): + """Commands with multiple permission requirements.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command(name="my-league-moves", description="View your moves (league server only)") + @league_only() # Must be in league server + @requires_team() # AND must have a team + @logged_command("/my-league-moves") + async def league_moves(self, interaction: discord.Interaction): + """ + Only works in league server. + User must have a team. + Combines both restrictions. + """ + await interaction.response.defer() + + user_team = interaction.extras.get('user_team') + team_name = user_team['name'] if user_team else "Unknown" + + await interaction.followup.send(f"League moves for {team_name}...") + + +# Example 7: Package-Level Organization +# How to organize commands by scope in your packages + +# In commands/dice/__init__.py (GLOBAL commands) +async def setup_dice(bot: commands.Bot): + """All dice commands are global - available everywhere.""" + await bot.add_cog(DiceCommands(bot)) + return 1, 0, [] + + +# In commands/league/__init__.py (LEAGUE-ONLY commands) +async def setup_league(bot: commands.Bot): + """All league commands are league-only - use @league_only() decorator.""" + await bot.add_cog(LeagueCommands(bot)) + return 1, 0, [] + + +# In commands/transactions/__init__.py (MIXED commands) +async def setup_transactions(bot: commands.Bot): + """ + Mixed scope commands: + - /trade: @league_only() - complex UI, league server only + - /mymoves: @requires_team() - global but needs team + - /legal: @league_only() - lookup command for league context + """ + await bot.add_cog(TransactionCommands(bot)) + return 1, 0, [] + + +""" +Summary of Permission Patterns: + +1. GLOBAL (no decorator needed): + - Dice rolling + - Weather + - Utilities + - General help + +2. @league_only(): + - Team information + - Player stats + - League standings + - Schedule + - Rosters + +3. @requires_team(): + - My moves (can check from anywhere) + - My team (if you want global access) + - Personal league stats + +4. @admin_only(): + - Bot sync + - Bot shutdown + - System management + +5. @league_admin_only(): + - Force process transactions + - League configuration + - Season management + +6. @league_only() + @requires_team(): + - Trade initiation + - Advanced league features + - GM-only league commands +"""