Merge pull request 'feat: add is_admin() helper to utils/permissions.py (#55)' (#64) from ai/major-domo-v2#55 into next-release
All checks were successful
Build Docker Image / build (push) Successful in 1m11s

Reviewed-on: #64
This commit is contained in:
cal 2026-03-09 14:38:07 +00:00
commit 4314b67537
3 changed files with 66 additions and 28 deletions

View File

@ -22,6 +22,7 @@ from utils.decorators import logged_command
from views.embeds import EmbedColors, EmbedTemplate from views.embeds import EmbedColors, EmbedTemplate
from views.base import BaseView from views.base import BaseView
from models.player import Player from models.player import Player
from utils.permissions import is_admin
# URL Validation Functions # URL Validation Functions
@ -117,7 +118,7 @@ async def can_edit_player_image(
If has permission, error_message is empty string If has permission, error_message is empty string
""" """
# Admins can edit anyone # Admins can edit anyone
if interaction.user.guild_permissions.administrator: if is_admin(interaction):
logger.debug("User is admin, granting permission", user_id=interaction.user.id) logger.debug("User is admin, granting permission", user_id=interaction.user.id)
return True, "" return True, ""

View File

@ -3,17 +3,19 @@ Tests for player image management commands.
Covers URL validation, permission checking, and command execution. Covers URL validation, permission checking, and command execution.
""" """
import pytest import pytest
import asyncio import asyncio
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import aiohttp import aiohttp
import discord
from aioresponses import aioresponses from aioresponses import aioresponses
from commands.profile.images import ( from commands.profile.images import (
validate_url_format, validate_url_format,
check_url_accessibility, check_url_accessibility,
can_edit_player_image, can_edit_player_image,
ImageCommands ImageCommands,
) )
from tests.factories import PlayerFactory, TeamFactory from tests.factories import PlayerFactory, TeamFactory
@ -94,7 +96,7 @@ class TestURLAccessibility:
url = "https://example.com/image.jpg" url = "https://example.com/image.jpg"
with aioresponses() as m: with aioresponses() as m:
m.head(url, status=200, headers={'content-type': 'image/jpeg'}) m.head(url, status=200, headers={"content-type": "image/jpeg"})
is_accessible, error = await check_url_accessibility(url) is_accessible, error = await check_url_accessibility(url)
@ -118,7 +120,7 @@ class TestURLAccessibility:
url = "https://example.com/page.html" url = "https://example.com/page.html"
with aioresponses() as m: with aioresponses() as m:
m.head(url, status=200, headers={'content-type': 'text/html'}) m.head(url, status=200, headers={"content-type": "text/html"})
is_accessible, error = await check_url_accessibility(url) is_accessible, error = await check_url_accessibility(url)
@ -157,6 +159,7 @@ class TestPermissionChecking:
async def test_admin_can_edit_any_player(self): async def test_admin_can_edit_any_player(self):
"""Test administrator can edit any player's images.""" """Test administrator can edit any player's images."""
mock_interaction = MagicMock() mock_interaction = MagicMock()
mock_interaction.user = MagicMock(spec=discord.Member)
mock_interaction.user.id = 12345 mock_interaction.user.id = 12345
mock_interaction.user.guild_permissions.administrator = True mock_interaction.user.guild_permissions.administrator = True
@ -186,7 +189,9 @@ class TestPermissionChecking:
mock_logger = MagicMock() mock_logger = MagicMock()
with patch('commands.profile.images.team_service.get_teams_by_owner') as mock_get_teams: with patch(
"commands.profile.images.team_service.get_teams_by_owner"
) as mock_get_teams:
mock_get_teams.return_value = [user_team] mock_get_teams.return_value = [user_team]
has_permission, error = await can_edit_player_image( has_permission, error = await can_edit_player_image(
@ -211,7 +216,9 @@ class TestPermissionChecking:
mock_logger = MagicMock() mock_logger = MagicMock()
with patch('commands.profile.images.team_service.get_teams_by_owner') as mock_get_teams: with patch(
"commands.profile.images.team_service.get_teams_by_owner"
) as mock_get_teams:
mock_get_teams.return_value = [user_team] mock_get_teams.return_value = [user_team]
has_permission, error = await can_edit_player_image( has_permission, error = await can_edit_player_image(
@ -236,7 +243,9 @@ class TestPermissionChecking:
mock_logger = MagicMock() mock_logger = MagicMock()
with patch('commands.profile.images.team_service.get_teams_by_owner') as mock_get_teams: with patch(
"commands.profile.images.team_service.get_teams_by_owner"
) as mock_get_teams:
mock_get_teams.return_value = [user_team] mock_get_teams.return_value = [user_team]
has_permission, error = await can_edit_player_image( has_permission, error = await can_edit_player_image(
@ -258,7 +267,9 @@ class TestPermissionChecking:
mock_logger = MagicMock() mock_logger = MagicMock()
with patch('commands.profile.images.team_service.get_teams_by_owner') as mock_get_teams: with patch(
"commands.profile.images.team_service.get_teams_by_owner"
) as mock_get_teams:
mock_get_teams.return_value = [] mock_get_teams.return_value = []
has_permission, error = await can_edit_player_image( has_permission, error = await can_edit_player_image(
@ -299,7 +310,7 @@ class TestImageCommandsIntegration:
async def test_set_image_command_structure(self, commands_cog): async def test_set_image_command_structure(self, commands_cog):
"""Test that set_image command is properly configured.""" """Test that set_image command is properly configured."""
assert hasattr(commands_cog, 'set_image') assert hasattr(commands_cog, "set_image")
assert commands_cog.set_image.name == "set-image" assert commands_cog.set_image.name == "set-image"
async def test_fancy_card_updates_vanity_card_field(self, commands_cog): async def test_fancy_card_updates_vanity_card_field(self, commands_cog):

View File

@ -7,6 +7,7 @@ servers and user types:
- @league_only: Only available in the league server - @league_only: Only available in the league server
- @requires_team: User must have a team (works with global commands) - @requires_team: User must have a team (works with global commands)
""" """
import logging import logging
from functools import wraps from functools import wraps
from typing import Optional, Callable from typing import Optional, Callable
@ -21,6 +22,7 @@ logger = logging.getLogger(__name__)
class PermissionError(Exception): class PermissionError(Exception):
"""Raised when a user doesn't have permission to use a command.""" """Raised when a user doesn't have permission to use a command."""
pass pass
@ -54,17 +56,16 @@ async def get_user_team(user_id: int) -> Optional[dict]:
# This call is automatically cached by TeamService # This call is automatically cached by TeamService
config = get_config() config = get_config()
team = await team_service.get_team_by_owner( team = await team_service.get_team_by_owner(
owner_id=user_id, owner_id=user_id, season=config.sba_season
season=config.sba_season
) )
if team: if team:
logger.debug(f"User {user_id} has team: {team.lname}") logger.debug(f"User {user_id} has team: {team.lname}")
return { return {
'id': team.id, "id": team.id,
'name': team.lname, "name": team.lname,
'abbrev': team.abbrev, "abbrev": team.abbrev,
'season': team.season "season": team.season,
} }
logger.debug(f"User {user_id} does not have a team") logger.debug(f"User {user_id} does not have a team")
@ -77,6 +78,18 @@ def is_league_server(guild_id: int) -> bool:
return guild_id == config.guild_id return guild_id == config.guild_id
def is_admin(interaction: discord.Interaction) -> bool:
"""Check if the interaction user is a server administrator.
Includes an isinstance guard for discord.Member so this is safe
to call from DM contexts (where guild_permissions is unavailable).
"""
return (
isinstance(interaction.user, discord.Member)
and interaction.user.guild_permissions.administrator
)
def league_only(): def league_only():
""" """
Decorator to restrict a command to the league server only. Decorator to restrict a command to the league server only.
@ -87,14 +100,14 @@ def league_only():
async def team_command(self, interaction: discord.Interaction): async def team_command(self, interaction: discord.Interaction):
# Only executes in league server # Only executes in league server
""" """
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
@wraps(func) @wraps(func)
async def wrapper(self, interaction: discord.Interaction, *args, **kwargs): async def wrapper(self, interaction: discord.Interaction, *args, **kwargs):
# Check if in a guild # Check if in a guild
if not interaction.guild: if not interaction.guild:
await interaction.response.send_message( await interaction.response.send_message(
"❌ This command can only be used in a server.", "❌ This command can only be used in a server.", ephemeral=True
ephemeral=True
) )
return return
@ -102,13 +115,14 @@ def league_only():
if not is_league_server(interaction.guild.id): if not is_league_server(interaction.guild.id):
await interaction.response.send_message( await interaction.response.send_message(
"❌ This command is only available in the SBa league server.", "❌ This command is only available in the SBa league server.",
ephemeral=True ephemeral=True,
) )
return return
return await func(self, interaction, *args, **kwargs) return await func(self, interaction, *args, **kwargs)
return wrapper return wrapper
return decorator return decorator
@ -123,6 +137,7 @@ def requires_team():
async def mymoves_command(self, interaction: discord.Interaction): async def mymoves_command(self, interaction: discord.Interaction):
# Only executes if user has a team # Only executes if user has a team
""" """
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
@wraps(func) @wraps(func)
async def wrapper(self, interaction: discord.Interaction, *args, **kwargs): async def wrapper(self, interaction: discord.Interaction, *args, **kwargs):
@ -133,29 +148,33 @@ def requires_team():
if team is None: if team is None:
await interaction.response.send_message( 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.", "❌ This command requires you to have a team in the SBa league. Contact an admin if you believe this is an error.",
ephemeral=True ephemeral=True,
) )
return return
# Store team info in interaction for command to use # Store team info in interaction for command to use
# This allows commands to access the team without another lookup # This allows commands to access the team without another lookup
interaction.extras['user_team'] = team interaction.extras["user_team"] = team
return await func(self, interaction, *args, **kwargs) return await func(self, interaction, *args, **kwargs)
except Exception as e: except Exception as e:
# Log the error for debugging # Log the error for debugging
logger.error(f"Error checking team ownership for user {interaction.user.id}: {e}", exc_info=True) logger.error(
f"Error checking team ownership for user {interaction.user.id}: {e}",
exc_info=True,
)
# Provide helpful error message to user # Provide helpful error message to user
await interaction.response.send_message( await interaction.response.send_message(
"❌ Unable to verify team ownership due to a temporary error. Please try again in a moment. " "❌ Unable to verify team ownership due to a temporary error. Please try again in a moment. "
"If this persists, contact an admin.", "If this persists, contact an admin.",
ephemeral=True ephemeral=True,
) )
return return
return wrapper return wrapper
return decorator return decorator
@ -170,12 +189,14 @@ def global_command():
async def roll_command(self, interaction: discord.Interaction): async def roll_command(self, interaction: discord.Interaction):
# Available in all servers # Available in all servers
""" """
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
@wraps(func) @wraps(func)
async def wrapper(self, interaction: discord.Interaction, *args, **kwargs): async def wrapper(self, interaction: discord.Interaction, *args, **kwargs):
return await func(self, interaction, *args, **kwargs) return await func(self, interaction, *args, **kwargs)
return wrapper return wrapper
return decorator return decorator
@ -190,35 +211,35 @@ def admin_only():
async def sync_command(self, interaction: discord.Interaction): async def sync_command(self, interaction: discord.Interaction):
# Only executes for admins # Only executes for admins
""" """
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
@wraps(func) @wraps(func)
async def wrapper(self, interaction: discord.Interaction, *args, **kwargs): async def wrapper(self, interaction: discord.Interaction, *args, **kwargs):
# Check if user is guild admin # Check if user is guild admin
if not interaction.guild: if not interaction.guild:
await interaction.response.send_message( await interaction.response.send_message(
"❌ This command can only be used in a server.", "❌ This command can only be used in a server.", ephemeral=True
ephemeral=True
) )
return return
# Check if user has admin permissions # Check if user has admin permissions
if not isinstance(interaction.user, discord.Member): if not isinstance(interaction.user, discord.Member):
await interaction.response.send_message( await interaction.response.send_message(
"❌ Unable to verify permissions.", "❌ Unable to verify permissions.", ephemeral=True
ephemeral=True
) )
return return
if not interaction.user.guild_permissions.administrator: if not interaction.user.guild_permissions.administrator:
await interaction.response.send_message( await interaction.response.send_message(
"❌ This command requires administrator permissions.", "❌ This command requires administrator permissions.",
ephemeral=True ephemeral=True,
) )
return return
return await func(self, interaction, *args, **kwargs) return await func(self, interaction, *args, **kwargs)
return wrapper return wrapper
return decorator return decorator
@ -241,6 +262,7 @@ def league_admin_only():
async def admin_sync_prefix(self, ctx: commands.Context): async def admin_sync_prefix(self, ctx: commands.Context):
# Only league server admins can use this # Only league server admins can use this
""" """
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
@wraps(func) @wraps(func)
async def wrapper(self, ctx_or_interaction, *args, **kwargs): async def wrapper(self, ctx_or_interaction, *args, **kwargs):
@ -254,6 +276,7 @@ def league_admin_only():
async def send_error(msg: str): async def send_error(msg: str):
await ctx.send(msg) await ctx.send(msg)
else: else:
interaction = ctx_or_interaction interaction = ctx_or_interaction
guild = interaction.guild guild = interaction.guild
@ -269,7 +292,9 @@ def league_admin_only():
# Check if league server # Check if league server
if not is_league_server(guild.id): if not is_league_server(guild.id):
await send_error("❌ This command is only available in the SBa league server.") await send_error(
"❌ This command is only available in the SBa league server."
)
return return
# Check admin permissions # Check admin permissions
@ -284,4 +309,5 @@ def league_admin_only():
return await func(self, ctx_or_interaction, *args, **kwargs) return await func(self, ctx_or_interaction, *args, **kwargs)
return wrapper return wrapper
return decorator return decorator