major-domo-v2/commands/teams/branding.py
Cal Corum 5133dc3d0f Add /branding command for team color/logo management (v2.20.0)
Implemented comprehensive team branding management system allowing team owners
to update colors and logos for major league, minor league, and dice rolls.

Features:
- Modal-based interactive form input with validation
- Hex color validation with normalization (6 chars, optional # prefix)
- Image URL accessibility testing with aiohttp (5 second timeout)
- Preview + confirmation workflow with ConfirmationView
- Support for both major league and minor league affiliate updates
- Dice color customization for game rolls
- Discord role color sync (non-blocking with graceful fallback)
- Comprehensive error handling and user feedback

Technical Implementation:
- BrandingModal class with 5 optional fields
- Concurrent URL validation using asyncio.gather
- Fixed team_service.update_team() to use PATCH with query parameters
- Enhanced TeamService documentation with correct method signatures
- 33 comprehensive tests (100% passing)

Bug Fixes:
- Fixed modal send timing (immediate response vs deferred)
- Fixed interaction handling for cancel button
- Fixed database API communication (PATCH query params vs PUT JSON)

Files:
- commands/teams/branding.py (NEW - ~500 lines)
- commands/teams/__init__.py (added BrandingCommands registration)
- commands/teams/CLAUDE.md (added comprehensive documentation)
- tests/test_commands_teams_branding.py (NEW - 33 tests)
- services/team_service.py (fixed update_team to use query params)
- VERSION (2.19.2 → 2.20.0)

Docker: manticorum67/major-domo-discord-app-v2:2.20.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:39:52 -06:00

678 lines
23 KiB
Python

"""
Team Branding Management Commands
Allows team owners to update their team's visual branding including colors and logos
for major league teams, minor league affiliates, and dice roll displays.
"""
import asyncio
import aiohttp
from typing import Optional, Tuple, Dict, List
import discord
from discord.ext import commands
from discord import app_commands
from config import get_config
from services import team_service
from models.team import Team
from utils.logging import get_contextual_logger
from utils.decorators import logged_command
from views.embeds import EmbedTemplate, EmbedColors
from views.confirmations import ConfirmationView
# ============================================================================
# Validation Functions
# ============================================================================
def validate_hex_color(color: str) -> Tuple[bool, str, str]:
"""
Validate hex color format.
Args:
color: Hex color string (with or without # prefix)
Returns:
Tuple of (is_valid, normalized_color, error_message)
- is_valid: True if validation passed
- normalized_color: Uppercase hex without # prefix
- error_message: Empty if valid, error description if invalid
"""
if not color:
return True, "", ""
# Strip # and whitespace
color = color.strip().lstrip('#')
# Check length
if len(color) != 6:
return False, "", "Color must be 6 characters (e.g., FF5733 or #FF5733)"
# Check characters are valid hex
if not all(c in '0123456789ABCDEFabcdef' for c in color):
return False, "", "Color must contain only hex digits (0-9, A-F)"
return True, color.upper(), ""
async def validate_image_url(url: str) -> Tuple[bool, str]:
"""
Validate image URL format and accessibility.
Args:
url: Image URL to validate
Returns:
Tuple of (is_valid, error_message)
- is_valid: True if validation passed
- error_message: Empty if valid, error description if invalid
"""
if not url:
return True, ""
# Format validation
if not url.startswith(('http://', 'https://')):
return False, "URL must start with http:// or https://"
valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.webp')
# Check extension (handle query parameters)
url_path = url.split('?')[0] # Strip query params
if not any(url_path.lower().endswith(ext) for ext in valid_extensions):
return False, f"URL must end with {', '.join(valid_extensions)}"
# Accessibility test
try:
async with aiohttp.ClientSession() as session:
async with session.head(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status != 200:
return False, f"URL returned status {resp.status} (not accessible)"
content_type = resp.headers.get('Content-Type', '')
if not content_type.startswith('image/'):
return False, "URL does not point to an image"
return True, ""
except asyncio.TimeoutError:
return False, "URL request timed out (5 seconds)"
except Exception as e:
return False, f"Unable to access URL: {str(e)}"
# ============================================================================
# Branding Modal
# ============================================================================
class BrandingModal(discord.ui.Modal, title="Team Branding"):
"""Modal form for collecting team branding updates."""
def __init__(
self,
cog, # BrandingCommands instance
ml_team: Team,
mil_team: Optional[Team]
):
"""
Initialize branding modal with current team values.
Args:
cog: BrandingCommands cog instance (for access to service and logger)
ml_team: Major league team to update
mil_team: Minor league team (optional)
"""
super().__init__()
self.cog = cog
self.ml_team = ml_team
self.mil_team = mil_team
# Set placeholders to show current values
current_ml_color = ml_team.color or "Not set"
current_dice_color = ml_team.dice_color or "Not set"
current_mil_color = mil_team.color if mil_team else "No MiL team"
self.major_color.placeholder = f"Current: {current_ml_color}"
self.dice_color.placeholder = f"Current: {current_dice_color}"
self.minor_color.placeholder = f"Current: {current_mil_color}"
major_color = discord.ui.TextInput(
label="Major League Team Color",
placeholder="FF5733 or #FF5733",
max_length=7,
required=False,
style=discord.TextStyle.short
)
major_logo = discord.ui.TextInput(
label="Major League Logo URL",
placeholder="https://... (leave blank to keep current)",
max_length=500,
required=False,
style=discord.TextStyle.short
)
minor_color = discord.ui.TextInput(
label="Minor League Team Color",
placeholder="33C3FF or #33C3FF",
max_length=7,
required=False,
style=discord.TextStyle.short
)
minor_logo = discord.ui.TextInput(
label="Minor League Logo URL",
placeholder="https://... (leave blank to keep current)",
max_length=500,
required=False,
style=discord.TextStyle.short
)
dice_color = discord.ui.TextInput(
label="Dice Roll Color",
placeholder="A6CE39 or #A6CE39",
max_length=7,
required=False,
style=discord.TextStyle.short
)
async def on_submit(self, interaction: discord.Interaction):
"""Handle modal submission and validation."""
await interaction.response.defer()
# Collect modal data
modal_data = {
'major_color': self.major_color.value.strip(),
'major_logo': self.major_logo.value.strip(),
'minor_color': self.minor_color.value.strip(),
'minor_logo': self.minor_logo.value.strip(),
'dice_color': self.dice_color.value.strip(),
}
# Check if any fields were filled
if not any(modal_data.values()):
embed = EmbedTemplate.info(
"No Changes",
"No branding changes were specified. All fields were left blank."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Validate and process
await self.cog._process_branding_update(
interaction,
self.ml_team,
self.mil_team,
modal_data
)
# ============================================================================
# Branding Commands Cog
# ============================================================================
class BrandingCommands(commands.Cog):
"""Team branding management command handlers."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.BrandingCommands')
self.logger.info("BrandingCommands cog initialized")
@app_commands.command(name="branding", description="Update your team's colors and logos")
@logged_command("/branding")
async def team_branding(self, interaction: discord.Interaction):
"""
Update team branding including colors and logos.
Team owners can update:
- Major league team color and logo
- Minor league team color and logo
- Dice roll color
"""
# Get current season
config = get_config()
season = config.sba_current_season
# Verify user owns a team (must do this BEFORE responding to interaction)
ml_team = await team_service.get_team_by_owner(interaction.user.id, season)
if not ml_team:
self.logger.info("User does not own a team", user_id=interaction.user.id)
embed = EmbedTemplate.error(
"Not a Team Owner",
"You don't own a team in the current season."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Get minor league affiliate (if exists)
mil_team = None
try:
mil_team = await ml_team.minor_league_affiliate()
self.logger.info("Found minor league affiliate", mil_team_id=mil_team.id)
except ValueError:
# No MiL affiliate - this is OK
self.logger.info("No minor league affiliate found for team", team_id=ml_team.id)
# Show branding modal as immediate response
modal = BrandingModal(
cog=self,
ml_team=ml_team,
mil_team=mil_team
)
# Send modal as the interaction response
await interaction.response.send_modal(modal)
async def _process_branding_update(
self,
interaction: discord.Interaction,
ml_team: Team,
mil_team: Optional[Team],
modal_data: Dict[str, str]
):
"""
Process and validate branding update from modal.
Args:
interaction: Discord interaction
ml_team: Major league team to update
mil_team: Minor league team (optional)
modal_data: Dictionary of modal field values
"""
# Validate all inputs
updates, errors = await self._validate_all_inputs(modal_data)
# Show validation errors
if errors:
error_text = "\n".join(errors)
embed = EmbedTemplate.error(
"Validation Errors",
f"{error_text}\n\n💡 **Tip:** Leave fields blank to keep current values"
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Check if there are any actual updates
if not updates['major'] and not updates['minor']:
embed = EmbedTemplate.info(
"No Changes",
"No valid branding changes were specified."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Create preview embeds
embeds = await self._create_preview_embeds(ml_team, mil_team, updates)
# Show confirmation dialog
confirmation_view = ConfirmationView(
responders=[interaction.user],
timeout=60.0,
confirm_label="Apply Changes",
cancel_label="Cancel"
)
confirmation_message = await interaction.followup.send(
content="🎨 **Branding Preview** - Review and confirm changes:",
embeds=embeds,
view=confirmation_view,
wait=True
)
await confirmation_view.wait()
if not confirmation_view.confirmed:
embed = EmbedTemplate.info(
"Cancelled",
"Branding changes were not applied."
)
await confirmation_message.edit(content=None, embeds=[embed], view=None)
return
# Apply updates
await self._apply_branding_updates(interaction, ml_team, mil_team, updates, confirmation_message)
async def _validate_all_inputs(
self,
modal_data: Dict[str, str]
) -> Tuple[Dict[str, Dict[str, str]], List[str]]:
"""
Validate all modal inputs.
Args:
modal_data: Dictionary of modal field values
Returns:
Tuple of (updates_dict, error_list)
- updates_dict: {'major': {...}, 'minor': {...}}
- error_list: List of error messages
"""
updates = {
'major': {},
'minor': {},
}
errors = []
# Validate major league color
if modal_data['major_color']:
valid, normalized, error = validate_hex_color(modal_data['major_color'])
if not valid:
errors.append(f"**Major Team Color:** {error}")
else:
updates['major']['color'] = normalized
# Validate dice color
if modal_data['dice_color']:
valid, normalized, error = validate_hex_color(modal_data['dice_color'])
if not valid:
errors.append(f"**Dice Roll Color:** {error}")
else:
updates['major']['dice_color'] = normalized
# Validate minor league color
if modal_data['minor_color']:
valid, normalized, error = validate_hex_color(modal_data['minor_color'])
if not valid:
errors.append(f"**Minor Team Color:** {error}")
else:
updates['minor']['color'] = normalized
# Collect URLs for concurrent validation
url_validations = []
if modal_data['major_logo']:
url_validations.append(('major_logo', 'Major Team Logo', modal_data['major_logo']))
if modal_data['minor_logo']:
url_validations.append(('minor_logo', 'Minor Team Logo', modal_data['minor_logo']))
# Validate all URLs concurrently
if url_validations:
tasks = [validate_image_url(url) for _, _, url in url_validations]
results = await asyncio.gather(*tasks)
for (field, label, url), (valid, error) in zip(url_validations, results):
if not valid:
errors.append(f"**{label}:** {error}")
else:
# Add to appropriate updates dict
if field == 'major_logo':
updates['major']['thumbnail'] = url
elif field == 'minor_logo':
updates['minor']['thumbnail'] = url
return updates, errors
async def _create_preview_embeds(
self,
ml_team: Team,
mil_team: Optional[Team],
updates: Dict[str, Dict[str, str]]
) -> List[discord.Embed]:
"""
Create preview embeds showing branding changes.
Args:
ml_team: Major league team
mil_team: Minor league team (optional)
updates: Updates dictionary from validation
Returns:
List of 1-3 embeds showing previews
"""
embeds = []
# Major league preview
if updates['major']:
# Determine preview color (use new color if provided, else current)
if 'color' in updates['major']:
preview_color = int(updates['major']['color'], 16)
elif ml_team.color:
preview_color = int(ml_team.color, 16)
else:
preview_color = EmbedColors.PRIMARY
# Determine preview logo
preview_logo = updates['major'].get('thumbnail', ml_team.thumbnail)
embed = EmbedTemplate.create_base_embed(
title=f"{ml_team.lname} - Preview",
description="Major League Team Branding",
color=preview_color
)
if preview_logo:
embed.set_thumbnail(url=preview_logo)
# Add fields showing what's changing
changes = []
if 'color' in updates['major']:
changes.append(f"**Team Color:** #{updates['major']['color']}")
if 'thumbnail' in updates['major']:
changes.append(f"**Team Logo:** Updated ✓")
if 'dice_color' in updates['major']:
changes.append(f"**Dice Color:** #{updates['major']['dice_color']}")
if changes:
embed.add_field(
name="Changes",
value="\n".join(changes),
inline=False
)
embeds.append(embed)
# Minor league preview (if applicable)
if mil_team and updates['minor']:
# Determine preview color
if 'color' in updates['minor']:
preview_color = int(updates['minor']['color'], 16)
elif mil_team.color:
preview_color = int(mil_team.color, 16)
else:
preview_color = EmbedColors.PRIMARY
# Determine preview logo
preview_logo = updates['minor'].get('thumbnail', mil_team.thumbnail)
embed = EmbedTemplate.create_base_embed(
title=f"{mil_team.lname} - Preview",
description="Minor League Team Branding",
color=preview_color
)
if preview_logo:
embed.set_thumbnail(url=preview_logo)
# Add fields showing what's changing
changes = []
if 'color' in updates['minor']:
changes.append(f"**Team Color:** #{updates['minor']['color']}")
if 'thumbnail' in updates['minor']:
changes.append(f"**Team Logo:** Updated ✓")
if changes:
embed.add_field(
name="Changes",
value="\n".join(changes),
inline=False
)
embeds.append(embed)
# Dice color preview (if updated and different from team color)
if 'dice_color' in updates.get('major', {}):
dice_color_int = int(updates['major']['dice_color'], 16)
dice_embed = EmbedTemplate.create_base_embed(
title=f"{ml_team.lname} - Dice Color Preview",
description="This color will be used for dice rolls in gameplay",
color=dice_color_int
)
dice_embed.add_field(
name="Dice Color",
value=f"#{updates['major']['dice_color']}",
inline=False
)
embeds.append(dice_embed)
return embeds
async def _apply_branding_updates(
self,
interaction: discord.Interaction,
ml_team: Team,
mil_team: Optional[Team],
updates: Dict[str, Dict[str, str]],
confirmation_message
):
"""
Apply branding updates to database and Discord.
Args:
interaction: Discord interaction
ml_team: Major league team to update
mil_team: Minor league team (optional)
updates: Updates dictionary from validation
confirmation_message: The message to update with results
"""
role_updated = False
role_error = None
# Update major league team
if updates['major']:
self.logger.info("Updating major league team", team_id=ml_team.id, updates=updates['major'])
updated_ml = await team_service.update_team(ml_team.id, updates['major'])
if not updated_ml:
self.logger.error("Failed to update major league team", team_id=ml_team.id)
embed = EmbedTemplate.error(
"Update Failed",
"Failed to update major league team branding. Please try again."
)
await confirmation_message.edit(content=None, embeds=[embed], view=None)
return
# Update Discord role color (non-blocking)
if 'color' in updates['major']:
role_updated, role_error = await self._update_discord_role_color(
interaction,
ml_team,
updates['major']['color']
)
# Update minor league team (if applicable)
if mil_team and updates['minor']:
self.logger.info("Updating minor league team", team_id=mil_team.id, updates=updates['minor'])
await team_service.update_team(mil_team.id, updates['minor'])
# Create success message
success_message = self._format_success_message(updates, role_updated, role_error)
embed = EmbedTemplate.success(
"Branding Updated",
success_message
)
await confirmation_message.edit(content=None, embeds=[embed], view=None)
async def _update_discord_role_color(
self,
interaction: discord.Interaction,
team: Team,
hex_color: str
) -> Tuple[bool, Optional[str]]:
"""
Update Discord role color for team (non-blocking).
Args:
interaction: Discord interaction
team: Team whose role to update
hex_color: New hex color (without # prefix)
Returns:
Tuple of (success, error_message)
"""
try:
# Find role by team long name
role = discord.utils.get(
interaction.guild.roles,
name=team.lname
)
if not role:
self.logger.warning("Discord role not found", team_name=team.lname)
return False, "Discord role not found"
# Convert hex to int
color_int = int(hex_color, 16)
# Update role
await role.edit(colour=color_int)
self.logger.info("Discord role color updated", team_name=team.lname, color=hex_color)
return True, None
except discord.Forbidden:
self.logger.warning("Missing permissions to edit role", team_name=team.lname)
return False, "Missing permissions to edit role"
except Exception as e:
self.logger.warning(f"Failed to update Discord role color: {e}", team_name=team.lname)
return False, str(e)
def _format_success_message(
self,
updates: Dict[str, Dict[str, str]],
role_updated: bool,
role_error: Optional[str]
) -> str:
"""
Format success message showing all applied changes.
Args:
updates: Updates dictionary
role_updated: Whether Discord role was updated
role_error: Error message if role update failed
Returns:
Formatted success message string
"""
lines = []
# Major league updates
if updates['major']:
lines.append("**Major League:**")
if 'color' in updates['major']:
lines.append(f"• Color: #{updates['major']['color']}")
if 'thumbnail' in updates['major']:
lines.append(f"• Logo: Updated ✅")
if 'dice_color' in updates['major']:
lines.append(f"• Dice Color: #{updates['major']['dice_color']}")
# Discord role status
if 'color' in updates['major']:
if role_updated:
lines.append(f"• Discord role: Updated ✅")
else:
lines.append(f"• Discord role: Failed ({role_error}) ⚠️")
# Minor league updates
if updates['minor']:
lines.append("\n**Minor League:**")
if 'color' in updates['minor']:
lines.append(f"• Color: #{updates['minor']['color']}")
if 'thumbnail' in updates['minor']:
lines.append(f"• Logo: Updated ✅")
# Warning about role update failure
if role_error:
lines.append("\n⚠️ **Note:** Discord role color update failed, but database was updated successfully.")
return "\n".join(lines)
async def setup(bot: commands.Bot):
"""Load the branding commands cog."""
await bot.add_cog(BrandingCommands(bot))