fix: batch quick-wins — 4 issues resolved (closes #37, #27, #25, #38)

- #37: Fix stale comment in transaction_freeze.py referencing wrong moveid format
- #27: Change config.testing default from True to False (was masking prod behavior)
- #25: Replace deprecated asyncio.get_event_loop() with get_running_loop()
- #38: Replace naive datetime.now() with timezone-aware datetime.now(UTC) across
  7 source files and 4 test files to prevent subtle timezone bugs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-20 11:48:16 -06:00
parent f64fee8d2e
commit 9cd577cba1
16 changed files with 1785 additions and 1511 deletions

View File

@ -3,10 +3,11 @@ Draft Pick Commands
Implements slash commands for making draft picks with global lock protection. Implements slash commands for making draft picks with global lock protection.
""" """
import asyncio import asyncio
import re import re
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import UTC, datetime
import discord import discord
from discord.ext import commands from discord.ext import commands
@ -27,7 +28,7 @@ from views.draft_views import (
create_player_draft_card, create_player_draft_card,
create_pick_illegal_embed, create_pick_illegal_embed,
create_pick_success_embed, create_pick_success_embed,
create_on_clock_announcement_embed create_on_clock_announcement_embed,
) )
@ -53,7 +54,7 @@ def _parse_player_name(raw_input: str) -> str:
# Pattern: "Player Name (POS) - X.XX sWAR" # Pattern: "Player Name (POS) - X.XX sWAR"
# Position can be letters or numbers (e.g., SS, RP, 1B, 2B, 3B, OF) # Position can be letters or numbers (e.g., SS, RP, 1B, 2B, 3B, OF)
# Extract just the player name before the opening parenthesis # Extract just the player name before the opening parenthesis
match = re.match(r'^(.+?)\s*\([A-Z0-9]+\)\s*-\s*[\d.]+\s*sWAR$', raw_input) match = re.match(r"^(.+?)\s*\([A-Z0-9]+\)\s*-\s*[\d.]+\s*sWAR$", raw_input)
if match: if match:
return match.group(1).strip() return match.group(1).strip()
@ -73,9 +74,7 @@ async def fa_player_autocomplete(
config = get_config() config = get_config()
# Search for FA players only # Search for FA players only
players = await player_service.search_players( players = await player_service.search_players(
current, current, limit=25, season=config.sba_season
limit=25,
season=config.sba_season
) )
# Filter to FA team # Filter to FA team
@ -84,7 +83,7 @@ async def fa_player_autocomplete(
return [ return [
discord.app_commands.Choice( discord.app_commands.Choice(
name=f"{p.name} ({p.primary_position}) - {p.wara:.2f} sWAR", name=f"{p.name} ({p.primary_position}) - {p.wara:.2f} sWAR",
value=p.name value=p.name,
) )
for p in fa_players[:25] for p in fa_players[:25]
] ]
@ -98,7 +97,7 @@ class DraftPicksCog(commands.Cog):
def __init__(self, bot: commands.Bot): def __init__(self, bot: commands.Bot):
self.bot = bot self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.DraftPicksCog') self.logger = get_contextual_logger(f"{__name__}.DraftPicksCog")
# GLOBAL PICK LOCK (local only - not in database) # GLOBAL PICK LOCK (local only - not in database)
self.pick_lock = asyncio.Lock() self.pick_lock = asyncio.Lock()
@ -107,7 +106,7 @@ class DraftPicksCog(commands.Cog):
@discord.app_commands.command( @discord.app_commands.command(
name="draft", name="draft",
description="Make a draft pick (autocomplete shows FA players only)" description="Make a draft pick (autocomplete shows FA players only)",
) )
@discord.app_commands.describe( @discord.app_commands.describe(
player="Player name to draft (autocomplete shows available FA players)" player="Player name to draft (autocomplete shows available FA players)"
@ -116,18 +115,14 @@ class DraftPicksCog(commands.Cog):
@requires_draft_period @requires_draft_period
@requires_team() @requires_team()
@logged_command("/draft") @logged_command("/draft")
async def draft_pick( async def draft_pick(self, interaction: discord.Interaction, player: str):
self,
interaction: discord.Interaction,
player: str
):
"""Make a draft pick with global lock protection.""" """Make a draft pick with global lock protection."""
await interaction.response.defer() await interaction.response.defer()
# Check if lock is held # Check if lock is held
if self.pick_lock.locked(): if self.pick_lock.locked():
if self.lock_acquired_at: if self.lock_acquired_at:
time_held = (datetime.now() - self.lock_acquired_at).total_seconds() time_held = (datetime.now(UTC) - self.lock_acquired_at).total_seconds()
if time_held > 30: if time_held > 30:
# STALE LOCK: Auto-override after 30 seconds # STALE LOCK: Auto-override after 30 seconds
@ -140,14 +135,14 @@ class DraftPicksCog(commands.Cog):
embed = await create_pick_illegal_embed( embed = await create_pick_illegal_embed(
"Pick In Progress", "Pick In Progress",
f"Another manager is currently making a pick. " f"Another manager is currently making a pick. "
f"Please wait approximately {30 - int(time_held)} seconds." f"Please wait approximately {30 - int(time_held)} seconds.",
) )
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
# Acquire global lock # Acquire global lock
async with self.pick_lock: async with self.pick_lock:
self.lock_acquired_at = datetime.now() self.lock_acquired_at = datetime.now(UTC)
self.lock_acquired_by = interaction.user.id self.lock_acquired_by = interaction.user.id
try: try:
@ -157,9 +152,7 @@ class DraftPicksCog(commands.Cog):
self.lock_acquired_by = None self.lock_acquired_by = None
async def _process_draft_pick( async def _process_draft_pick(
self, self, interaction: discord.Interaction, player_name: str
interaction: discord.Interaction,
player_name: str
): ):
""" """
Process draft pick with validation. Process draft pick with validation.
@ -176,14 +169,12 @@ class DraftPicksCog(commands.Cog):
# Get user's team (CACHED via @cached_single_item) # Get user's team (CACHED via @cached_single_item)
team = await team_service.get_team_by_owner( team = await team_service.get_team_by_owner(
interaction.user.id, interaction.user.id, config.sba_season
config.sba_season
) )
if not team: if not team:
embed = await create_pick_illegal_embed( embed = await create_pick_illegal_embed(
"Not a GM", "Not a GM", "You are not registered as a team owner."
"You are not registered as a team owner."
) )
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
@ -192,8 +183,7 @@ class DraftPicksCog(commands.Cog):
draft_data = await draft_service.get_draft_data() draft_data = await draft_service.get_draft_data()
if not draft_data: if not draft_data:
embed = await create_pick_illegal_embed( embed = await create_pick_illegal_embed(
"Draft Not Found", "Draft Not Found", "Could not retrieve draft configuration."
"Could not retrieve draft configuration."
) )
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
@ -202,21 +192,19 @@ class DraftPicksCog(commands.Cog):
if draft_data.paused: if draft_data.paused:
embed = await create_pick_illegal_embed( embed = await create_pick_illegal_embed(
"Draft Paused", "Draft Paused",
"The draft is currently paused. Please wait for an administrator to resume." "The draft is currently paused. Please wait for an administrator to resume.",
) )
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
# Get current pick # Get current pick
current_pick = await draft_pick_service.get_pick( current_pick = await draft_pick_service.get_pick(
config.sba_season, config.sba_season, draft_data.currentpick
draft_data.currentpick
) )
if not current_pick or not current_pick.owner: if not current_pick or not current_pick.owner:
embed = await create_pick_illegal_embed( embed = await create_pick_illegal_embed(
"Invalid Pick", "Invalid Pick", f"Could not retrieve pick #{draft_data.currentpick}."
f"Could not retrieve pick #{draft_data.currentpick}."
) )
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
@ -227,16 +215,14 @@ class DraftPicksCog(commands.Cog):
if current_pick.owner.id != team.id: if current_pick.owner.id != team.id:
# Not on the clock - check for skipped picks # Not on the clock - check for skipped picks
skipped_picks = await draft_pick_service.get_skipped_picks_for_team( skipped_picks = await draft_pick_service.get_skipped_picks_for_team(
config.sba_season, config.sba_season, team.id, draft_data.currentpick
team.id,
draft_data.currentpick
) )
if not skipped_picks: if not skipped_picks:
# No skipped picks - can't draft # No skipped picks - can't draft
embed = await create_pick_illegal_embed( embed = await create_pick_illegal_embed(
"Not Your Turn", "Not Your Turn",
f"{current_pick.owner.sname} is on the clock for {format_pick_display(current_pick.overall)}." f"{current_pick.owner.sname} is on the clock for {format_pick_display(current_pick.overall)}.",
) )
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
@ -249,12 +235,13 @@ class DraftPicksCog(commands.Cog):
) )
# Get player # Get player
players = await player_service.get_players_by_name(player_name, config.sba_season) players = await player_service.get_players_by_name(
player_name, config.sba_season
)
if not players: if not players:
embed = await create_pick_illegal_embed( embed = await create_pick_illegal_embed(
"Player Not Found", "Player Not Found", f"Could not find player '{player_name}'."
f"Could not find player '{player_name}'."
) )
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
@ -264,55 +251,52 @@ class DraftPicksCog(commands.Cog):
# Validate player is FA # Validate player is FA
if player_obj.team_id != config.free_agent_team_id: if player_obj.team_id != config.free_agent_team_id:
embed = await create_pick_illegal_embed( embed = await create_pick_illegal_embed(
"Player Not Available", "Player Not Available", f"{player_obj.name} is not a free agent."
f"{player_obj.name} is not a free agent."
) )
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
# Validate cap space # Validate cap space
roster = await team_service.get_team_roster(team.id, 'current') roster = await team_service.get_team_roster(team.id, "current")
if not roster: if not roster:
embed = await create_pick_illegal_embed( embed = await create_pick_illegal_embed(
"Roster Error", "Roster Error", f"Could not retrieve roster for {team.abbrev}."
f"Could not retrieve roster for {team.abbrev}."
) )
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
is_valid, projected_total, cap_limit = await validate_cap_space(roster, player_obj.wara, team) is_valid, projected_total, cap_limit = await validate_cap_space(
roster, player_obj.wara, team
)
if not is_valid: if not is_valid:
embed = await create_pick_illegal_embed( embed = await create_pick_illegal_embed(
"Cap Space Exceeded", "Cap Space Exceeded",
f"Drafting {player_obj.name} would put you at {projected_total:.2f} sWAR (limit: {cap_limit:.2f})." f"Drafting {player_obj.name} would put you at {projected_total:.2f} sWAR (limit: {cap_limit:.2f}).",
) )
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
# Execute pick (using pick_to_use which may be current or skipped pick) # Execute pick (using pick_to_use which may be current or skipped pick)
updated_pick = await draft_pick_service.update_pick_selection( updated_pick = await draft_pick_service.update_pick_selection(
pick_to_use.id, pick_to_use.id, player_obj.id
player_obj.id
) )
if not updated_pick: if not updated_pick:
embed = await create_pick_illegal_embed( embed = await create_pick_illegal_embed(
"Pick Failed", "Pick Failed", "Failed to update draft pick. Please try again."
"Failed to update draft pick. Please try again."
) )
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
return return
# Get current league state for dem_week calculation # Get current league state for dem_week calculation
from services.league_service import league_service from services.league_service import league_service
current = await league_service.get_current_state() current = await league_service.get_current_state()
# Update player team with dem_week set to current.week + 2 for draft picks # Update player team with dem_week set to current.week + 2 for draft picks
updated_player = await player_service.update_player_team( updated_player = await player_service.update_player_team(
player_obj.id, player_obj.id, team.id, dem_week=current.week + 2 if current else None
team.id,
dem_week=current.week + 2 if current else None
) )
if not updated_player: if not updated_player:
@ -324,7 +308,7 @@ class DraftPicksCog(commands.Cog):
pick=pick_to_use, pick=pick_to_use,
player=player_obj, player=player_obj,
team=team, team=team,
guild=interaction.guild guild=interaction.guild,
) )
# Determine if this was a skipped pick # Determine if this was a skipped pick
@ -332,11 +316,7 @@ class DraftPicksCog(commands.Cog):
# Send success message # Send success message
success_embed = await create_pick_success_embed( success_embed = await create_pick_success_embed(
player_obj, player_obj, team, pick_to_use.overall, projected_total, cap_limit
team,
pick_to_use.overall,
projected_total,
cap_limit
) )
# Add note if this was a skipped pick # Add note if this was a skipped pick
@ -348,7 +328,10 @@ class DraftPicksCog(commands.Cog):
await interaction.followup.send(embed=success_embed) await interaction.followup.send(embed=success_embed)
# Post draft card to ping channel (only if different from command channel) # Post draft card to ping channel (only if different from command channel)
if draft_data.ping_channel and draft_data.ping_channel != interaction.channel_id: if (
draft_data.ping_channel
and draft_data.ping_channel != interaction.channel_id
):
guild = interaction.guild guild = interaction.guild
if guild: if guild:
ping_channel = guild.get_channel(draft_data.ping_channel) ping_channel = guild.get_channel(draft_data.ping_channel)
@ -369,7 +352,9 @@ class DraftPicksCog(commands.Cog):
if guild: if guild:
result_channel = guild.get_channel(draft_data.result_channel) result_channel = guild.get_channel(draft_data.result_channel)
if result_channel: if result_channel:
result_card = await create_player_draft_card(player_obj, pick_to_use) result_card = await create_player_draft_card(
player_obj, pick_to_use
)
# Add skipped pick context to result card # Add skipped pick context to result card
if is_skipped_pick: if is_skipped_pick:
@ -379,7 +364,9 @@ class DraftPicksCog(commands.Cog):
await result_channel.send(embed=result_card) await result_channel.send(embed=result_card)
else: else:
self.logger.warning(f"Could not find result channel {draft_data.result_channel}") self.logger.warning(
f"Could not find result channel {draft_data.result_channel}"
)
# Only advance the draft if this was the current pick (not a skipped pick) # Only advance the draft if this was the current pick (not a skipped pick)
if not is_skipped_pick: if not is_skipped_pick:
@ -391,8 +378,7 @@ class DraftPicksCog(commands.Cog):
ping_channel = guild.get_channel(draft_data.ping_channel) ping_channel = guild.get_channel(draft_data.ping_channel)
if ping_channel: if ping_channel:
await self._post_on_clock_announcement( await self._post_on_clock_announcement(
ping_channel=ping_channel, ping_channel=ping_channel, guild=guild
guild=guild
) )
self.logger.info( self.logger.info(
@ -402,12 +388,7 @@ class DraftPicksCog(commands.Cog):
) )
async def _write_pick_to_sheets( async def _write_pick_to_sheets(
self, self, draft_data, pick, player, team, guild: Optional[discord.Guild]
draft_data,
pick,
player,
team,
guild: Optional[discord.Guild]
): ):
""" """
Write pick to Google Sheets (fire-and-forget with ping channel notification on failure). Write pick to Google Sheets (fire-and-forget with ping channel notification on failure).
@ -426,10 +407,12 @@ class DraftPicksCog(commands.Cog):
success = await draft_sheet_service.write_pick( success = await draft_sheet_service.write_pick(
season=config.sba_season, season=config.sba_season,
overall=pick.overall, overall=pick.overall,
orig_owner_abbrev=pick.origowner.abbrev if pick.origowner else team.abbrev, orig_owner_abbrev=(
pick.origowner.abbrev if pick.origowner else team.abbrev
),
owner_abbrev=team.abbrev, owner_abbrev=team.abbrev,
player_name=player.name, player_name=player.name,
swar=player.wara swar=player.wara,
) )
if not success: if not success:
@ -439,7 +422,7 @@ class DraftPicksCog(commands.Cog):
channel_id=draft_data.ping_channel, channel_id=draft_data.ping_channel,
pick_overall=pick.overall, pick_overall=pick.overall,
player_name=player.name, player_name=player.name,
reason="Sheet write returned failure" reason="Sheet write returned failure",
) )
except Exception as e: except Exception as e:
@ -450,7 +433,7 @@ class DraftPicksCog(commands.Cog):
channel_id=draft_data.ping_channel, channel_id=draft_data.ping_channel,
pick_overall=pick.overall, pick_overall=pick.overall,
player_name=player.name, player_name=player.name,
reason=str(e) reason=str(e),
) )
async def _notify_sheet_failure( async def _notify_sheet_failure(
@ -459,7 +442,7 @@ class DraftPicksCog(commands.Cog):
channel_id: Optional[int], channel_id: Optional[int],
pick_overall: int, pick_overall: int,
player_name: str, player_name: str,
reason: str reason: str,
): ):
""" """
Post notification to ping channel when sheet write fails. Post notification to ping channel when sheet write fails.
@ -476,7 +459,7 @@ class DraftPicksCog(commands.Cog):
try: try:
channel = guild.get_channel(channel_id) channel = guild.get_channel(channel_id)
if channel and hasattr(channel, 'send'): if channel and hasattr(channel, "send"):
await channel.send( await channel.send(
f"⚠️ **Sheet Sync Failed** - Pick #{pick_overall} ({player_name}) " f"⚠️ **Sheet Sync Failed** - Pick #{pick_overall} ({player_name}) "
f"was not written to the draft sheet. " f"was not written to the draft sheet. "
@ -486,9 +469,7 @@ class DraftPicksCog(commands.Cog):
self.logger.error(f"Failed to send sheet failure notification: {e}") self.logger.error(f"Failed to send sheet failure notification: {e}")
async def _post_on_clock_announcement( async def _post_on_clock_announcement(
self, self, ping_channel, guild: discord.Guild
ping_channel,
guild: discord.Guild
) -> None: ) -> None:
""" """
Post the on-clock announcement embed for the next team with role ping. Post the on-clock announcement embed for the next team with role ping.
@ -510,23 +491,26 @@ class DraftPicksCog(commands.Cog):
# Get the new current pick # Get the new current pick
next_pick = await draft_pick_service.get_pick( next_pick = await draft_pick_service.get_pick(
config.sba_season, config.sba_season, updated_draft_data.currentpick
updated_draft_data.currentpick
) )
if not next_pick or not next_pick.owner: if not next_pick or not next_pick.owner:
self.logger.error(f"Could not get pick #{updated_draft_data.currentpick} for announcement") self.logger.error(
f"Could not get pick #{updated_draft_data.currentpick} for announcement"
)
return return
# Get recent picks (last 5 completed) # Get recent picks (last 5 completed)
recent_picks = await draft_pick_service.get_recent_picks( recent_picks = await draft_pick_service.get_recent_picks(
config.sba_season, config.sba_season,
updated_draft_data.currentpick - 1, # Start from previous pick updated_draft_data.currentpick - 1, # Start from previous pick
limit=5 limit=5,
) )
# Get team roster for sWAR calculation # Get team roster for sWAR calculation
team_roster = await roster_service.get_team_roster(next_pick.owner.id, "current") team_roster = await roster_service.get_team_roster(
next_pick.owner.id, "current"
)
roster_swar = team_roster.total_wara if team_roster else 0.0 roster_swar = team_roster.total_wara if team_roster else 0.0
cap_limit = get_team_salary_cap(next_pick.owner) cap_limit = get_team_salary_cap(next_pick.owner)
@ -534,7 +518,9 @@ class DraftPicksCog(commands.Cog):
top_roster_players = [] top_roster_players = []
if team_roster: if team_roster:
all_players = team_roster.all_players all_players = team_roster.all_players
sorted_players = sorted(all_players, key=lambda p: p.wara if p.wara else 0.0, reverse=True) sorted_players = sorted(
all_players, key=lambda p: p.wara if p.wara else 0.0, reverse=True
)
top_roster_players = sorted_players[:5] top_roster_players = sorted_players[:5]
# Get sheet URL # Get sheet URL
@ -548,7 +534,7 @@ class DraftPicksCog(commands.Cog):
roster_swar=roster_swar, roster_swar=roster_swar,
cap_limit=cap_limit, cap_limit=cap_limit,
top_roster_players=top_roster_players, top_roster_players=top_roster_players,
sheet_url=sheet_url sheet_url=sheet_url,
) )
# Mention the team's role (using team.lname) # Mention the team's role (using team.lname)
@ -557,10 +543,14 @@ class DraftPicksCog(commands.Cog):
if team_role: if team_role:
team_mention = f"{team_role.mention} " team_mention = f"{team_role.mention} "
else: else:
self.logger.warning(f"Could not find role for team {next_pick.owner.lname}") self.logger.warning(
f"Could not find role for team {next_pick.owner.lname}"
)
await ping_channel.send(content=team_mention, embed=embed) await ping_channel.send(content=team_mention, embed=embed)
self.logger.info(f"Posted on-clock announcement for pick #{updated_draft_data.currentpick}") self.logger.info(
f"Posted on-clock announcement for pick #{updated_draft_data.currentpick}"
)
except Exception as e: except Exception as e:
self.logger.error("Error posting on-clock announcement", error=e) self.logger.error("Error posting on-clock announcement", error=e)

View File

@ -87,7 +87,7 @@ class BotConfig(BaseSettings):
# Application settings # Application settings
log_level: str = "INFO" log_level: str = "INFO"
environment: str = "development" environment: str = "development"
testing: bool = True testing: bool = False
# Google Sheets settings # Google Sheets settings
sheets_credentials_path: str = "/app/data/major-domo-service-creds.json" sheets_credentials_path: str = "/app/data/major-domo-service-creds.json"

View File

@ -3,7 +3,8 @@ Custom Command models for Discord Bot v2.0
Modern Pydantic models for the custom command system with full type safety. Modern Pydantic models for the custom command system with full type safety.
""" """
from datetime import datetime
from datetime import UTC, datetime
from typing import Optional from typing import Optional
import re import re
@ -13,27 +14,33 @@ from models.base import SBABaseModel
class CustomCommandCreator(SBABaseModel): class CustomCommandCreator(SBABaseModel):
"""Creator of custom commands.""" """Creator of custom commands."""
id: int = Field(..., description="Database ID") # type: ignore
id: int = Field(..., description="Database ID") # type: ignore
discord_id: int = Field(..., description="Discord user ID") discord_id: int = Field(..., description="Discord user ID")
username: str = Field(..., description="Discord username") username: str = Field(..., description="Discord username")
display_name: Optional[str] = Field(None, description="Discord display name") display_name: Optional[str] = Field(None, description="Discord display name")
created_at: datetime = Field(..., description="When creator was first recorded") # type: ignore created_at: datetime = Field(..., description="When creator was first recorded") # type: ignore
total_commands: int = Field(0, description="Total commands created by this user") total_commands: int = Field(0, description="Total commands created by this user")
active_commands: int = Field(0, description="Currently active commands") active_commands: int = Field(0, description="Currently active commands")
class CustomCommand(SBABaseModel): class CustomCommand(SBABaseModel):
"""A custom command created by a user.""" """A custom command created by a user."""
id: int = Field(..., description="Database ID") # type: ignore
id: int = Field(..., description="Database ID") # type: ignore
name: str = Field(..., description="Command name (unique)") name: str = Field(..., description="Command name (unique)")
content: str = Field(..., description="Command response content") content: str = Field(..., description="Command response content")
creator_id: Optional[int] = Field(None, description="ID of the creator (may be missing from execute endpoint)") creator_id: Optional[int] = Field(
None, description="ID of the creator (may be missing from execute endpoint)"
)
creator: Optional[CustomCommandCreator] = Field(None, description="Creator details") creator: Optional[CustomCommandCreator] = Field(None, description="Creator details")
# Timestamps # Timestamps
created_at: datetime = Field(..., description="When command was created") # type: ignore created_at: datetime = Field(..., description="When command was created") # type: ignore
updated_at: Optional[datetime] = Field(None, description="When command was last updated") # type: ignore updated_at: Optional[datetime] = Field(None, description="When command was last updated") # type: ignore
last_used: Optional[datetime] = Field(None, description="When command was last executed") last_used: Optional[datetime] = Field(
None, description="When command was last executed"
)
# Usage tracking # Usage tracking
use_count: int = Field(0, description="Total times command has been used") use_count: int = Field(0, description="Total times command has been used")
@ -41,9 +48,11 @@ class CustomCommand(SBABaseModel):
# Metadata # Metadata
is_active: bool = Field(True, description="Whether command is currently active") is_active: bool = Field(True, description="Whether command is currently active")
tags: Optional[list[str]] = Field(None, description="Optional tags for categorization") tags: Optional[list[str]] = Field(
None, description="Optional tags for categorization"
)
@field_validator('name') @field_validator("name")
@classmethod @classmethod
def validate_name(cls, v): def validate_name(cls, v):
"""Validate command name.""" """Validate command name."""
@ -59,20 +68,33 @@ class CustomCommand(SBABaseModel):
raise ValueError("Command name cannot exceed 32 characters") raise ValueError("Command name cannot exceed 32 characters")
# Character validation - only allow alphanumeric, dashes, underscores # Character validation - only allow alphanumeric, dashes, underscores
if not re.match(r'^[a-z0-9_-]+$', name): if not re.match(r"^[a-z0-9_-]+$", name):
raise ValueError("Command name can only contain letters, numbers, dashes, and underscores") raise ValueError(
"Command name can only contain letters, numbers, dashes, and underscores"
)
# Reserved names # Reserved names
reserved = { reserved = {
'help', 'ping', 'info', 'list', 'create', 'delete', 'edit', "help",
'admin', 'mod', 'owner', 'bot', 'system', 'config' "ping",
"info",
"list",
"create",
"delete",
"edit",
"admin",
"mod",
"owner",
"bot",
"system",
"config",
} }
if name in reserved: if name in reserved:
raise ValueError(f"'{name}' is a reserved command name") raise ValueError(f"'{name}' is a reserved command name")
return name.lower() return name.lower()
@field_validator('content') @field_validator("content")
@classmethod @classmethod
def validate_content(cls, v): def validate_content(cls, v):
"""Validate command content.""" """Validate command content."""
@ -86,7 +108,7 @@ class CustomCommand(SBABaseModel):
raise ValueError("Command content cannot exceed 2000 characters") raise ValueError("Command content cannot exceed 2000 characters")
# Basic content filtering # Basic content filtering
prohibited = ['@everyone', '@here'] prohibited = ["@everyone", "@here"]
content_lower = content.lower() content_lower = content.lower()
for term in prohibited: for term in prohibited:
if term in content_lower: if term in content_lower:
@ -99,21 +121,21 @@ class CustomCommand(SBABaseModel):
"""Calculate days since last use.""" """Calculate days since last use."""
if not self.last_used: if not self.last_used:
return None return None
return (datetime.now() - self.last_used).days return (datetime.now(UTC) - self.last_used).days
@property @property
def is_eligible_for_warning(self) -> bool: def is_eligible_for_warning(self) -> bool:
"""Check if command is eligible for deletion warning.""" """Check if command is eligible for deletion warning."""
if not self.last_used or self.warning_sent: if not self.last_used or self.warning_sent:
return False return False
return self.days_since_last_use >= 60 # type: ignore return self.days_since_last_use >= 60 # type: ignore
@property @property
def is_eligible_for_deletion(self) -> bool: def is_eligible_for_deletion(self) -> bool:
"""Check if command is eligible for deletion.""" """Check if command is eligible for deletion."""
if not self.last_used: if not self.last_used:
return False return False
return self.days_since_last_use >= 90 # type: ignore return self.days_since_last_use >= 90 # type: ignore
@property @property
def popularity_score(self) -> float: def popularity_score(self) -> float:
@ -127,11 +149,11 @@ class CustomCommand(SBABaseModel):
# Recency modifier # Recency modifier
if self.last_used: if self.last_used:
days_ago = self.days_since_last_use days_ago = self.days_since_last_use
if days_ago <= 7: # type: ignore if days_ago <= 7: # type: ignore
recency_modifier = 1.5 # Recent use bonus recency_modifier = 1.5 # Recent use bonus
elif days_ago <= 30: # type: ignore elif days_ago <= 30: # type: ignore
recency_modifier = 1.0 # No modifier recency_modifier = 1.0 # No modifier
elif days_ago <= 60: # type: ignore elif days_ago <= 60: # type: ignore
recency_modifier = 0.7 # Slight penalty recency_modifier = 0.7 # Slight penalty
else: else:
recency_modifier = 0.3 # Old command penalty recency_modifier = 0.3 # Old command penalty
@ -143,6 +165,7 @@ class CustomCommand(SBABaseModel):
class CustomCommandSearchFilters(BaseModel): class CustomCommandSearchFilters(BaseModel):
"""Filters for searching custom commands.""" """Filters for searching custom commands."""
name_contains: Optional[str] = None name_contains: Optional[str] = None
creator_id: Optional[int] = None creator_id: Optional[int] = None
creator_name: Optional[str] = None creator_name: Optional[str] = None
@ -152,23 +175,33 @@ class CustomCommandSearchFilters(BaseModel):
is_active: bool = True is_active: bool = True
# Sorting options # Sorting options
sort_by: str = Field('name', description="Sort field: name, created_at, last_used, use_count, popularity") sort_by: str = Field(
"name",
description="Sort field: name, created_at, last_used, use_count, popularity",
)
sort_desc: bool = Field(False, description="Sort in descending order") sort_desc: bool = Field(False, description="Sort in descending order")
# Pagination # Pagination
page: int = Field(1, description="Page number (1-based)") page: int = Field(1, description="Page number (1-based)")
page_size: int = Field(25, description="Items per page") page_size: int = Field(25, description="Items per page")
@field_validator('sort_by') @field_validator("sort_by")
@classmethod @classmethod
def validate_sort_by(cls, v): def validate_sort_by(cls, v):
"""Validate sort field.""" """Validate sort field."""
valid_sorts = {'name', 'created_at', 'last_used', 'use_count', 'popularity', 'creator'} valid_sorts = {
"name",
"created_at",
"last_used",
"use_count",
"popularity",
"creator",
}
if v not in valid_sorts: if v not in valid_sorts:
raise ValueError(f"sort_by must be one of: {', '.join(valid_sorts)}") raise ValueError(f"sort_by must be one of: {', '.join(valid_sorts)}")
return v return v
@field_validator('page') @field_validator("page")
@classmethod @classmethod
def validate_page(cls, v): def validate_page(cls, v):
"""Validate page number.""" """Validate page number."""
@ -176,7 +209,7 @@ class CustomCommandSearchFilters(BaseModel):
raise ValueError("Page number must be >= 1") raise ValueError("Page number must be >= 1")
return v return v
@field_validator('page_size') @field_validator("page_size")
@classmethod @classmethod
def validate_page_size(cls, v): def validate_page_size(cls, v):
"""Validate page size.""" """Validate page size."""
@ -187,6 +220,7 @@ class CustomCommandSearchFilters(BaseModel):
class CustomCommandSearchResult(BaseModel): class CustomCommandSearchResult(BaseModel):
"""Result of a custom command search.""" """Result of a custom command search."""
commands: list[CustomCommand] commands: list[CustomCommand]
total_count: int total_count: int
page: int page: int
@ -207,6 +241,7 @@ class CustomCommandSearchResult(BaseModel):
class CustomCommandStats(BaseModel): class CustomCommandStats(BaseModel):
"""Statistics about custom commands.""" """Statistics about custom commands."""
total_commands: int total_commands: int
active_commands: int active_commands: int
total_creators: int total_creators: int

View File

@ -3,8 +3,9 @@ Draft configuration and state model
Represents the current draft settings and timer state. Represents the current draft settings and timer state.
""" """
from typing import Optional from typing import Optional
from datetime import datetime from datetime import UTC, datetime
from pydantic import Field, field_validator from pydantic import Field, field_validator
from models.base import SBABaseModel from models.base import SBABaseModel
@ -15,10 +16,18 @@ class DraftData(SBABaseModel):
currentpick: int = Field(0, description="Current pick number in progress") currentpick: int = Field(0, description="Current pick number in progress")
timer: bool = Field(False, description="Whether draft timer is active") timer: bool = Field(False, description="Whether draft timer is active")
paused: bool = Field(False, description="Whether draft is paused (blocks all picks)") paused: bool = Field(
pick_deadline: Optional[datetime] = Field(None, description="Deadline for current pick") False, description="Whether draft is paused (blocks all picks)"
result_channel: Optional[int] = Field(None, description="Discord channel ID for draft results") )
ping_channel: Optional[int] = Field(None, description="Discord channel ID for draft pings") pick_deadline: Optional[datetime] = Field(
None, description="Deadline for current pick"
)
result_channel: Optional[int] = Field(
None, description="Discord channel ID for draft results"
)
ping_channel: Optional[int] = Field(
None, description="Discord channel ID for draft pings"
)
pick_minutes: int = Field(1, description="Minutes allowed per pick") pick_minutes: int = Field(1, description="Minutes allowed per pick")
@field_validator("result_channel", "ping_channel", mode="before") @field_validator("result_channel", "ping_channel", mode="before")
@ -41,7 +50,7 @@ class DraftData(SBABaseModel):
"""Check if the current pick deadline has passed.""" """Check if the current pick deadline has passed."""
if not self.pick_deadline: if not self.pick_deadline:
return False return False
return datetime.now() > self.pick_deadline return datetime.now(UTC) > self.pick_deadline
@property @property
def can_make_picks(self) -> bool: def can_make_picks(self) -> bool:

View File

@ -5,7 +5,8 @@ Modern Pydantic models for the custom help system with full type safety.
Allows admins and help editors to create custom help topics for league documentation, Allows admins and help editors to create custom help topics for league documentation,
resources, FAQs, links, and guides. resources, FAQs, links, and guides.
""" """
from datetime import datetime
from datetime import UTC, datetime
from typing import Optional from typing import Optional
import re import re
@ -15,6 +16,7 @@ from models.base import SBABaseModel
class HelpCommand(SBABaseModel): class HelpCommand(SBABaseModel):
"""A help topic created by an admin or help editor.""" """A help topic created by an admin or help editor."""
id: int = Field(..., description="Database ID") # type: ignore id: int = Field(..., description="Database ID") # type: ignore
name: str = Field(..., description="Help topic name (unique)") name: str = Field(..., description="Help topic name (unique)")
title: str = Field(..., description="Display title") title: str = Field(..., description="Display title")
@ -22,17 +24,23 @@ class HelpCommand(SBABaseModel):
category: Optional[str] = Field(None, description="Category for organization") category: Optional[str] = Field(None, description="Category for organization")
# Audit fields # Audit fields
created_by_discord_id: str = Field(..., description="Creator Discord ID (stored as text)") created_by_discord_id: str = Field(
..., description="Creator Discord ID (stored as text)"
)
created_at: datetime = Field(..., description="When help topic was created") # type: ignore created_at: datetime = Field(..., description="When help topic was created") # type: ignore
updated_at: Optional[datetime] = Field(None, description="When help topic was last updated") # type: ignore updated_at: Optional[datetime] = Field(None, description="When help topic was last updated") # type: ignore
last_modified_by: Optional[str] = Field(None, description="Discord ID of last editor (stored as text)") last_modified_by: Optional[str] = Field(
None, description="Discord ID of last editor (stored as text)"
)
# Status and metrics # Status and metrics
is_active: bool = Field(True, description="Whether help topic is active (soft delete)") is_active: bool = Field(
True, description="Whether help topic is active (soft delete)"
)
view_count: int = Field(0, description="Number of times viewed") view_count: int = Field(0, description="Number of times viewed")
display_order: int = Field(0, description="Sort order for display") display_order: int = Field(0, description="Sort order for display")
@field_validator('name') @field_validator("name")
@classmethod @classmethod
def validate_name(cls, v): def validate_name(cls, v):
"""Validate help topic name.""" """Validate help topic name."""
@ -48,12 +56,14 @@ class HelpCommand(SBABaseModel):
raise ValueError("Help topic name cannot exceed 32 characters") raise ValueError("Help topic name cannot exceed 32 characters")
# Character validation - only allow alphanumeric, dashes, underscores # Character validation - only allow alphanumeric, dashes, underscores
if not re.match(r'^[a-z0-9_-]+$', name): if not re.match(r"^[a-z0-9_-]+$", name):
raise ValueError("Help topic name can only contain letters, numbers, dashes, and underscores") raise ValueError(
"Help topic name can only contain letters, numbers, dashes, and underscores"
)
return name.lower() return name.lower()
@field_validator('title') @field_validator("title")
@classmethod @classmethod
def validate_title(cls, v): def validate_title(cls, v):
"""Validate help topic title.""" """Validate help topic title."""
@ -68,7 +78,7 @@ class HelpCommand(SBABaseModel):
return title return title
@field_validator('content') @field_validator("content")
@classmethod @classmethod
def validate_content(cls, v): def validate_content(cls, v):
"""Validate help topic content.""" """Validate help topic content."""
@ -86,7 +96,7 @@ class HelpCommand(SBABaseModel):
return content return content
@field_validator('category') @field_validator("category")
@classmethod @classmethod
def validate_category(cls, v): def validate_category(cls, v):
"""Validate category if provided.""" """Validate category if provided."""
@ -103,8 +113,10 @@ class HelpCommand(SBABaseModel):
raise ValueError("Category cannot exceed 50 characters") raise ValueError("Category cannot exceed 50 characters")
# Character validation # Character validation
if not re.match(r'^[a-z0-9_-]+$', category): if not re.match(r"^[a-z0-9_-]+$", category):
raise ValueError("Category can only contain letters, numbers, dashes, and underscores") raise ValueError(
"Category can only contain letters, numbers, dashes, and underscores"
)
return category return category
@ -118,12 +130,12 @@ class HelpCommand(SBABaseModel):
"""Calculate days since last update.""" """Calculate days since last update."""
if not self.updated_at: if not self.updated_at:
return None return None
return (datetime.now() - self.updated_at).days return (datetime.now(UTC) - self.updated_at).days
@property @property
def days_since_creation(self) -> int: def days_since_creation(self) -> int:
"""Calculate days since creation.""" """Calculate days since creation."""
return (datetime.now() - self.created_at).days return (datetime.now(UTC) - self.created_at).days
@property @property
def popularity_score(self) -> float: def popularity_score(self) -> float:
@ -153,28 +165,40 @@ class HelpCommand(SBABaseModel):
class HelpCommandSearchFilters(BaseModel): class HelpCommandSearchFilters(BaseModel):
"""Filters for searching help commands.""" """Filters for searching help commands."""
name_contains: Optional[str] = None name_contains: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
is_active: bool = True is_active: bool = True
# Sorting # Sorting
sort_by: str = Field('name', description="Sort field: name, category, created_at, view_count, display_order") sort_by: str = Field(
"name",
description="Sort field: name, category, created_at, view_count, display_order",
)
sort_desc: bool = Field(False, description="Sort in descending order") sort_desc: bool = Field(False, description="Sort in descending order")
# Pagination # Pagination
page: int = Field(1, description="Page number (1-based)") page: int = Field(1, description="Page number (1-based)")
page_size: int = Field(25, description="Items per page") page_size: int = Field(25, description="Items per page")
@field_validator('sort_by') @field_validator("sort_by")
@classmethod @classmethod
def validate_sort_by(cls, v): def validate_sort_by(cls, v):
"""Validate sort field.""" """Validate sort field."""
valid_sorts = {'name', 'title', 'category', 'created_at', 'updated_at', 'view_count', 'display_order'} valid_sorts = {
"name",
"title",
"category",
"created_at",
"updated_at",
"view_count",
"display_order",
}
if v not in valid_sorts: if v not in valid_sorts:
raise ValueError(f"sort_by must be one of: {', '.join(valid_sorts)}") raise ValueError(f"sort_by must be one of: {', '.join(valid_sorts)}")
return v return v
@field_validator('page') @field_validator("page")
@classmethod @classmethod
def validate_page(cls, v): def validate_page(cls, v):
"""Validate page number.""" """Validate page number."""
@ -182,7 +206,7 @@ class HelpCommandSearchFilters(BaseModel):
raise ValueError("Page number must be >= 1") raise ValueError("Page number must be >= 1")
return v return v
@field_validator('page_size') @field_validator("page_size")
@classmethod @classmethod
def validate_page_size(cls, v): def validate_page_size(cls, v):
"""Validate page size.""" """Validate page size."""
@ -193,6 +217,7 @@ class HelpCommandSearchFilters(BaseModel):
class HelpCommandSearchResult(BaseModel): class HelpCommandSearchResult(BaseModel):
"""Result of a help command search.""" """Result of a help command search."""
help_commands: list[HelpCommand] help_commands: list[HelpCommand]
total_count: int total_count: int
page: int page: int
@ -213,6 +238,7 @@ class HelpCommandSearchResult(BaseModel):
class HelpCommandStats(BaseModel): class HelpCommandStats(BaseModel):
"""Statistics about help commands.""" """Statistics about help commands."""
total_commands: int total_commands: int
active_commands: int active_commands: int
total_views: int total_views: int

View File

@ -3,8 +3,9 @@ Custom Commands Service for Discord Bot v2.0
Modern async service layer for managing custom commands with full type safety. Modern async service layer for managing custom commands with full type safety.
""" """
import math import math
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Optional, List, Any, Tuple from typing import Optional, List, Any, Tuple
from utils.logging import get_contextual_logger from utils.logging import get_contextual_logger
@ -13,7 +14,7 @@ from models.custom_command import (
CustomCommandCreator, CustomCommandCreator,
CustomCommandSearchFilters, CustomCommandSearchFilters,
CustomCommandSearchResult, CustomCommandSearchResult,
CustomCommandStats CustomCommandStats,
) )
from services.base_service import BaseService from services.base_service import BaseService
from exceptions import BotException from exceptions import BotException
@ -21,16 +22,19 @@ from exceptions import BotException
class CustomCommandNotFoundError(BotException): class CustomCommandNotFoundError(BotException):
"""Raised when a custom command is not found.""" """Raised when a custom command is not found."""
pass pass
class CustomCommandExistsError(BotException): class CustomCommandExistsError(BotException):
"""Raised when trying to create a command that already exists.""" """Raised when trying to create a command that already exists."""
pass pass
class CustomCommandPermissionError(BotException): class CustomCommandPermissionError(BotException):
"""Raised when user lacks permission for command operation.""" """Raised when user lacks permission for command operation."""
pass pass
@ -38,8 +42,8 @@ class CustomCommandsService(BaseService[CustomCommand]):
"""Service for managing custom commands.""" """Service for managing custom commands."""
def __init__(self): def __init__(self):
super().__init__(CustomCommand, 'custom_commands') super().__init__(CustomCommand, "custom_commands")
self.logger = get_contextual_logger(f'{__name__}.CustomCommandsService') self.logger = get_contextual_logger(f"{__name__}.CustomCommandsService")
self.logger.info("CustomCommandsService initialized") self.logger.info("CustomCommandsService initialized")
# === Command CRUD Operations === # === Command CRUD Operations ===
@ -51,7 +55,7 @@ class CustomCommandsService(BaseService[CustomCommand]):
creator_discord_id: int, creator_discord_id: int,
creator_username: str, creator_username: str,
creator_display_name: Optional[str] = None, creator_display_name: Optional[str] = None,
tags: Optional[List[str]] = None tags: Optional[List[str]] = None,
) -> CustomCommand: ) -> CustomCommand:
""" """
Create a new custom command. Create a new custom command.
@ -83,21 +87,21 @@ class CustomCommandsService(BaseService[CustomCommand]):
creator = await self.get_or_create_creator( creator = await self.get_or_create_creator(
discord_id=creator_discord_id, discord_id=creator_discord_id,
username=creator_username, username=creator_username,
display_name=creator_display_name display_name=creator_display_name,
) )
# Create command data # Create command data
now = datetime.now() now = datetime.now(UTC)
command_data = { command_data = {
'name': name.lower().strip(), "name": name.lower().strip(),
'content': content.strip(), "content": content.strip(),
'creator_id': creator.id, "creator_id": creator.id,
'created_at': now.isoformat(), "created_at": now.isoformat(),
'last_used': now.isoformat(), # Set initial last_used to creation time "last_used": now.isoformat(), # Set initial last_used to creation time
'use_count': 0, "use_count": 0,
'warning_sent': False, "warning_sent": False,
'is_active': True, "is_active": True,
'tags': tags or [] "tags": tags or [],
} }
# Create via API # Create via API
@ -108,18 +112,17 @@ class CustomCommandsService(BaseService[CustomCommand]):
# Update creator stats # Update creator stats
await self._update_creator_stats(creator.id) await self._update_creator_stats(creator.id)
self.logger.info("Custom command created", self.logger.info(
command_name=name, "Custom command created",
creator_id=creator_discord_id, command_name=name,
content_length=len(content)) creator_id=creator_discord_id,
content_length=len(content),
)
# Return full command with creator info # Return full command with creator info
return await self.get_command_by_name(name) return await self.get_command_by_name(name)
async def get_command_by_name( async def get_command_by_name(self, name: str) -> CustomCommand:
self,
name: str
) -> CustomCommand:
""" """
Get a custom command by name. Get a custom command by name.
@ -137,7 +140,7 @@ class CustomCommandsService(BaseService[CustomCommand]):
try: try:
# Use the dedicated by_name endpoint for exact lookup # Use the dedicated by_name endpoint for exact lookup
client = await self.get_client() client = await self.get_client()
data = await client.get(f'custom_commands/by_name/{normalized_name}') data = await client.get(f"custom_commands/by_name/{normalized_name}")
if not data: if not data:
raise CustomCommandNotFoundError(f"Custom command '{name}' not found") raise CustomCommandNotFoundError(f"Custom command '{name}' not found")
@ -149,9 +152,9 @@ class CustomCommandsService(BaseService[CustomCommand]):
if "404" in str(e) or "not found" in str(e).lower(): if "404" in str(e) or "not found" in str(e).lower():
raise CustomCommandNotFoundError(f"Custom command '{name}' not found") raise CustomCommandNotFoundError(f"Custom command '{name}' not found")
else: else:
self.logger.error("Failed to get command by name", self.logger.error(
command_name=name, "Failed to get command by name", command_name=name, error=e
error=e) )
raise BotException(f"Failed to retrieve command '{name}': {e}") raise BotException(f"Failed to retrieve command '{name}': {e}")
async def update_command( async def update_command(
@ -159,7 +162,7 @@ class CustomCommandsService(BaseService[CustomCommand]):
name: str, name: str,
new_content: str, new_content: str,
updater_discord_id: int, updater_discord_id: int,
new_tags: Optional[List[str]] = None new_tags: Optional[List[str]] = None,
) -> CustomCommand: ) -> CustomCommand:
""" """
Update an existing custom command. Update an existing custom command.
@ -185,40 +188,39 @@ class CustomCommandsService(BaseService[CustomCommand]):
# Prepare update data - include all required fields to avoid NULL constraints # Prepare update data - include all required fields to avoid NULL constraints
update_data = { update_data = {
'name': command.name, "name": command.name,
'content': new_content.strip(), "content": new_content.strip(),
'creator_id': command.creator_id, "creator_id": command.creator_id,
'created_at': command.created_at.isoformat(), # Preserve original creation time "created_at": command.created_at.isoformat(), # Preserve original creation time
'updated_at': datetime.now().isoformat(), "updated_at": datetime.now(UTC).isoformat(),
'last_used': command.last_used.isoformat() if command.last_used else None, "last_used": command.last_used.isoformat() if command.last_used else None,
'warning_sent': False, # Reset warning if command is updated "warning_sent": False, # Reset warning if command is updated
'is_active': command.is_active, # Preserve active status "is_active": command.is_active, # Preserve active status
'use_count': command.use_count # Preserve usage count "use_count": command.use_count, # Preserve usage count
} }
if new_tags is not None: if new_tags is not None:
update_data['tags'] = new_tags update_data["tags"] = new_tags
else: else:
# Preserve existing tags if not being updated # Preserve existing tags if not being updated
update_data['tags'] = command.tags update_data["tags"] = command.tags
# Update via API # Update via API
result = await self.update_item_by_field('name', name, update_data) result = await self.update_item_by_field("name", name, update_data)
if not result: if not result:
raise BotException("Failed to update custom command") raise BotException("Failed to update custom command")
self.logger.info("Custom command updated", self.logger.info(
command_name=name, "Custom command updated",
updater_id=updater_discord_id, command_name=name,
new_content_length=len(new_content)) updater_id=updater_discord_id,
new_content_length=len(new_content),
)
return await self.get_command_by_name(name) return await self.get_command_by_name(name)
async def delete_command( async def delete_command(
self, self, name: str, deleter_discord_id: int, force: bool = False
name: str,
deleter_discord_id: int,
force: bool = False
) -> bool: ) -> bool:
""" """
Delete a custom command. Delete a custom command.
@ -239,20 +241,24 @@ class CustomCommandsService(BaseService[CustomCommand]):
# Check permissions (unless force delete) # Check permissions (unless force delete)
if not force and command.creator.discord_id != deleter_discord_id: if not force and command.creator.discord_id != deleter_discord_id:
raise CustomCommandPermissionError("You can only delete commands you created") raise CustomCommandPermissionError(
"You can only delete commands you created"
)
# Delete via API # Delete via API
result = await self.delete_item_by_field('name', name) result = await self.delete_item_by_field("name", name)
if not result: if not result:
raise BotException("Failed to delete custom command") raise BotException("Failed to delete custom command")
# Update creator stats # Update creator stats
await self._update_creator_stats(command.creator_id) await self._update_creator_stats(command.creator_id)
self.logger.info("Custom command deleted", self.logger.info(
command_name=name, "Custom command deleted",
deleter_id=deleter_discord_id, command_name=name,
was_forced=force) deleter_id=deleter_discord_id,
was_forced=force,
)
return True return True
@ -274,7 +280,9 @@ class CustomCommandsService(BaseService[CustomCommand]):
try: try:
# Use the dedicated execute endpoint which updates stats and returns the command # Use the dedicated execute endpoint which updates stats and returns the command
client = await self.get_client() client = await self.get_client()
data = await client.patch(f'custom_commands/by_name/{normalized_name}/execute') data = await client.patch(
f"custom_commands/by_name/{normalized_name}/execute"
)
if not data: if not data:
raise CustomCommandNotFoundError(f"Custom command '{name}' not found") raise CustomCommandNotFoundError(f"Custom command '{name}' not found")
@ -282,9 +290,11 @@ class CustomCommandsService(BaseService[CustomCommand]):
# Convert API data to CustomCommand # Convert API data to CustomCommand
updated_command = self.model_class.from_api_data(data) updated_command = self.model_class.from_api_data(data)
self.logger.debug("Custom command executed", self.logger.debug(
command_name=name, "Custom command executed",
new_use_count=updated_command.use_count) command_name=name,
new_use_count=updated_command.use_count,
)
return updated_command, updated_command.content return updated_command, updated_command.content
@ -292,16 +302,15 @@ class CustomCommandsService(BaseService[CustomCommand]):
if "404" in str(e) or "not found" in str(e).lower(): if "404" in str(e) or "not found" in str(e).lower():
raise CustomCommandNotFoundError(f"Custom command '{name}' not found") raise CustomCommandNotFoundError(f"Custom command '{name}' not found")
else: else:
self.logger.error("Failed to execute command", self.logger.error(
command_name=name, "Failed to execute command", command_name=name, error=e
error=e) )
raise BotException(f"Failed to execute command '{name}': {e}") raise BotException(f"Failed to execute command '{name}': {e}")
# === Search and Listing === # === Search and Listing ===
async def search_commands( async def search_commands(
self, self, filters: CustomCommandSearchFilters
filters: CustomCommandSearchFilters
) -> CustomCommandSearchResult: ) -> CustomCommandSearchResult:
""" """
Search for custom commands with filtering and pagination. Search for custom commands with filtering and pagination.
@ -317,25 +326,25 @@ class CustomCommandsService(BaseService[CustomCommand]):
# Apply filters # Apply filters
if filters.name_contains: if filters.name_contains:
params.append(('name__icontains', filters.name_contains)) params.append(("name__icontains", filters.name_contains))
if filters.creator_id: if filters.creator_id:
params.append(('creator_id', filters.creator_id)) params.append(("creator_id", filters.creator_id))
if filters.min_uses: if filters.min_uses:
params.append(('use_count__gte', filters.min_uses)) params.append(("use_count__gte", filters.min_uses))
if filters.max_days_unused: if filters.max_days_unused:
cutoff_date = datetime.now() - timedelta(days=filters.max_days_unused) cutoff_date = datetime.now(UTC) - timedelta(days=filters.max_days_unused)
params.append(('last_used__gte', cutoff_date.isoformat())) params.append(("last_used__gte", cutoff_date.isoformat()))
params.append(('is_active', filters.is_active)) params.append(("is_active", filters.is_active))
# Add sorting # Add sorting
sort_field = filters.sort_by sort_field = filters.sort_by
if filters.sort_desc: if filters.sort_desc:
sort_field = f'-{sort_field}' sort_field = f"-{sort_field}"
params.append(('sort', sort_field)) params.append(("sort", sort_field))
# Get total count for pagination # Get total count for pagination
total_count = await self._get_search_count(params) total_count = await self._get_search_count(params)
@ -343,10 +352,7 @@ class CustomCommandsService(BaseService[CustomCommand]):
# Add pagination # Add pagination
offset = (filters.page - 1) * filters.page_size offset = (filters.page - 1) * filters.page_size
params.extend([ params.extend([("limit", filters.page_size), ("offset", offset)])
('limit', filters.page_size),
('offset', offset)
])
# Execute search # Execute search
commands_data = await self.get_items_with_params(params) commands_data = await self.get_items_with_params(params)
@ -357,10 +363,14 @@ class CustomCommandsService(BaseService[CustomCommand]):
# The API now returns complete creator data, so we can use it directly # The API now returns complete creator data, so we can use it directly
commands.append(cmd_data) commands.append(cmd_data)
self.logger.debug("Custom commands search completed", self.logger.debug(
total_results=total_count, "Custom commands search completed",
page=filters.page, total_results=total_count,
filters_applied=len([p for p in params if not p[0] in ['sort', 'limit', 'offset']])) page=filters.page,
filters_applied=len(
[p for p in params if not p[0] in ["sort", "limit", "offset"]]
),
)
return CustomCommandSearchResult( return CustomCommandSearchResult(
commands=commands, commands=commands,
@ -368,14 +378,11 @@ class CustomCommandsService(BaseService[CustomCommand]):
page=filters.page, page=filters.page,
page_size=filters.page_size, page_size=filters.page_size,
total_pages=total_pages, total_pages=total_pages,
has_more=filters.page < total_pages has_more=filters.page < total_pages,
) )
async def get_commands_by_creator( async def get_commands_by_creator(
self, self, creator_discord_id: int, page: int = 1, page_size: int = 25
creator_discord_id: int,
page: int = 1,
page_size: int = 25
) -> CustomCommandSearchResult: ) -> CustomCommandSearchResult:
"""Get all commands created by a specific user.""" """Get all commands created by a specific user."""
try: try:
@ -383,14 +390,14 @@ class CustomCommandsService(BaseService[CustomCommand]):
client = await self.get_client() client = await self.get_client()
params = [ params = [
('creator_discord_id', creator_discord_id), ("creator_discord_id", creator_discord_id),
('is_active', True), ("is_active", True),
('sort', 'name'), ("sort", "name"),
('page', page), ("page", page),
('page_size', page_size) ("page_size", page_size),
] ]
data = await client.get('custom_commands', params=params) data = await client.get("custom_commands", params=params)
if not data: if not data:
return CustomCommandSearchResult( return CustomCommandSearchResult(
@ -399,14 +406,14 @@ class CustomCommandsService(BaseService[CustomCommand]):
page=page, page=page,
page_size=page_size, page_size=page_size,
total_pages=0, total_pages=0,
has_more=False has_more=False,
) )
# Extract response data # Extract response data
custom_commands = data.get('custom_commands', []) custom_commands = data.get("custom_commands", [])
total_count = data.get('total_count', 0) total_count = data.get("total_count", 0)
total_pages = data.get('total_pages', 0) total_pages = data.get("total_pages", 0)
has_more = data.get('has_more', False) has_more = data.get("has_more", False)
# Convert to CustomCommand objects (creator data is included in API response) # Convert to CustomCommand objects (creator data is included in API response)
commands = [] commands = []
@ -414,15 +421,19 @@ class CustomCommandsService(BaseService[CustomCommand]):
try: try:
commands.append(self.model_class.from_api_data(cmd_data)) commands.append(self.model_class.from_api_data(cmd_data))
except Exception as e: except Exception as e:
self.logger.warning("Failed to create CustomCommand from API data", self.logger.warning(
command_id=cmd_data.get('id'), "Failed to create CustomCommand from API data",
error=e) command_id=cmd_data.get("id"),
error=e,
)
continue continue
self.logger.debug("Got commands by creator", self.logger.debug(
creator_discord_id=creator_discord_id, "Got commands by creator",
returned_commands=len(commands), creator_discord_id=creator_discord_id,
total_count=total_count) returned_commands=len(commands),
total_count=total_count,
)
return CustomCommandSearchResult( return CustomCommandSearchResult(
commands=commands, commands=commands,
@ -430,13 +441,15 @@ class CustomCommandsService(BaseService[CustomCommand]):
page=page, page=page,
page_size=page_size, page_size=page_size,
total_pages=total_pages, total_pages=total_pages,
has_more=has_more has_more=has_more,
) )
except Exception as e: except Exception as e:
self.logger.error("Failed to get commands by creator", self.logger.error(
creator_discord_id=creator_discord_id, "Failed to get commands by creator",
error=e) creator_discord_id=creator_discord_id,
error=e,
)
# Return empty result on error # Return empty result on error
return CustomCommandSearchResult( return CustomCommandSearchResult(
commands=[], commands=[],
@ -444,16 +457,12 @@ class CustomCommandsService(BaseService[CustomCommand]):
page=page, page=page,
page_size=page_size, page_size=page_size,
total_pages=0, total_pages=0,
has_more=False has_more=False,
) )
async def get_popular_commands(self, limit: int = 10) -> List[CustomCommand]: async def get_popular_commands(self, limit: int = 10) -> List[CustomCommand]:
"""Get the most popular commands by usage.""" """Get the most popular commands by usage."""
params = [ params = [("is_active", True), ("sort", "-use_count"), ("limit", limit)]
('is_active', True),
('sort', '-use_count'),
('limit', limit)
]
commands_data = await self.get_items_with_params(params) commands_data = await self.get_items_with_params(params)
@ -464,19 +473,19 @@ class CustomCommandsService(BaseService[CustomCommand]):
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator)) commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
except BotException as e: except BotException as e:
# Handle missing creator gracefully # Handle missing creator gracefully
self.logger.warning("Skipping popular command with missing creator", self.logger.warning(
command_id=cmd_data.id, "Skipping popular command with missing creator",
command_name=cmd_data.name, command_id=cmd_data.id,
creator_id=cmd_data.creator_id, command_name=cmd_data.name,
error=str(e)) creator_id=cmd_data.creator_id,
error=str(e),
)
continue continue
return commands return commands
async def get_command_names_for_autocomplete( async def get_command_names_for_autocomplete(
self, self, partial_name: str = "", limit: int = 25
partial_name: str = "",
limit: int = 25
) -> List[str]: ) -> List[str]:
""" """
Get command names for Discord autocomplete. Get command names for Discord autocomplete.
@ -491,35 +500,35 @@ class CustomCommandsService(BaseService[CustomCommand]):
try: try:
# Use the dedicated autocomplete endpoint for better performance # Use the dedicated autocomplete endpoint for better performance
client = await self.get_client() client = await self.get_client()
params = [('limit', limit)] params = [("limit", limit)]
if partial_name: if partial_name:
params.append(('partial_name', partial_name.lower())) params.append(("partial_name", partial_name.lower()))
result = await client.get('custom_commands/autocomplete', params=params) result = await client.get("custom_commands/autocomplete", params=params)
# The autocomplete endpoint returns a list of strings directly # The autocomplete endpoint returns a list of strings directly
if isinstance(result, list): if isinstance(result, list):
return result return result
else: else:
self.logger.warning("Unexpected autocomplete response format", self.logger.warning(
response=result) "Unexpected autocomplete response format", response=result
)
return [] return []
except Exception as e: except Exception as e:
self.logger.error("Failed to get command names for autocomplete", self.logger.error(
partial_name=partial_name, "Failed to get command names for autocomplete",
error=e) partial_name=partial_name,
error=e,
)
# Return empty list on error to not break Discord autocomplete # Return empty list on error to not break Discord autocomplete
return [] return []
# === Creator Management === # === Creator Management ===
async def get_or_create_creator( async def get_or_create_creator(
self, self, discord_id: int, username: str, display_name: Optional[str] = None
discord_id: int,
username: str,
display_name: Optional[str] = None
) -> CustomCommandCreator: ) -> CustomCommandCreator:
"""Get existing creator or create a new one.""" """Get existing creator or create a new one."""
try: try:
@ -535,15 +544,17 @@ class CustomCommandsService(BaseService[CustomCommand]):
# Create new creator # Create new creator
creator_data = { creator_data = {
'discord_id': discord_id, "discord_id": discord_id,
'username': username, "username": username,
'display_name': display_name, "display_name": display_name,
'created_at': datetime.now().isoformat(), "created_at": datetime.now(UTC).isoformat(),
'total_commands': 0, "total_commands": 0,
'active_commands': 0 "active_commands": 0,
} }
result = await self.create_item_in_table('custom_commands/creators', creator_data) result = await self.create_item_in_table(
"custom_commands/creators", creator_data
)
if not result: if not result:
raise BotException("Failed to create command creator") raise BotException("Failed to create command creator")
@ -557,12 +568,14 @@ class CustomCommandsService(BaseService[CustomCommand]):
""" """
try: try:
client = await self.get_client() client = await self.get_client()
data = await client.get('custom_commands/creators', params=[('discord_id', discord_id)]) data = await client.get(
"custom_commands/creators", params=[("discord_id", discord_id)]
)
if not data or not data.get('creators'): if not data or not data.get("creators"):
raise BotException(f"Creator with Discord ID {discord_id} not found") raise BotException(f"Creator with Discord ID {discord_id} not found")
creators = data['creators'] creators = data["creators"]
if not creators: if not creators:
raise BotException(f"Creator with Discord ID {discord_id} not found") raise BotException(f"Creator with Discord ID {discord_id} not found")
@ -572,9 +585,11 @@ class CustomCommandsService(BaseService[CustomCommand]):
if "not found" in str(e).lower(): if "not found" in str(e).lower():
raise BotException(f"Creator with Discord ID {discord_id} not found") raise BotException(f"Creator with Discord ID {discord_id} not found")
else: else:
self.logger.error("Failed to get creator by Discord ID", self.logger.error(
discord_id=discord_id, "Failed to get creator by Discord ID",
error=e) discord_id=discord_id,
error=e,
)
raise BotException(f"Failed to retrieve creator: {e}") raise BotException(f"Failed to retrieve creator: {e}")
async def get_creator_by_id(self, creator_id: int) -> CustomCommandCreator: async def get_creator_by_id(self, creator_id: int) -> CustomCommandCreator:
@ -584,8 +599,7 @@ class CustomCommandsService(BaseService[CustomCommand]):
BotException: If creator not found BotException: If creator not found
""" """
creators = await self.get_items_from_table_with_params( creators = await self.get_items_from_table_with_params(
'custom_commands/creators', "custom_commands/creators", [("id", creator_id)]
[('id', creator_id)]
) )
if not creators: if not creators:
@ -599,11 +613,11 @@ class CustomCommandsService(BaseService[CustomCommand]):
"""Get comprehensive statistics about custom commands.""" """Get comprehensive statistics about custom commands."""
# Get basic counts # Get basic counts
total_commands = await self._get_search_count([]) total_commands = await self._get_search_count([])
active_commands = await self._get_search_count([('is_active', True)]) active_commands = await self._get_search_count([("is_active", True)])
total_creators = await self._get_creator_count() total_creators = await self._get_creator_count()
# Get total uses # Get total uses
all_commands = await self.get_items_with_params([('is_active', True)]) all_commands = await self.get_items_with_params([("is_active", True)])
total_uses = sum(cmd.use_count for cmd in all_commands) total_uses = sum(cmd.use_count for cmd in all_commands)
# Get most popular command # Get most popular command
@ -614,11 +628,10 @@ class CustomCommandsService(BaseService[CustomCommand]):
most_active_creator = await self._get_most_active_creator() most_active_creator = await self._get_most_active_creator()
# Get recent commands count # Get recent commands count
week_ago = datetime.now() - timedelta(days=7) week_ago = datetime.now(UTC) - timedelta(days=7)
recent_count = await self._get_search_count([ recent_count = await self._get_search_count(
('created_at__gte', week_ago.isoformat()), [("created_at__gte", week_ago.isoformat()), ("is_active", True)]
('is_active', True) )
])
# Get cleanup statistics # Get cleanup statistics
warning_count = await self._get_commands_needing_warning_count() warning_count = await self._get_commands_needing_warning_count()
@ -633,19 +646,19 @@ class CustomCommandsService(BaseService[CustomCommand]):
most_active_creator=most_active_creator, most_active_creator=most_active_creator,
recent_commands_count=recent_count, recent_commands_count=recent_count,
commands_needing_warning=warning_count, commands_needing_warning=warning_count,
commands_eligible_for_deletion=deletion_count commands_eligible_for_deletion=deletion_count,
) )
# === Cleanup Operations === # === Cleanup Operations ===
async def get_commands_needing_warning(self) -> List[CustomCommand]: async def get_commands_needing_warning(self) -> List[CustomCommand]:
"""Get commands that need deletion warning (60+ days unused).""" """Get commands that need deletion warning (60+ days unused)."""
cutoff_date = datetime.now() - timedelta(days=60) cutoff_date = datetime.now(UTC) - timedelta(days=60)
params = [ params = [
('last_used__lt', cutoff_date.isoformat()), ("last_used__lt", cutoff_date.isoformat()),
('warning_sent', False), ("warning_sent", False),
('is_active', True) ("is_active", True),
] ]
commands_data = await self.get_items_with_params(params) commands_data = await self.get_items_with_params(params)
@ -657,23 +670,22 @@ class CustomCommandsService(BaseService[CustomCommand]):
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator)) commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
except BotException as e: except BotException as e:
# Handle missing creator gracefully # Handle missing creator gracefully
self.logger.warning("Skipping command with missing creator", self.logger.warning(
command_id=cmd_data.id, "Skipping command with missing creator",
command_name=cmd_data.name, command_id=cmd_data.id,
creator_id=cmd_data.creator_id, command_name=cmd_data.name,
error=str(e)) creator_id=cmd_data.creator_id,
error=str(e),
)
continue continue
return commands return commands
async def get_commands_eligible_for_deletion(self) -> List[CustomCommand]: async def get_commands_eligible_for_deletion(self) -> List[CustomCommand]:
"""Get commands eligible for deletion (90+ days unused).""" """Get commands eligible for deletion (90+ days unused)."""
cutoff_date = datetime.now() - timedelta(days=90) cutoff_date = datetime.now(UTC) - timedelta(days=90)
params = [ params = [("last_used__lt", cutoff_date.isoformat()), ("is_active", True)]
('last_used__lt', cutoff_date.isoformat()),
('is_active', True)
]
commands_data = await self.get_items_with_params(params) commands_data = await self.get_items_with_params(params)
@ -684,11 +696,13 @@ class CustomCommandsService(BaseService[CustomCommand]):
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator)) commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
except BotException as e: except BotException as e:
# Handle missing creator gracefully # Handle missing creator gracefully
self.logger.warning("Skipping command with missing creator", self.logger.warning(
command_id=cmd_data.id, "Skipping command with missing creator",
command_name=cmd_data.name, command_id=cmd_data.id,
creator_id=cmd_data.creator_id, command_name=cmd_data.name,
error=str(e)) creator_id=cmd_data.creator_id,
error=str(e),
)
continue continue
return commands return commands
@ -696,9 +710,7 @@ class CustomCommandsService(BaseService[CustomCommand]):
async def mark_warning_sent(self, command_name: str) -> bool: async def mark_warning_sent(self, command_name: str) -> bool:
"""Mark that a deletion warning has been sent for a command.""" """Mark that a deletion warning has been sent for a command."""
result = await self.update_item_by_field( result = await self.update_item_by_field(
'name', "name", command_name, {"warning_sent": True}
command_name,
{'warning_sent': True}
) )
return bool(result) return bool(result)
@ -708,12 +720,14 @@ class CustomCommandsService(BaseService[CustomCommand]):
for name in command_names: for name in command_names:
try: try:
await self.delete_item_by_field('name', name) await self.delete_item_by_field("name", name)
deleted_count += 1 deleted_count += 1
except Exception as e: except Exception as e:
self.logger.error("Failed to delete command during bulk delete", self.logger.error(
command_name=name, "Failed to delete command during bulk delete",
error=e) command_name=name,
error=e,
)
return deleted_count return deleted_count
@ -722,32 +736,33 @@ class CustomCommandsService(BaseService[CustomCommand]):
async def _update_creator_stats(self, creator_id: int) -> None: async def _update_creator_stats(self, creator_id: int) -> None:
"""Update creator statistics.""" """Update creator statistics."""
# Count total and active commands # Count total and active commands
total = await self._get_search_count([('creator_id', creator_id)]) total = await self._get_search_count([("creator_id", creator_id)])
active = await self._get_search_count([('creator_id', creator_id), ('is_active', True)]) active = await self._get_search_count(
[("creator_id", creator_id), ("is_active", True)]
)
# Update creator via API # Update creator via API
try: try:
client = await self.get_client() client = await self.get_client()
await client.put('custom_commands/creators', { await client.put(
'total_commands': total, "custom_commands/creators",
'active_commands': active {"total_commands": total, "active_commands": active},
}, object_id=creator_id) object_id=creator_id,
)
except Exception as e: except Exception as e:
self.logger.error(f"Failed to update creator {creator_id} stats: {e}") self.logger.error(f"Failed to update creator {creator_id} stats: {e}")
async def _update_creator_info( async def _update_creator_info(
self, self, creator_id: int, username: str, display_name: Optional[str]
creator_id: int,
username: str,
display_name: Optional[str]
) -> None: ) -> None:
"""Update creator username and display name.""" """Update creator username and display name."""
try: try:
client = await self.get_client() client = await self.get_client()
await client.put('custom_commands/creators', { await client.put(
'username': username, "custom_commands/creators",
'display_name': display_name {"username": username, "display_name": display_name},
}, object_id=creator_id) object_id=creator_id,
)
except Exception as e: except Exception as e:
self.logger.error(f"Failed to update creator {creator_id} info: {e}") self.logger.error(f"Failed to update creator {creator_id} info: {e}")
@ -758,14 +773,15 @@ class CustomCommandsService(BaseService[CustomCommand]):
async def _get_creator_count(self) -> int: async def _get_creator_count(self) -> int:
"""Get total number of creators.""" """Get total number of creators."""
creators = await self.get_items_from_table_with_params('custom_commands/creators', []) creators = await self.get_items_from_table_with_params(
"custom_commands/creators", []
)
return len(creators) return len(creators)
async def _get_most_active_creator(self) -> Optional[CustomCommandCreator]: async def _get_most_active_creator(self) -> Optional[CustomCommandCreator]:
"""Get creator with most active commands.""" """Get creator with most active commands."""
creators = await self.get_items_from_table_with_params( creators = await self.get_items_from_table_with_params(
'custom_commands/creators', "custom_commands/creators", [("sort", "-active_commands"), ("limit", 1)]
[('sort', '-active_commands'), ('limit', 1)]
) )
if not creators: if not creators:
@ -775,20 +791,21 @@ class CustomCommandsService(BaseService[CustomCommand]):
async def _get_commands_needing_warning_count(self) -> int: async def _get_commands_needing_warning_count(self) -> int:
"""Get count of commands needing warning.""" """Get count of commands needing warning."""
cutoff_date = datetime.now() - timedelta(days=60) cutoff_date = datetime.now(UTC) - timedelta(days=60)
return await self._get_search_count([ return await self._get_search_count(
('last_used__lt', cutoff_date.isoformat()), [
('warning_sent', False), ("last_used__lt", cutoff_date.isoformat()),
('is_active', True) ("warning_sent", False),
]) ("is_active", True),
]
)
async def _get_commands_eligible_for_deletion_count(self) -> int: async def _get_commands_eligible_for_deletion_count(self) -> int:
"""Get count of commands eligible for deletion.""" """Get count of commands eligible for deletion."""
cutoff_date = datetime.now() - timedelta(days=90) cutoff_date = datetime.now(UTC) - timedelta(days=90)
return await self._get_search_count([ return await self._get_search_count(
('last_used__lt', cutoff_date.isoformat()), [("last_used__lt", cutoff_date.isoformat()), ("is_active", True)]
('is_active', True) )
])
# Global service instance # Global service instance

View File

@ -3,14 +3,15 @@ Draft service for Discord Bot v2.0
Core draft business logic and state management. NO CACHING - draft state changes constantly. Core draft business logic and state management. NO CACHING - draft state changes constantly.
""" """
import logging import logging
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from services.base_service import BaseService from services.base_service import BaseService
from models.draft_data import DraftData from models.draft_data import DraftData
logger = logging.getLogger(f'{__name__}.DraftService') logger = logging.getLogger(f"{__name__}.DraftService")
class DraftService(BaseService[DraftData]): class DraftService(BaseService[DraftData]):
@ -29,7 +30,7 @@ class DraftService(BaseService[DraftData]):
def __init__(self): def __init__(self):
"""Initialize draft service.""" """Initialize draft service."""
super().__init__(DraftData, 'draftdata') super().__init__(DraftData, "draftdata")
logger.debug("DraftService initialized") logger.debug("DraftService initialized")
async def get_draft_data(self) -> Optional[DraftData]: async def get_draft_data(self) -> Optional[DraftData]:
@ -62,9 +63,7 @@ class DraftService(BaseService[DraftData]):
return None return None
async def update_draft_data( async def update_draft_data(
self, self, draft_id: int, updates: Dict[str, Any]
draft_id: int,
updates: Dict[str, Any]
) -> Optional[DraftData]: ) -> Optional[DraftData]:
""" """
Update draft configuration. Update draft configuration.
@ -92,10 +91,7 @@ class DraftService(BaseService[DraftData]):
return None return None
async def set_timer( async def set_timer(
self, self, draft_id: int, active: bool, pick_minutes: Optional[int] = None
draft_id: int,
active: bool,
pick_minutes: Optional[int] = None
) -> Optional[DraftData]: ) -> Optional[DraftData]:
""" """
Enable or disable draft timer. Enable or disable draft timer.
@ -109,27 +105,31 @@ class DraftService(BaseService[DraftData]):
Updated DraftData instance Updated DraftData instance
""" """
try: try:
updates = {'timer': active} updates = {"timer": active}
if pick_minutes is not None: if pick_minutes is not None:
updates['pick_minutes'] = pick_minutes updates["pick_minutes"] = pick_minutes
# Set deadline based on timer state # Set deadline based on timer state
if active: if active:
# Calculate new deadline # Calculate new deadline
if pick_minutes: if pick_minutes:
deadline = datetime.now() + timedelta(minutes=pick_minutes) deadline = datetime.now(UTC) + timedelta(minutes=pick_minutes)
else: else:
# Get current pick_minutes from existing data # Get current pick_minutes from existing data
current_data = await self.get_draft_data() current_data = await self.get_draft_data()
if current_data: if current_data:
deadline = datetime.now() + timedelta(minutes=current_data.pick_minutes) deadline = datetime.now(UTC) + timedelta(
minutes=current_data.pick_minutes
)
else: else:
deadline = datetime.now() + timedelta(minutes=2) # Default fallback deadline = datetime.now(UTC) + timedelta(
updates['pick_deadline'] = deadline minutes=2
) # Default fallback
updates["pick_deadline"] = deadline
else: else:
# Set deadline far in future when timer inactive # Set deadline far in future when timer inactive
updates['pick_deadline'] = datetime.now() + timedelta(days=690) updates["pick_deadline"] = datetime.now(UTC) + timedelta(days=690)
updated = await self.update_draft_data(draft_id, updates) updated = await self.update_draft_data(draft_id, updates)
@ -146,9 +146,7 @@ class DraftService(BaseService[DraftData]):
return None return None
async def advance_pick( async def advance_pick(
self, self, draft_id: int, current_pick: int
draft_id: int,
current_pick: int
) -> Optional[DraftData]: ) -> Optional[DraftData]:
""" """
Advance to next pick in draft. Advance to next pick in draft.
@ -199,12 +197,14 @@ class DraftService(BaseService[DraftData]):
return await self.get_draft_data() return await self.get_draft_data()
# Update to next pick # Update to next pick
updates = {'currentpick': next_pick} updates = {"currentpick": next_pick}
# Reset deadline if timer is active # Reset deadline if timer is active
current_data = await self.get_draft_data() current_data = await self.get_draft_data()
if current_data and current_data.timer: if current_data and current_data.timer:
updates['pick_deadline'] = datetime.now() + timedelta(minutes=current_data.pick_minutes) updates["pick_deadline"] = datetime.now(UTC) + timedelta(
minutes=current_data.pick_minutes
)
updated = await self.update_draft_data(draft_id, updates) updated = await self.update_draft_data(draft_id, updates)
@ -220,10 +220,7 @@ class DraftService(BaseService[DraftData]):
return None return None
async def set_current_pick( async def set_current_pick(
self, self, draft_id: int, overall: int, reset_timer: bool = True
draft_id: int,
overall: int,
reset_timer: bool = True
) -> Optional[DraftData]: ) -> Optional[DraftData]:
""" """
Manually set current pick (admin operation). Manually set current pick (admin operation).
@ -237,12 +234,14 @@ class DraftService(BaseService[DraftData]):
Updated DraftData Updated DraftData
""" """
try: try:
updates = {'currentpick': overall} updates = {"currentpick": overall}
if reset_timer: if reset_timer:
current_data = await self.get_draft_data() current_data = await self.get_draft_data()
if current_data and current_data.timer: if current_data and current_data.timer:
updates['pick_deadline'] = datetime.now() + timedelta(minutes=current_data.pick_minutes) updates["pick_deadline"] = datetime.now(UTC) + timedelta(
minutes=current_data.pick_minutes
)
updated = await self.update_draft_data(draft_id, updates) updated = await self.update_draft_data(draft_id, updates)
@ -261,7 +260,7 @@ class DraftService(BaseService[DraftData]):
self, self,
draft_id: int, draft_id: int,
ping_channel_id: Optional[int] = None, ping_channel_id: Optional[int] = None,
result_channel_id: Optional[int] = None result_channel_id: Optional[int] = None,
) -> Optional[DraftData]: ) -> Optional[DraftData]:
""" """
Update draft Discord channel configuration. Update draft Discord channel configuration.
@ -277,9 +276,9 @@ class DraftService(BaseService[DraftData]):
try: try:
updates = {} updates = {}
if ping_channel_id is not None: if ping_channel_id is not None:
updates['ping_channel'] = ping_channel_id updates["ping_channel"] = ping_channel_id
if result_channel_id is not None: if result_channel_id is not None:
updates['result_channel'] = result_channel_id updates["result_channel"] = result_channel_id
if not updates: if not updates:
logger.warning("No channel updates provided") logger.warning("No channel updates provided")
@ -299,9 +298,7 @@ class DraftService(BaseService[DraftData]):
return None return None
async def reset_draft_deadline( async def reset_draft_deadline(
self, self, draft_id: int, minutes: Optional[int] = None
draft_id: int,
minutes: Optional[int] = None
) -> Optional[DraftData]: ) -> Optional[DraftData]:
""" """
Reset the current pick deadline. Reset the current pick deadline.
@ -321,8 +318,8 @@ class DraftService(BaseService[DraftData]):
return None return None
minutes = current_data.pick_minutes minutes = current_data.pick_minutes
new_deadline = datetime.now() + timedelta(minutes=minutes) new_deadline = datetime.now(UTC) + timedelta(minutes=minutes)
updates = {'pick_deadline': new_deadline} updates = {"pick_deadline": new_deadline}
updated = await self.update_draft_data(draft_id, updates) updated = await self.update_draft_data(draft_id, updates)
@ -357,9 +354,9 @@ class DraftService(BaseService[DraftData]):
# Pause the draft AND stop the timer # Pause the draft AND stop the timer
# Set deadline far in future so it doesn't expire while paused # Set deadline far in future so it doesn't expire while paused
updates = { updates = {
'paused': True, "paused": True,
'timer': False, "timer": False,
'pick_deadline': datetime.now() + timedelta(days=690) "pick_deadline": datetime.now(UTC) + timedelta(days=690),
} }
updated = await self.update_draft_data(draft_id, updates) updated = await self.update_draft_data(draft_id, updates)
@ -394,16 +391,14 @@ class DraftService(BaseService[DraftData]):
pick_minutes = current_data.pick_minutes if current_data else 2 pick_minutes = current_data.pick_minutes if current_data else 2
# Resume the draft AND restart the timer with fresh deadline # Resume the draft AND restart the timer with fresh deadline
new_deadline = datetime.now() + timedelta(minutes=pick_minutes) new_deadline = datetime.now(UTC) + timedelta(minutes=pick_minutes)
updates = { updates = {"paused": False, "timer": True, "pick_deadline": new_deadline}
'paused': False,
'timer': True,
'pick_deadline': new_deadline
}
updated = await self.update_draft_data(draft_id, updates) updated = await self.update_draft_data(draft_id, updates)
if updated: if updated:
logger.info(f"Draft resumed - timer restarted with {pick_minutes}min deadline") logger.info(
f"Draft resumed - timer restarted with {pick_minutes}min deadline"
)
else: else:
logger.error("Failed to resume draft") logger.error("Failed to resume draft")

View File

@ -4,6 +4,7 @@ Draft Sheet Service
Handles writing draft picks to Google Sheets for public tracking. Handles writing draft picks to Google Sheets for public tracking.
Extends SheetsService to reuse authentication and async patterns. Extends SheetsService to reuse authentication and async patterns.
""" """
import asyncio import asyncio
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
@ -24,7 +25,7 @@ class DraftSheetService(SheetsService):
If None, will use path from config If None, will use path from config
""" """
super().__init__(credentials_path) super().__init__(credentials_path)
self.logger = get_contextual_logger(f'{__name__}.DraftSheetService') self.logger = get_contextual_logger(f"{__name__}.DraftSheetService")
self._config = get_config() self._config = get_config()
async def write_pick( async def write_pick(
@ -34,7 +35,7 @@ class DraftSheetService(SheetsService):
orig_owner_abbrev: str, orig_owner_abbrev: str,
owner_abbrev: str, owner_abbrev: str,
player_name: str, player_name: str,
swar: float swar: float,
) -> bool: ) -> bool:
""" """
Write a single draft pick to the season's draft sheet. Write a single draft pick to the season's draft sheet.
@ -68,23 +69,19 @@ class DraftSheetService(SheetsService):
return False return False
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
# Get pygsheets client # Get pygsheets client
sheets = await loop.run_in_executor(None, self._get_client) sheets = await loop.run_in_executor(None, self._get_client)
# Open the draft sheet by key # Open the draft sheet by key
spreadsheet = await loop.run_in_executor( spreadsheet = await loop.run_in_executor(
None, None, sheets.open_by_key, sheet_key
sheets.open_by_key,
sheet_key
) )
# Get the worksheet # Get the worksheet
worksheet = await loop.run_in_executor( worksheet = await loop.run_in_executor(
None, None, spreadsheet.worksheet_by_title, self._config.draft_sheet_worksheet
spreadsheet.worksheet_by_title,
self._config.draft_sheet_worksheet
) )
# Prepare pick data (4 columns: orig_owner, owner, player, swar) # Prepare pick data (4 columns: orig_owner, owner, player, swar)
@ -93,12 +90,12 @@ class DraftSheetService(SheetsService):
# Calculate row (overall + 1 to leave row 1 for headers) # Calculate row (overall + 1 to leave row 1 for headers)
row = overall + 1 row = overall + 1
start_column = self._config.draft_sheet_start_column start_column = self._config.draft_sheet_start_column
cell_range = f'{start_column}{row}' cell_range = f"{start_column}{row}"
# Write the pick data # Write the pick data
await loop.run_in_executor( await loop.run_in_executor(
None, None,
lambda: worksheet.update_values(crange=cell_range, values=pick_data) lambda: worksheet.update_values(crange=cell_range, values=pick_data),
) )
self.logger.info( self.logger.info(
@ -106,7 +103,7 @@ class DraftSheetService(SheetsService):
season=season, season=season,
overall=overall, overall=overall,
player=player_name, player=player_name,
owner=owner_abbrev owner=owner_abbrev,
) )
return True return True
@ -115,14 +112,12 @@ class DraftSheetService(SheetsService):
f"Failed to write pick to draft sheet: {e}", f"Failed to write pick to draft sheet: {e}",
season=season, season=season,
overall=overall, overall=overall,
player=player_name player=player_name,
) )
return False return False
async def write_picks_batch( async def write_picks_batch(
self, self, season: int, picks: List[Tuple[int, str, str, str, float]]
season: int,
picks: List[Tuple[int, str, str, str, float]]
) -> Tuple[int, int]: ) -> Tuple[int, int]:
""" """
Write multiple draft picks to the sheet in a single batch operation. Write multiple draft picks to the sheet in a single batch operation.
@ -151,23 +146,19 @@ class DraftSheetService(SheetsService):
return (0, 0) return (0, 0)
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
# Get pygsheets client # Get pygsheets client
sheets = await loop.run_in_executor(None, self._get_client) sheets = await loop.run_in_executor(None, self._get_client)
# Open the draft sheet by key # Open the draft sheet by key
spreadsheet = await loop.run_in_executor( spreadsheet = await loop.run_in_executor(
None, None, sheets.open_by_key, sheet_key
sheets.open_by_key,
sheet_key
) )
# Get the worksheet # Get the worksheet
worksheet = await loop.run_in_executor( worksheet = await loop.run_in_executor(
None, None, spreadsheet.worksheet_by_title, self._config.draft_sheet_worksheet
spreadsheet.worksheet_by_title,
self._config.draft_sheet_worksheet
) )
# Sort picks by overall to find range bounds # Sort picks by overall to find range bounds
@ -180,7 +171,7 @@ class DraftSheetService(SheetsService):
# Build a 2D array for the entire range (sparse - empty rows for missing picks) # Build a 2D array for the entire range (sparse - empty rows for missing picks)
# Row index 0 = min_overall, row index N = max_overall # Row index 0 = min_overall, row index N = max_overall
num_rows = max_overall - min_overall + 1 num_rows = max_overall - min_overall + 1
batch_data: List[List[str]] = [['', '', '', ''] for _ in range(num_rows)] batch_data: List[List[str]] = [["", "", "", ""] for _ in range(num_rows)]
# Populate the batch data array # Populate the batch data array
for overall, orig_owner, owner, player_name, swar in sorted_picks: for overall, orig_owner, owner, player_name, swar in sorted_picks:
@ -193,23 +184,23 @@ class DraftSheetService(SheetsService):
end_column = chr(ord(start_column) + 3) # 4 columns: D -> G end_column = chr(ord(start_column) + 3) # 4 columns: D -> G
end_row = max_overall + 1 end_row = max_overall + 1
cell_range = f'{start_column}{start_row}:{end_column}{end_row}' cell_range = f"{start_column}{start_row}:{end_column}{end_row}"
self.logger.info( self.logger.info(
f"Writing {len(picks)} picks in single batch to range {cell_range}", f"Writing {len(picks)} picks in single batch to range {cell_range}",
season=season season=season,
) )
# Write all picks in a single API call # Write all picks in a single API call
await loop.run_in_executor( await loop.run_in_executor(
None, None,
lambda: worksheet.update_values(crange=cell_range, values=batch_data) lambda: worksheet.update_values(crange=cell_range, values=batch_data),
) )
self.logger.info( self.logger.info(
f"Batch write complete: {len(picks)} picks written successfully", f"Batch write complete: {len(picks)} picks written successfully",
season=season, season=season,
total_picks=len(picks) total_picks=len(picks),
) )
return (len(picks), 0) return (len(picks), 0)
@ -218,10 +209,7 @@ class DraftSheetService(SheetsService):
return (0, len(picks)) return (0, len(picks))
async def clear_picks_range( async def clear_picks_range(
self, self, season: int, start_overall: int = 1, end_overall: int = 512
season: int,
start_overall: int = 1,
end_overall: int = 512
) -> bool: ) -> bool:
""" """
Clear a range of picks from the draft sheet. Clear a range of picks from the draft sheet.
@ -246,23 +234,19 @@ class DraftSheetService(SheetsService):
return False return False
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
# Get pygsheets client # Get pygsheets client
sheets = await loop.run_in_executor(None, self._get_client) sheets = await loop.run_in_executor(None, self._get_client)
# Open the draft sheet by key # Open the draft sheet by key
spreadsheet = await loop.run_in_executor( spreadsheet = await loop.run_in_executor(
None, None, sheets.open_by_key, sheet_key
sheets.open_by_key,
sheet_key
) )
# Get the worksheet # Get the worksheet
worksheet = await loop.run_in_executor( worksheet = await loop.run_in_executor(
None, None, spreadsheet.worksheet_by_title, self._config.draft_sheet_worksheet
spreadsheet.worksheet_by_title,
self._config.draft_sheet_worksheet
) )
# Calculate range (4 columns: D through G) # Calculate range (4 columns: D through G)
@ -273,24 +257,23 @@ class DraftSheetService(SheetsService):
# Convert start column letter to end column (D -> G for 4 columns) # Convert start column letter to end column (D -> G for 4 columns)
end_column = chr(ord(start_column) + 3) end_column = chr(ord(start_column) + 3)
cell_range = f'{start_column}{start_row}:{end_column}{end_row}' cell_range = f"{start_column}{start_row}:{end_column}{end_row}"
# Clear the range by setting empty values # Clear the range by setting empty values
# We create a 2D array of empty strings # We create a 2D array of empty strings
num_rows = end_row - start_row + 1 num_rows = end_row - start_row + 1
empty_data = [['', '', '', ''] for _ in range(num_rows)] empty_data = [["", "", "", ""] for _ in range(num_rows)]
await loop.run_in_executor( await loop.run_in_executor(
None, None,
lambda: worksheet.update_values( lambda: worksheet.update_values(
crange=f'{start_column}{start_row}', crange=f"{start_column}{start_row}", values=empty_data
values=empty_data ),
)
) )
self.logger.info( self.logger.info(
f"Cleared picks {start_overall}-{end_overall} from draft sheet", f"Cleared picks {start_overall}-{end_overall} from draft sheet",
season=season season=season,
) )
return True return True

View File

@ -3,6 +3,7 @@ Scorebug Service
Handles reading live game data from Google Sheets scorecards for real-time score displays. Handles reading live game data from Google Sheets scorecards for real-time score displays.
""" """
import asyncio import asyncio
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
import pygsheets import pygsheets
@ -16,30 +17,32 @@ class ScorebugData:
"""Data class for scorebug information.""" """Data class for scorebug information."""
def __init__(self, data: Dict[str, Any]): def __init__(self, data: Dict[str, Any]):
self.away_team_id = data.get('away_team_id', 1) self.away_team_id = data.get("away_team_id", 1)
self.home_team_id = data.get('home_team_id', 1) self.home_team_id = data.get("home_team_id", 1)
self.header = data.get('header', '') self.header = data.get("header", "")
self.away_score = data.get('away_score', 0) self.away_score = data.get("away_score", 0)
self.home_score = data.get('home_score', 0) self.home_score = data.get("home_score", 0)
self.which_half = data.get('which_half', '') self.which_half = data.get("which_half", "")
self.inning = data.get('inning', 1) self.inning = data.get("inning", 1)
self.is_final = data.get('is_final', False) self.is_final = data.get("is_final", False)
self.outs = data.get('outs', 0) self.outs = data.get("outs", 0)
self.win_percentage = data.get('win_percentage', 50.0) self.win_percentage = data.get("win_percentage", 50.0)
# Current matchup information # Current matchup information
self.pitcher_name = data.get('pitcher_name', '') self.pitcher_name = data.get("pitcher_name", "")
self.pitcher_url = data.get('pitcher_url', '') self.pitcher_url = data.get("pitcher_url", "")
self.pitcher_stats = data.get('pitcher_stats', '') self.pitcher_stats = data.get("pitcher_stats", "")
self.batter_name = data.get('batter_name', '') self.batter_name = data.get("batter_name", "")
self.batter_url = data.get('batter_url', '') self.batter_url = data.get("batter_url", "")
self.batter_stats = data.get('batter_stats', '') self.batter_stats = data.get("batter_stats", "")
self.on_deck_name = data.get('on_deck_name', '') self.on_deck_name = data.get("on_deck_name", "")
self.in_hole_name = data.get('in_hole_name', '') self.in_hole_name = data.get("in_hole_name", "")
# Additional data # Additional data
self.runners = data.get('runners', []) # [Catcher, On First, On Second, On Third] self.runners = data.get(
self.summary = data.get('summary', []) # Play-by-play summary lines "runners", []
) # [Catcher, On First, On Second, On Third]
self.summary = data.get("summary", []) # Play-by-play summary lines
@property @property
def score_line(self) -> str: def score_line(self) -> str:
@ -79,12 +82,10 @@ class ScorebugService(SheetsService):
credentials_path: Path to service account credentials JSON credentials_path: Path to service account credentials JSON
""" """
super().__init__(credentials_path) super().__init__(credentials_path)
self.logger = get_contextual_logger(f'{__name__}.ScorebugService') self.logger = get_contextual_logger(f"{__name__}.ScorebugService")
async def read_scorebug_data( async def read_scorebug_data(
self, self, sheet_url_or_key: str, full_length: bool = True
sheet_url_or_key: str,
full_length: bool = True
) -> ScorebugData: ) -> ScorebugData:
""" """
Read live scorebug data from Google Sheets scorecard. Read live scorebug data from Google Sheets scorecard.
@ -107,24 +108,28 @@ class ScorebugService(SheetsService):
scorecard = await self.open_scorecard(sheet_url_or_key) scorecard = await self.open_scorecard(sheet_url_or_key)
self.logger.debug(f" ✅ Scorecard opened successfully") self.logger.debug(f" ✅ Scorecard opened successfully")
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
# Get Scorebug tab # Get Scorebug tab
scorebug_tab = await loop.run_in_executor( scorebug_tab = await loop.run_in_executor(
None, None, scorecard.worksheet_by_title, "Scorebug"
scorecard.worksheet_by_title,
'Scorebug'
) )
# Read all data from B2:S20 for efficiency # Read all data from B2:S20 for efficiency
all_data = await loop.run_in_executor( all_data = await loop.run_in_executor(
None, None,
lambda: scorebug_tab.get_values('B2', 'S20', include_tailing_empty_rows=True) lambda: scorebug_tab.get_values(
"B2", "S20", include_tailing_empty_rows=True
),
) )
self.logger.debug(f"📊 Raw scorebug data dimensions: {len(all_data)} rows") self.logger.debug(f"📊 Raw scorebug data dimensions: {len(all_data)} rows")
self.logger.debug(f"📊 First row length: {len(all_data[0]) if all_data else 0} columns") self.logger.debug(
self.logger.debug(f"📊 Reading from range B2:S20 (columns B-S = indices 0-17 in data)") f"📊 First row length: {len(all_data[0]) if all_data else 0} columns"
)
self.logger.debug(
f"📊 Reading from range B2:S20 (columns B-S = indices 0-17 in data)"
)
self.logger.debug(f"📊 Raw data structure (all rows):") self.logger.debug(f"📊 Raw data structure (all rows):")
for idx, row in enumerate(all_data): for idx, row in enumerate(all_data):
self.logger.debug(f" Row {idx} (Sheet row {idx + 2}): {row}") self.logger.debug(f" Row {idx} (Sheet row {idx + 2}): {row}")
@ -133,8 +138,13 @@ class ScorebugService(SheetsService):
# This corresponds to columns B-G (indices 0-5 in all_data) # This corresponds to columns B-G (indices 0-5 in all_data)
# Rows 2-8 in sheet (indices 0-6 in all_data) # Rows 2-8 in sheet (indices 0-6 in all_data)
game_state = [ game_state = [
all_data[0][:6], all_data[1][:6], all_data[2][:6], all_data[3][:6], all_data[0][:6],
all_data[4][:6], all_data[5][:6], all_data[6][:6] all_data[1][:6],
all_data[2][:6],
all_data[3][:6],
all_data[4][:6],
all_data[5][:6],
all_data[6][:6],
] ]
self.logger.debug(f"🎮 Extracted game_state (B2:G8):") self.logger.debug(f"🎮 Extracted game_state (B2:G8):")
@ -145,12 +155,24 @@ class ScorebugService(SheetsService):
# game_state[3] is away team row (Sheet row 5), game_state[4] is home team row (Sheet row 6) # game_state[3] is away team row (Sheet row 5), game_state[4] is home team row (Sheet row 6)
# First column (index 0) contains the team ID - this is column B in the sheet # First column (index 0) contains the team ID - this is column B in the sheet
self.logger.debug(f"🏟️ Extracting team IDs from game_state:") self.logger.debug(f"🏟️ Extracting team IDs from game_state:")
self.logger.debug(f" Away team row: game_state[3] = Sheet row 5, column B (index 0)") self.logger.debug(
self.logger.debug(f" Home team row: game_state[4] = Sheet row 6, column B (index 0)") f" Away team row: game_state[3] = Sheet row 5, column B (index 0)"
)
self.logger.debug(
f" Home team row: game_state[4] = Sheet row 6, column B (index 0)"
)
try: try:
away_team_id_raw = game_state[3][0] if len(game_state) > 3 and len(game_state[3]) > 0 else None away_team_id_raw = (
home_team_id_raw = game_state[4][0] if len(game_state) > 4 and len(game_state[4]) > 0 else None game_state[3][0]
if len(game_state) > 3 and len(game_state[3]) > 0
else None
)
home_team_id_raw = (
game_state[4][0]
if len(game_state) > 4 and len(game_state[4]) > 0
else None
)
self.logger.debug(f" Raw away team ID value: '{away_team_id_raw}'") self.logger.debug(f" Raw away team ID value: '{away_team_id_raw}'")
self.logger.debug(f" Raw home team ID value: '{home_team_id_raw}'") self.logger.debug(f" Raw home team ID value: '{home_team_id_raw}'")
@ -158,61 +180,97 @@ class ScorebugService(SheetsService):
away_team_id = int(away_team_id_raw) if away_team_id_raw else None away_team_id = int(away_team_id_raw) if away_team_id_raw else None
home_team_id = int(home_team_id_raw) if home_team_id_raw else None home_team_id = int(home_team_id_raw) if home_team_id_raw else None
self.logger.debug(f" ✅ Parsed team IDs - Away: {away_team_id}, Home: {home_team_id}") self.logger.debug(
f" ✅ Parsed team IDs - Away: {away_team_id}, Home: {home_team_id}"
)
if away_team_id is None or home_team_id is None: if away_team_id is None or home_team_id is None:
raise ValueError(f'Team IDs not found in scorebug (away: {away_team_id}, home: {home_team_id})') raise ValueError(
f"Team IDs not found in scorebug (away: {away_team_id}, home: {home_team_id})"
)
except (ValueError, IndexError) as e: except (ValueError, IndexError) as e:
self.logger.error(f"❌ Failed to parse team IDs from scorebug: {e}") self.logger.error(f"❌ Failed to parse team IDs from scorebug: {e}")
raise ValueError(f'Could not extract team IDs from scorecard') raise ValueError(f"Could not extract team IDs from scorecard")
# Parse game state # Parse game state
self.logger.debug(f"📝 Parsing header from game_state[0][0] (Sheet B2):") self.logger.debug(f"📝 Parsing header from game_state[0][0] (Sheet B2):")
header = game_state[0][0] if game_state[0] else '' header = game_state[0][0] if game_state[0] else ""
is_final = header[-5:] == 'FINAL' if header else False is_final = header[-5:] == "FINAL" if header else False
self.logger.debug(f" Header value: '{header}'") self.logger.debug(f" Header value: '{header}'")
self.logger.debug(f" Is Final: {is_final}") self.logger.debug(f" Is Final: {is_final}")
# Parse scores with validation # Parse scores with validation
self.logger.debug(f"⚾ Parsing scores:") self.logger.debug(f"⚾ Parsing scores:")
self.logger.debug(f" Away score: game_state[3][2] (Sheet row 5, column D)") self.logger.debug(
self.logger.debug(f" Home score: game_state[4][2] (Sheet row 6, column D)") f" Away score: game_state[3][2] (Sheet row 5, column D)"
)
self.logger.debug(
f" Home score: game_state[4][2] (Sheet row 6, column D)"
)
try: try:
away_score_raw = game_state[3][2] if len(game_state) > 3 and len(game_state[3]) > 2 else '0' away_score_raw = (
self.logger.debug(f" Raw away score value: '{away_score_raw}' (type: {type(away_score_raw).__name__})") game_state[3][2]
away_score = int(away_score_raw) if away_score_raw != '' else 0 if len(game_state) > 3 and len(game_state[3]) > 2
else "0"
)
self.logger.debug(
f" Raw away score value: '{away_score_raw}' (type: {type(away_score_raw).__name__})"
)
away_score = int(away_score_raw) if away_score_raw != "" else 0
self.logger.debug(f" ✅ Parsed away score: {away_score}") self.logger.debug(f" ✅ Parsed away score: {away_score}")
except (ValueError, IndexError) as e: except (ValueError, IndexError) as e:
self.logger.warning(f" ⚠️ Failed to parse away score: {e}") self.logger.warning(f" ⚠️ Failed to parse away score: {e}")
away_score = 0 away_score = 0
try: try:
home_score_raw = game_state[4][2] if len(game_state) > 4 and len(game_state[4]) > 2 else '0' home_score_raw = (
self.logger.debug(f" Raw home score value: '{home_score_raw}' (type: {type(home_score_raw).__name__})") game_state[4][2]
home_score = int(home_score_raw) if home_score_raw != '' else 0 if len(game_state) > 4 and len(game_state[4]) > 2
else "0"
)
self.logger.debug(
f" Raw home score value: '{home_score_raw}' (type: {type(home_score_raw).__name__})"
)
home_score = int(home_score_raw) if home_score_raw != "" else 0
self.logger.debug(f" ✅ Parsed home score: {home_score}") self.logger.debug(f" ✅ Parsed home score: {home_score}")
except (ValueError, IndexError) as e: except (ValueError, IndexError) as e:
self.logger.warning(f" ⚠️ Failed to parse home score: {e}") self.logger.warning(f" ⚠️ Failed to parse home score: {e}")
home_score = 0 home_score = 0
try: try:
inning_raw = game_state[3][5] if len(game_state) > 3 and len(game_state[3]) > 5 else '0' inning_raw = (
self.logger.debug(f" Raw inning value: '{inning_raw}' (type: {type(inning_raw).__name__})") game_state[3][5]
inning = int(inning_raw) if inning_raw != '' else 1 if len(game_state) > 3 and len(game_state[3]) > 5
else "0"
)
self.logger.debug(
f" Raw inning value: '{inning_raw}' (type: {type(inning_raw).__name__})"
)
inning = int(inning_raw) if inning_raw != "" else 1
self.logger.debug(f" ✅ Parsed inning: {inning}") self.logger.debug(f" ✅ Parsed inning: {inning}")
except (ValueError, IndexError) as e: except (ValueError, IndexError) as e:
self.logger.warning(f" ⚠️ Failed to parse home score: {e}") self.logger.warning(f" ⚠️ Failed to parse home score: {e}")
inning = 1 inning = 1
self.logger.debug(f"⏱️ Parsing game state from game_state[3][4] (Sheet row 5, column F):") self.logger.debug(
which_half = game_state[3][4] if len(game_state) > 3 and len(game_state[3]) > 4 else '' f"⏱️ Parsing game state from game_state[3][4] (Sheet row 5, column F):"
)
which_half = (
game_state[3][4]
if len(game_state) > 3 and len(game_state[3]) > 4
else ""
)
self.logger.debug(f" Which half value: '{which_half}'") self.logger.debug(f" Which half value: '{which_half}'")
# Parse outs from all_data[4][4] (Sheet F6 - columns start at B, so F=index 4) # Parse outs from all_data[4][4] (Sheet F6 - columns start at B, so F=index 4)
self.logger.debug(f"🔢 Parsing outs from F6 (all_data[4][4]):") self.logger.debug(f"🔢 Parsing outs from F6 (all_data[4][4]):")
try: try:
outs_raw = all_data[4][4] if len(all_data) > 4 and len(all_data[4]) > 4 else '0' outs_raw = (
all_data[4][4]
if len(all_data) > 4 and len(all_data[4]) > 4
else "0"
)
self.logger.debug(f" Raw outs value: '{outs_raw}'") self.logger.debug(f" Raw outs value: '{outs_raw}'")
# Handle "2" or any number # Handle "2" or any number
outs = int(outs_raw) if outs_raw and str(outs_raw).strip() else 0 outs = int(outs_raw) if outs_raw and str(outs_raw).strip() else 0
@ -232,34 +290,42 @@ class ScorebugService(SheetsService):
] ]
# Pitcher: matchups[0][0]=name, [1]=URL, [2]=stats # Pitcher: matchups[0][0]=name, [1]=URL, [2]=stats
pitcher_name = matchups[0][0] if len(matchups[0]) > 0 else '' pitcher_name = matchups[0][0] if len(matchups[0]) > 0 else ""
pitcher_url = matchups[0][1] if len(matchups[0]) > 1 else '' pitcher_url = matchups[0][1] if len(matchups[0]) > 1 else ""
pitcher_stats = matchups[0][2] if len(matchups[0]) > 2 else '' pitcher_stats = matchups[0][2] if len(matchups[0]) > 2 else ""
self.logger.debug(f" Pitcher: {pitcher_name} | {pitcher_stats} | {pitcher_url}") self.logger.debug(
f" Pitcher: {pitcher_name} | {pitcher_stats} | {pitcher_url}"
)
# Batter: matchups[1][0]=name, [1]=URL, [2]=stats, [3]=order, [4]=position # Batter: matchups[1][0]=name, [1]=URL, [2]=stats, [3]=order, [4]=position
batter_name = matchups[1][0] if len(matchups[1]) > 0 else '' batter_name = matchups[1][0] if len(matchups[1]) > 0 else ""
batter_url = matchups[1][1] if len(matchups[1]) > 1 else '' batter_url = matchups[1][1] if len(matchups[1]) > 1 else ""
batter_stats = matchups[1][2] if len(matchups[1]) > 2 else '' batter_stats = matchups[1][2] if len(matchups[1]) > 2 else ""
self.logger.debug(f" Batter: {batter_name} | {batter_stats} | {batter_url}") self.logger.debug(
f" Batter: {batter_name} | {batter_stats} | {batter_url}"
)
# On Deck: matchups[2][0]=name # On Deck: matchups[2][0]=name
on_deck_name = matchups[2][0] if len(matchups[2]) > 0 else '' on_deck_name = matchups[2][0] if len(matchups[2]) > 0 else ""
on_deck_url = matchups[2][1] if len(matchups[2]) > 1 else '' on_deck_url = matchups[2][1] if len(matchups[2]) > 1 else ""
self.logger.debug(f" On Deck: {on_deck_name}") self.logger.debug(f" On Deck: {on_deck_name}")
# In Hole: matchups[3][0]=name # In Hole: matchups[3][0]=name
in_hole_name = matchups[3][0] if len(matchups[3]) > 0 else '' in_hole_name = matchups[3][0] if len(matchups[3]) > 0 else ""
in_hole_url = matchups[3][1] if len(matchups[3]) > 1 else '' in_hole_url = matchups[3][1] if len(matchups[3]) > 1 else ""
self.logger.debug(f" In Hole: {in_hole_name}") self.logger.debug(f" In Hole: {in_hole_name}")
# Parse win percentage from all_data[6][2] (Sheet D8 - row 8, column D) # Parse win percentage from all_data[6][2] (Sheet D8 - row 8, column D)
self.logger.debug(f"📈 Parsing win percentage from D8 (all_data[6][2]):") self.logger.debug(f"📈 Parsing win percentage from D8 (all_data[6][2]):")
try: try:
win_pct_raw = all_data[6][2] if len(all_data) > 6 and len(all_data[6]) > 2 else '50%' win_pct_raw = (
all_data[6][2]
if len(all_data) > 6 and len(all_data[6]) > 2
else "50%"
)
self.logger.debug(f" Raw win percentage value: '{win_pct_raw}'") self.logger.debug(f" Raw win percentage value: '{win_pct_raw}'")
# Remove % sign if present and convert to float # Remove % sign if present and convert to float
win_pct_str = str(win_pct_raw).replace('%', '').strip() win_pct_str = str(win_pct_raw).replace("%", "").strip()
win_percentage = float(win_pct_str) if win_pct_str else 50.0 win_percentage = float(win_pct_str) if win_pct_str else 50.0
self.logger.debug(f" ✅ Parsed win percentage: {win_percentage}%") self.logger.debug(f" ✅ Parsed win percentage: {win_percentage}%")
except (ValueError, IndexError, AttributeError) as e: except (ValueError, IndexError, AttributeError) as e:
@ -281,10 +347,10 @@ class ScorebugService(SheetsService):
# Each runner is [name, URL] # Each runner is [name, URL]
self.logger.debug(f"🏃 Extracting runners from K11:L14:") self.logger.debug(f"🏃 Extracting runners from K11:L14:")
runners = [ runners = [
all_data[9][9:11] if len(all_data) > 9 else [], # Catcher (row 11) all_data[9][9:11] if len(all_data) > 9 else [], # Catcher (row 11)
all_data[10][9:11] if len(all_data) > 10 else [], # On First (row 12) all_data[10][9:11] if len(all_data) > 10 else [], # On First (row 12)
all_data[11][9:11] if len(all_data) > 11 else [], # On Second (row 13) all_data[11][9:11] if len(all_data) > 11 else [], # On Second (row 13)
all_data[12][9:11] if len(all_data) > 12 else [] # On Third (row 14) all_data[12][9:11] if len(all_data) > 12 else [], # On Third (row 14)
] ]
self.logger.debug(f" Catcher: {runners[0]}") self.logger.debug(f" Catcher: {runners[0]}")
self.logger.debug(f" On First: {runners[1]}") self.logger.debug(f" On First: {runners[1]}")
@ -308,28 +374,30 @@ class ScorebugService(SheetsService):
self.logger.debug(f"✅ Scorebug data extraction complete!") self.logger.debug(f"✅ Scorebug data extraction complete!")
scorebug_data = ScorebugData({ scorebug_data = ScorebugData(
'away_team_id': away_team_id, {
'home_team_id': home_team_id, "away_team_id": away_team_id,
'header': header, "home_team_id": home_team_id,
'away_score': away_score, "header": header,
'home_score': home_score, "away_score": away_score,
'which_half': which_half, "home_score": home_score,
'inning': inning, "which_half": which_half,
'is_final': is_final, "inning": inning,
'outs': outs, "is_final": is_final,
'win_percentage': win_percentage, "outs": outs,
'pitcher_name': pitcher_name, "win_percentage": win_percentage,
'pitcher_url': pitcher_url, "pitcher_name": pitcher_name,
'pitcher_stats': pitcher_stats, "pitcher_url": pitcher_url,
'batter_name': batter_name, "pitcher_stats": pitcher_stats,
'batter_url': batter_url, "batter_name": batter_name,
'batter_stats': batter_stats, "batter_url": batter_url,
'on_deck_name': on_deck_name, "batter_stats": batter_stats,
'in_hole_name': in_hole_name, "on_deck_name": on_deck_name,
'runners': runners, # [Catcher, On First, On Second, On Third], each is [name, URL] "in_hole_name": in_hole_name,
'summary': summary # Play-by-play lines from R3:S20 "runners": runners, # [Catcher, On First, On Second, On Third], each is [name, URL]
}) "summary": summary, # Play-by-play lines from R3:S20
}
)
self.logger.debug(f"🎯 Created ScorebugData object:") self.logger.debug(f"🎯 Created ScorebugData object:")
self.logger.debug(f" Away Team ID: {scorebug_data.away_team_id}") self.logger.debug(f" Away Team ID: {scorebug_data.away_team_id}")

View File

@ -3,6 +3,7 @@ Google Sheets Service
Handles reading data from Google Sheets scorecards for game submission. Handles reading data from Google Sheets scorecards for game submission.
""" """
import asyncio import asyncio
from typing import Dict, List, Any, Optional from typing import Dict, List, Any, Optional
import pygsheets import pygsheets
@ -24,10 +25,11 @@ class SheetsService:
""" """
if credentials_path is None: if credentials_path is None:
from config import get_config from config import get_config
credentials_path = get_config().sheets_credentials_path credentials_path = get_config().sheets_credentials_path
self.credentials_path = credentials_path self.credentials_path = credentials_path
self.logger = get_contextual_logger(f'{__name__}.SheetsService') self.logger = get_contextual_logger(f"{__name__}.SheetsService")
self._sheets_client = None self._sheets_client = None
def _get_client(self) -> pygsheets.client.Client: def _get_client(self) -> pygsheets.client.Client:
@ -53,7 +55,16 @@ class SheetsService:
return False return False
# Common spreadsheet errors # Common spreadsheet errors
error_values = ['#N/A', '#REF!', '#VALUE!', '#DIV/0!', '#NUM!', '#NAME?', '#NULL!', '#ERROR!'] error_values = [
"#N/A",
"#REF!",
"#VALUE!",
"#DIV/0!",
"#NUM!",
"#NAME?",
"#NULL!",
"#ERROR!",
]
return value.strip() in error_values return value.strip() in error_values
@staticmethod @staticmethod
@ -68,7 +79,7 @@ class SheetsService:
Returns: Returns:
Integer value or None if invalid Integer value or None if invalid
""" """
if value is None or value == '': if value is None or value == "":
return None return None
# Check for spreadsheet errors # Check for spreadsheet errors
@ -96,16 +107,9 @@ class SheetsService:
""" """
try: try:
# Run in thread pool since pygsheets is synchronous # Run in thread pool since pygsheets is synchronous
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
sheets = await loop.run_in_executor( sheets = await loop.run_in_executor(None, self._get_client)
None, scorecard = await loop.run_in_executor(None, sheets.open_by_url, sheet_url)
self._get_client
)
scorecard = await loop.run_in_executor(
None,
sheets.open_by_url,
sheet_url
)
self.logger.info(f"Opened scorecard: {scorecard.title}") self.logger.info(f"Opened scorecard: {scorecard.title}")
return scorecard return scorecard
@ -116,10 +120,7 @@ class SheetsService:
"Unable to access scorecard. Is it publicly readable?" "Unable to access scorecard. Is it publicly readable?"
) from e ) from e
async def read_setup_data( async def read_setup_data(self, scorecard: pygsheets.Spreadsheet) -> Dict[str, Any]:
self,
scorecard: pygsheets.Spreadsheet
) -> Dict[str, Any]:
""" """
Read game metadata from Setup tab. Read game metadata from Setup tab.
@ -138,38 +139,27 @@ class SheetsService:
- home_manager_name: str - home_manager_name: str
""" """
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
# Get Setup tab # Get Setup tab
setup_tab = await loop.run_in_executor( setup_tab = await loop.run_in_executor(
None, None, scorecard.worksheet_by_title, "Setup"
scorecard.worksheet_by_title,
'Setup'
) )
# Read version # Read version
version = await loop.run_in_executor( version = await loop.run_in_executor(None, setup_tab.get_value, "V35")
None,
setup_tab.get_value,
'V35'
)
# Read game data (C3:D7) # Read game data (C3:D7)
g_data = await loop.run_in_executor( g_data = await loop.run_in_executor(None, setup_tab.get_values, "C3", "D7")
None,
setup_tab.get_values,
'C3',
'D7'
)
return { return {
'version': version, "version": version,
'week': int(g_data[1][0]), "week": int(g_data[1][0]),
'game_num': int(g_data[2][0]), "game_num": int(g_data[2][0]),
'away_team_abbrev': g_data[3][0], "away_team_abbrev": g_data[3][0],
'home_team_abbrev': g_data[4][0], "home_team_abbrev": g_data[4][0],
'away_manager_name': g_data[3][1], "away_manager_name": g_data[3][1],
'home_manager_name': g_data[4][1] "home_manager_name": g_data[4][1],
} }
except Exception as e: except Exception as e:
@ -177,8 +167,7 @@ class SheetsService:
raise SheetsException("Unable to read game setup data") from e raise SheetsException("Unable to read game setup data") from e
async def read_playtable_data( async def read_playtable_data(
self, self, scorecard: pygsheets.Spreadsheet
scorecard: pygsheets.Spreadsheet
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Read all plays from Playtable tab. Read all plays from Playtable tab.
@ -190,49 +179,101 @@ class SheetsService:
List of play dictionaries with field names mapped List of play dictionaries with field names mapped
""" """
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
# Get Playtable tab # Get Playtable tab
playtable = await loop.run_in_executor( playtable = await loop.run_in_executor(
None, None, scorecard.worksheet_by_title, "Playtable"
scorecard.worksheet_by_title,
'Playtable'
) )
# Read play data # Read play data
all_plays = await loop.run_in_executor( all_plays = await loop.run_in_executor(
None, None, playtable.get_values, "B3", "BW300"
playtable.get_values,
'B3',
'BW300'
) )
# Field names in order (from old bot lines 1621-1632) # Field names in order (from old bot lines 1621-1632)
play_keys = [ play_keys = [
'play_num', 'batter_id', 'batter_pos', 'pitcher_id', "play_num",
'on_base_code', 'inning_half', 'inning_num', 'batting_order', "batter_id",
'starting_outs', 'away_score', 'home_score', 'on_first_id', "batter_pos",
'on_first_final', 'on_second_id', 'on_second_final', "pitcher_id",
'on_third_id', 'on_third_final', 'batter_final', 'pa', 'ab', "on_base_code",
'run', 'e_run', 'hit', 'rbi', 'double', 'triple', 'homerun', "inning_half",
'bb', 'so', 'hbp', 'sac', 'ibb', 'gidp', 'bphr', 'bpfo', "inning_num",
'bp1b', 'bplo', 'sb', 'cs', 'outs', 'pitcher_rest_outs', "batting_order",
'wpa', 'catcher_id', 'defender_id', 'runner_id', 'check_pos', "starting_outs",
'error', 'wild_pitch', 'passed_ball', 'pick_off', 'balk', "away_score",
'is_go_ahead', 'is_tied', 'is_new_inning', 'inherited_runners', "home_score",
'inherited_scored', 'on_hook_for_loss', 'run_differential', "on_first_id",
'unused-manager', 'unused-pitcherpow', 'unused-pitcherrestip', "on_first_final",
'unused-runners', 'unused-fatigue', 'unused-roundedip', "on_second_id",
'unused-elitestart', 'unused-scenario', 'unused-winxaway', "on_second_final",
'unused-winxhome', 'unused-pinchrunner', 'unused-order', "on_third_id",
'hand_batting', 'hand_pitching', 're24_primary', 're24_running' "on_third_final",
"batter_final",
"pa",
"ab",
"run",
"e_run",
"hit",
"rbi",
"double",
"triple",
"homerun",
"bb",
"so",
"hbp",
"sac",
"ibb",
"gidp",
"bphr",
"bpfo",
"bp1b",
"bplo",
"sb",
"cs",
"outs",
"pitcher_rest_outs",
"wpa",
"catcher_id",
"defender_id",
"runner_id",
"check_pos",
"error",
"wild_pitch",
"passed_ball",
"pick_off",
"balk",
"is_go_ahead",
"is_tied",
"is_new_inning",
"inherited_runners",
"inherited_scored",
"on_hook_for_loss",
"run_differential",
"unused-manager",
"unused-pitcherpow",
"unused-pitcherrestip",
"unused-runners",
"unused-fatigue",
"unused-roundedip",
"unused-elitestart",
"unused-scenario",
"unused-winxaway",
"unused-winxhome",
"unused-pinchrunner",
"unused-order",
"hand_batting",
"hand_pitching",
"re24_primary",
"re24_running",
] ]
p_data = [] p_data = []
for line in all_plays: for line in all_plays:
this_data = {} this_data = {}
for count, value in enumerate(line): for count, value in enumerate(line):
if value != '' and count < len(play_keys): if value != "" and count < len(play_keys):
this_data[play_keys[count]] = value this_data[play_keys[count]] = value
# Only include rows with meaningful data (>5 fields) # Only include rows with meaningful data (>5 fields)
@ -247,8 +288,7 @@ class SheetsService:
raise SheetsException("Unable to read play-by-play data") from e raise SheetsException("Unable to read play-by-play data") from e
async def read_pitching_decisions( async def read_pitching_decisions(
self, self, scorecard: pygsheets.Spreadsheet
scorecard: pygsheets.Spreadsheet
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Read pitching decisions from Pitcherstats tab. Read pitching decisions from Pitcherstats tab.
@ -260,37 +300,51 @@ class SheetsService:
List of decision dictionaries with field names mapped List of decision dictionaries with field names mapped
""" """
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
# Get Pitcherstats tab # Get Pitcherstats tab
pitching = await loop.run_in_executor( pitching = await loop.run_in_executor(
None, None, scorecard.worksheet_by_title, "Pitcherstats"
scorecard.worksheet_by_title,
'Pitcherstats'
) )
# Read decision data # Read decision data
all_decisions = await loop.run_in_executor( all_decisions = await loop.run_in_executor(
None, None, pitching.get_values, "B3", "O30"
pitching.get_values,
'B3',
'O30'
) )
# Field names in order (from old bot lines 1688-1691) # Field names in order (from old bot lines 1688-1691)
pit_keys = [ pit_keys = [
'pitcher_id', 'rest_ip', 'is_start', 'base_rest', "pitcher_id",
'extra_rest', 'rest_required', 'win', 'loss', 'is_save', "rest_ip",
'hold', 'b_save', 'irunners', 'irunners_scored', 'team_id' "is_start",
"base_rest",
"extra_rest",
"rest_required",
"win",
"loss",
"is_save",
"hold",
"b_save",
"irunners",
"irunners_scored",
"team_id",
] ]
# Fields that must be integers # Fields that must be integers
int_fields = { int_fields = {
'pitcher_id', 'rest_required', 'win', 'loss', 'is_save', "pitcher_id",
'hold', 'b_save', 'irunners', 'irunners_scored', 'team_id' "rest_required",
"win",
"loss",
"is_save",
"hold",
"b_save",
"irunners",
"irunners_scored",
"team_id",
} }
# Fields that are required and cannot be None # Fields that are required and cannot be None
required_fields = {'pitcher_id', 'team_id'} required_fields = {"pitcher_id", "team_id"}
pit_data = [] pit_data = []
row_num = 3 # Start at row 3 (B3 in spreadsheet) row_num = 3 # Start at row 3 (B3 in spreadsheet)
@ -310,7 +364,7 @@ class SheetsService:
field_name = pit_keys[count] field_name = pit_keys[count]
# Skip empty values # Skip empty values
if value == '': if value == "":
continue continue
# Check for spreadsheet errors # Check for spreadsheet errors
@ -332,7 +386,7 @@ class SheetsService:
# Sanitize integer fields # Sanitize integer fields
if field_name in int_fields: if field_name in int_fields:
sanitized = self._sanitize_int_field(value, field_name) sanitized = self._sanitize_int_field(value, field_name)
if sanitized is None and value != '': if sanitized is None and value != "":
self.logger.warning( self.logger.warning(
f"Row {row_num}: Invalid integer value '{value}' for field '{field_name}' - skipping row" f"Row {row_num}: Invalid integer value '{value}' for field '{field_name}' - skipping row"
) )
@ -367,8 +421,7 @@ class SheetsService:
raise SheetsException("Unable to read pitching decisions") from e raise SheetsException("Unable to read pitching decisions") from e
async def read_box_score( async def read_box_score(
self, self, scorecard: pygsheets.Spreadsheet
scorecard: pygsheets.Spreadsheet
) -> Dict[str, List[int]]: ) -> Dict[str, List[int]]:
""" """
Read box score from Scorecard or Box Score tab. Read box score from Scorecard or Box Score tab.
@ -381,38 +434,28 @@ class SheetsService:
[runs, hits, errors] [runs, hits, errors]
""" """
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
# Try Scorecard tab first # Try Scorecard tab first
try: try:
sc_tab = await loop.run_in_executor( sc_tab = await loop.run_in_executor(
None, None, scorecard.worksheet_by_title, "Scorecard"
scorecard.worksheet_by_title,
'Scorecard'
) )
score_table = await loop.run_in_executor( score_table = await loop.run_in_executor(
None, None, sc_tab.get_values, "BW8", "BY9"
sc_tab.get_values,
'BW8',
'BY9'
) )
except pygsheets.WorksheetNotFound: except pygsheets.WorksheetNotFound:
# Fallback to Box Score tab # Fallback to Box Score tab
sc_tab = await loop.run_in_executor( sc_tab = await loop.run_in_executor(
None, None, scorecard.worksheet_by_title, "Box Score"
scorecard.worksheet_by_title,
'Box Score'
) )
score_table = await loop.run_in_executor( score_table = await loop.run_in_executor(
None, None, sc_tab.get_values, "T6", "V7"
sc_tab.get_values,
'T6',
'V7'
) )
return { return {
'away': [int(x) for x in score_table[0]], # [R, H, E] "away": [int(x) for x in score_table[0]], # [R, H, E]
'home': [int(x) for x in score_table[1]] # [R, H, E] "home": [int(x) for x in score_table[1]], # [R, H, E]
} }
except Exception as e: except Exception as e:

View File

@ -4,7 +4,8 @@ Draft Monitor Task for Discord Bot v2.0
Automated background task for draft timer monitoring, warnings, and auto-draft. Automated background task for draft timer monitoring, warnings, and auto-draft.
Self-terminates when draft timer is disabled to conserve resources. Self-terminates when draft timer is disabled to conserve resources.
""" """
from datetime import datetime
from datetime import UTC, datetime
import discord import discord
from discord.ext import commands, tasks from discord.ext import commands, tasks
@ -34,7 +35,7 @@ class DraftMonitorTask:
def __init__(self, bot: commands.Bot): def __init__(self, bot: commands.Bot):
self.bot = bot self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.DraftMonitorTask') self.logger = get_contextual_logger(f"{__name__}.DraftMonitorTask")
# Warning flags (reset each pick) # Warning flags (reset each pick)
self.warning_60s_sent = False self.warning_60s_sent = False
@ -101,7 +102,7 @@ class DraftMonitorTask:
return return
# Check if we need to take action # Check if we need to take action
now = datetime.now() now = datetime.now(UTC)
deadline = draft_data.pick_deadline deadline = draft_data.pick_deadline
if not deadline: if not deadline:
@ -115,7 +116,9 @@ class DraftMonitorTask:
new_interval = self._get_poll_interval(time_remaining) new_interval = self._get_poll_interval(time_remaining)
if self.monitor_loop.seconds != new_interval: if self.monitor_loop.seconds != new_interval:
self.monitor_loop.change_interval(seconds=new_interval) self.monitor_loop.change_interval(seconds=new_interval)
self.logger.debug(f"Adjusted poll interval to {new_interval}s (time remaining: {time_remaining:.0f}s)") self.logger.debug(
f"Adjusted poll interval to {new_interval}s (time remaining: {time_remaining:.0f}s)"
)
if time_remaining <= 0: if time_remaining <= 0:
# Timer expired - auto-draft # Timer expired - auto-draft
@ -150,8 +153,7 @@ class DraftMonitorTask:
# Get current pick # Get current pick
current_pick = await draft_pick_service.get_pick( current_pick = await draft_pick_service.get_pick(
config.sba_season, config.sba_season, draft_data.currentpick
draft_data.currentpick
) )
if not current_pick or not current_pick.owner: if not current_pick or not current_pick.owner:
@ -159,7 +161,7 @@ class DraftMonitorTask:
return return
# Get draft picks cog to check/acquire lock # Get draft picks cog to check/acquire lock
draft_picks_cog = self.bot.get_cog('DraftPicksCog') draft_picks_cog = self.bot.get_cog("DraftPicksCog")
if not draft_picks_cog: if not draft_picks_cog:
self.logger.error("Could not find DraftPicksCog") self.logger.error("Could not find DraftPicksCog")
@ -172,7 +174,7 @@ class DraftMonitorTask:
# Acquire lock # Acquire lock
async with draft_picks_cog.pick_lock: async with draft_picks_cog.pick_lock:
draft_picks_cog.lock_acquired_at = datetime.now() draft_picks_cog.lock_acquired_at = datetime.now(UTC)
draft_picks_cog.lock_acquired_by = None # System auto-draft draft_picks_cog.lock_acquired_by = None # System auto-draft
try: try:
@ -199,17 +201,20 @@ class DraftMonitorTask:
# Get ping channel # Get ping channel
ping_channel = guild.get_channel(draft_data.ping_channel) ping_channel = guild.get_channel(draft_data.ping_channel)
if not ping_channel: if not ping_channel:
self.logger.error(f"Could not find ping channel {draft_data.ping_channel}") self.logger.error(
f"Could not find ping channel {draft_data.ping_channel}"
)
return return
# Get team's draft list # Get team's draft list
draft_list = await draft_list_service.get_team_list( draft_list = await draft_list_service.get_team_list(
config.sba_season, config.sba_season, current_pick.owner.id
current_pick.owner.id
) )
if not draft_list: if not draft_list:
self.logger.warning(f"Team {current_pick.owner.abbrev} has no draft list") self.logger.warning(
f"Team {current_pick.owner.abbrev} has no draft list"
)
await ping_channel.send( await ping_channel.send(
content=f"{current_pick.owner.abbrev} time expired with no draft list - pick skipped" content=f"{current_pick.owner.abbrev} time expired with no draft list - pick skipped"
) )
@ -247,11 +252,7 @@ class DraftMonitorTask:
# Attempt to draft this player # Attempt to draft this player
success = await self._attempt_draft_player( success = await self._attempt_draft_player(
current_pick, current_pick, player, ping_channel, draft_data, guild
player,
ping_channel,
draft_data,
guild
) )
if success: if success:
@ -259,7 +260,9 @@ class DraftMonitorTask:
f"Auto-drafted {player.name} for {current_pick.owner.abbrev}" f"Auto-drafted {player.name} for {current_pick.owner.abbrev}"
) )
# Advance to next pick # Advance to next pick
await draft_service.advance_pick(draft_data.id, draft_data.currentpick) await draft_service.advance_pick(
draft_data.id, draft_data.currentpick
)
# Post on-clock announcement for next team # Post on-clock announcement for next team
await self._post_on_clock_announcement(ping_channel, draft_data) await self._post_on_clock_announcement(ping_channel, draft_data)
# Reset warning flags # Reset warning flags
@ -284,12 +287,7 @@ class DraftMonitorTask:
self.logger.error("Error auto-drafting player", error=e) self.logger.error("Error auto-drafting player", error=e)
async def _attempt_draft_player( async def _attempt_draft_player(
self, self, draft_pick, player, ping_channel, draft_data, guild
draft_pick,
player,
ping_channel,
draft_data,
guild
) -> bool: ) -> bool:
""" """
Attempt to draft a specific player. Attempt to draft a specific player.
@ -309,14 +307,18 @@ class DraftMonitorTask:
from services.team_service import team_service from services.team_service import team_service
# Get team roster for cap validation # Get team roster for cap validation
roster = await team_service.get_team_roster(draft_pick.owner.id, 'current') roster = await team_service.get_team_roster(draft_pick.owner.id, "current")
if not roster: if not roster:
self.logger.error(f"Could not get roster for team {draft_pick.owner.id}") self.logger.error(
f"Could not get roster for team {draft_pick.owner.id}"
)
return False return False
# Validate cap space # Validate cap space
is_valid, projected_total, cap_limit = await validate_cap_space(roster, player.wara) is_valid, projected_total, cap_limit = await validate_cap_space(
roster, player.wara
)
if not is_valid: if not is_valid:
self.logger.debug( self.logger.debug(
@ -327,8 +329,7 @@ class DraftMonitorTask:
# Update draft pick # Update draft pick
updated_pick = await draft_pick_service.update_pick_selection( updated_pick = await draft_pick_service.update_pick_selection(
draft_pick.id, draft_pick.id, player.id
player.id
) )
if not updated_pick: if not updated_pick:
@ -338,13 +339,14 @@ class DraftMonitorTask:
# Get current league state for dem_week calculation # Get current league state for dem_week calculation
from services.player_service import player_service from services.player_service import player_service
from services.league_service import league_service from services.league_service import league_service
current = await league_service.get_current_state() current = await league_service.get_current_state()
# Update player team with dem_week set to current.week + 2 for draft picks # Update player team with dem_week set to current.week + 2 for draft picks
updated_player = await player_service.update_player_team( updated_player = await player_service.update_player_team(
player.id, player.id,
draft_pick.owner.id, draft_pick.owner.id,
dem_week=current.week + 2 if current else None dem_week=current.week + 2 if current else None,
) )
if not updated_player: if not updated_player:
@ -357,7 +359,7 @@ class DraftMonitorTask:
# Post to ping channel # Post to ping channel
await ping_channel.send( await ping_channel.send(
content=f"🤖 AUTO-DRAFT: {draft_pick.owner.abbrev} selects **{player.name}** " content=f"🤖 AUTO-DRAFT: {draft_pick.owner.abbrev} selects **{player.name}** "
f"(Pick #{draft_pick.overall})" f"(Pick #{draft_pick.overall})"
) )
# Post draft card to result channel (same as regular /draft picks) # Post draft card to result channel (same as regular /draft picks)
@ -365,11 +367,14 @@ class DraftMonitorTask:
result_channel = guild.get_channel(draft_data.result_channel) result_channel = guild.get_channel(draft_data.result_channel)
if result_channel: if result_channel:
from views.draft_views import create_player_draft_card from views.draft_views import create_player_draft_card
draft_card = await create_player_draft_card(player, draft_pick) draft_card = await create_player_draft_card(player, draft_pick)
draft_card.set_footer(text="🤖 Auto-drafted from draft list") draft_card.set_footer(text="🤖 Auto-drafted from draft list")
await result_channel.send(embed=draft_card) await result_channel.send(embed=draft_card)
else: else:
self.logger.warning(f"Could not find result channel {draft_data.result_channel}") self.logger.warning(
f"Could not find result channel {draft_data.result_channel}"
)
return True return True
@ -403,23 +408,26 @@ class DraftMonitorTask:
# Get the new current pick # Get the new current pick
next_pick = await draft_pick_service.get_pick( next_pick = await draft_pick_service.get_pick(
config.sba_season, config.sba_season, updated_draft_data.currentpick
updated_draft_data.currentpick
) )
if not next_pick or not next_pick.owner: if not next_pick or not next_pick.owner:
self.logger.error(f"Could not get pick #{updated_draft_data.currentpick} for announcement") self.logger.error(
f"Could not get pick #{updated_draft_data.currentpick} for announcement"
)
return return
# Get recent picks (last 5 completed) # Get recent picks (last 5 completed)
recent_picks = await draft_pick_service.get_recent_picks( recent_picks = await draft_pick_service.get_recent_picks(
config.sba_season, config.sba_season,
updated_draft_data.currentpick - 1, # Start from previous pick updated_draft_data.currentpick - 1, # Start from previous pick
limit=5 limit=5,
) )
# Get team roster for sWAR calculation # Get team roster for sWAR calculation
team_roster = await roster_service.get_team_roster(next_pick.owner.id, "current") team_roster = await roster_service.get_team_roster(
next_pick.owner.id, "current"
)
roster_swar = team_roster.total_wara if team_roster else 0.0 roster_swar = team_roster.total_wara if team_roster else 0.0
cap_limit = get_team_salary_cap(next_pick.owner) cap_limit = get_team_salary_cap(next_pick.owner)
@ -427,7 +435,9 @@ class DraftMonitorTask:
top_roster_players = [] top_roster_players = []
if team_roster: if team_roster:
all_players = team_roster.all_players all_players = team_roster.all_players
sorted_players = sorted(all_players, key=lambda p: p.wara if p.wara else 0.0, reverse=True) sorted_players = sorted(
all_players, key=lambda p: p.wara if p.wara else 0.0, reverse=True
)
top_roster_players = sorted_players[:5] top_roster_players = sorted_players[:5]
# Get sheet URL # Get sheet URL
@ -441,7 +451,7 @@ class DraftMonitorTask:
roster_swar=roster_swar, roster_swar=roster_swar,
cap_limit=cap_limit, cap_limit=cap_limit,
top_roster_players=top_roster_players, top_roster_players=top_roster_players,
sheet_url=sheet_url sheet_url=sheet_url,
) )
# Mention the team's role (using team.lname) # Mention the team's role (using team.lname)
@ -450,10 +460,14 @@ class DraftMonitorTask:
if team_role: if team_role:
team_mention = f"{team_role.mention} " team_mention = f"{team_role.mention} "
else: else:
self.logger.warning(f"Could not find role for team {next_pick.owner.lname}") self.logger.warning(
f"Could not find role for team {next_pick.owner.lname}"
)
await ping_channel.send(content=team_mention, embed=embed) await ping_channel.send(content=team_mention, embed=embed)
self.logger.info(f"Posted on-clock announcement for pick #{updated_draft_data.currentpick}") self.logger.info(
f"Posted on-clock announcement for pick #{updated_draft_data.currentpick}"
)
# Reset poll interval to 30s for new pick # Reset poll interval to 30s for new pick
if self.monitor_loop.seconds != 30: if self.monitor_loop.seconds != 30:
@ -484,8 +498,7 @@ class DraftMonitorTask:
# Get current pick for mention # Get current pick for mention
current_pick = await draft_pick_service.get_pick( current_pick = await draft_pick_service.get_pick(
config.sba_season, config.sba_season, draft_data.currentpick
draft_data.currentpick
) )
if not current_pick or not current_pick.owner: if not current_pick or not current_pick.owner:
@ -495,7 +508,7 @@ class DraftMonitorTask:
if 55 <= time_remaining <= 60 and not self.warning_60s_sent: if 55 <= time_remaining <= 60 and not self.warning_60s_sent:
await ping_channel.send( await ping_channel.send(
content=f"{current_pick.owner.abbrev} - **60 seconds remaining** " content=f"{current_pick.owner.abbrev} - **60 seconds remaining** "
f"for pick #{current_pick.overall}!" f"for pick #{current_pick.overall}!"
) )
self.warning_60s_sent = True self.warning_60s_sent = True
self.logger.debug(f"Sent 60s warning for pick #{current_pick.overall}") self.logger.debug(f"Sent 60s warning for pick #{current_pick.overall}")
@ -504,7 +517,7 @@ class DraftMonitorTask:
elif 25 <= time_remaining <= 30 and not self.warning_30s_sent: elif 25 <= time_remaining <= 30 and not self.warning_30s_sent:
await ping_channel.send( await ping_channel.send(
content=f"{current_pick.owner.abbrev} - **30 seconds remaining** " content=f"{current_pick.owner.abbrev} - **30 seconds remaining** "
f"for pick #{current_pick.overall}!" f"for pick #{current_pick.overall}!"
) )
self.warning_30s_sent = True self.warning_30s_sent = True
self.logger.debug(f"Sent 30s warning for pick #{current_pick.overall}") self.logger.debug(f"Sent 30s warning for pick #{current_pick.overall}")
@ -535,10 +548,14 @@ class DraftMonitorTask:
success = await draft_sheet_service.write_pick( success = await draft_sheet_service.write_pick(
season=config.sba_season, season=config.sba_season,
overall=draft_pick.overall, overall=draft_pick.overall,
orig_owner_abbrev=draft_pick.origowner.abbrev if draft_pick.origowner else draft_pick.owner.abbrev, orig_owner_abbrev=(
draft_pick.origowner.abbrev
if draft_pick.origowner
else draft_pick.owner.abbrev
),
owner_abbrev=draft_pick.owner.abbrev, owner_abbrev=draft_pick.owner.abbrev,
player_name=player.name, player_name=player.name,
swar=player.wara swar=player.wara,
) )
if not success: if not success:
@ -546,7 +563,7 @@ class DraftMonitorTask:
await self._notify_sheet_failure( await self._notify_sheet_failure(
ping_channel=ping_channel, ping_channel=ping_channel,
pick_overall=draft_pick.overall, pick_overall=draft_pick.overall,
player_name=player.name player_name=player.name,
) )
except Exception as e: except Exception as e:
@ -554,10 +571,12 @@ class DraftMonitorTask:
await self._notify_sheet_failure( await self._notify_sheet_failure(
ping_channel=ping_channel, ping_channel=ping_channel,
pick_overall=draft_pick.overall, pick_overall=draft_pick.overall,
player_name=player.name player_name=player.name,
) )
async def _notify_sheet_failure(self, ping_channel, pick_overall: int, player_name: str) -> None: async def _notify_sheet_failure(
self, ping_channel, pick_overall: int, player_name: str
) -> None:
""" """
Post notification to ping channel when sheet write fails. Post notification to ping channel when sheet write fails.

View File

@ -325,7 +325,7 @@ class TransactionFreezeTask:
self.logger.warning("Could not get current league state") self.logger.warning("Could not get current league state")
return return
now = datetime.now() now = datetime.now(UTC)
self.logger.info( self.logger.info(
f"Weekly loop check", f"Weekly loop check",
datetime=now.isoformat(), datetime=now.isoformat(),
@ -701,10 +701,10 @@ class TransactionFreezeTask:
# Build report entry # Build report entry
if winning_moves: if winning_moves:
first_move = winning_moves[0] first_move = winning_moves[0]
# Extract timestamp from moveid (format: Season-XXX-Week-XX-DD-HH:MM:SS) # Extract timestamp from moveid (format: Season-{season:03d}-Week-{week:02d}-{unix_timestamp})
try: try:
parts = winning_move_id.split("-") parts = winning_move_id.split("-")
submitted_at = parts[-1] if len(parts) >= 6 else "Unknown" submitted_at = parts[-1] if len(parts) >= 4 else "Unknown"
except Exception: except Exception:
submitted_at = "Unknown" submitted_at = "Unknown"

View File

@ -3,6 +3,7 @@ Tests for configuration management
Ensures configuration loading, validation, and environment handling work correctly. Ensures configuration loading, validation, and environment handling work correctly.
""" """
import os import os
import pytest import pytest
from unittest.mock import patch from unittest.mock import patch
@ -15,26 +16,33 @@ class TestBotConfig:
def test_config_loads_required_fields(self): def test_config_loads_required_fields(self):
"""Test that config loads all required fields from environment.""" """Test that config loads all required fields from environment."""
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': 'test_bot_token', os.environ,
'GUILD_ID': '123456789', {
'API_TOKEN': 'test_api_token', "BOT_TOKEN": "test_bot_token",
'DB_URL': 'https://api.example.com' "GUILD_ID": "123456789",
}): "API_TOKEN": "test_api_token",
"DB_URL": "https://api.example.com",
},
):
config = BotConfig() config = BotConfig()
assert config.bot_token == 'test_bot_token' assert config.bot_token == "test_bot_token"
assert config.guild_id == 123456789 assert config.guild_id == 123456789
assert config.api_token == 'test_api_token' assert config.api_token == "test_api_token"
assert config.db_url == 'https://api.example.com' assert config.db_url == "https://api.example.com"
def test_config_has_default_values(self): def test_config_has_default_values(self):
"""Test that config provides sensible defaults.""" """Test that config provides sensible defaults."""
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': 'test_bot_token', os.environ,
'GUILD_ID': '123456789', {
'API_TOKEN': 'test_api_token', "BOT_TOKEN": "test_bot_token",
'DB_URL': 'https://api.example.com' "GUILD_ID": "123456789",
}, clear=True): "API_TOKEN": "test_api_token",
"DB_URL": "https://api.example.com",
},
clear=True,
):
# Create config with disabled env file to test true defaults # Create config with disabled env file to test true defaults
config = BotConfig(_env_file=None) config = BotConfig(_env_file=None)
assert config.sba_season == 13 assert config.sba_season == 13
@ -43,20 +51,23 @@ class TestBotConfig:
assert config.sba_color == "a6ce39" assert config.sba_color == "a6ce39"
assert config.log_level == "INFO" assert config.log_level == "INFO"
assert config.environment == "development" assert config.environment == "development"
assert config.testing is True assert config.testing is False
def test_config_overrides_defaults_from_env(self): def test_config_overrides_defaults_from_env(self):
"""Test that environment variables override default values.""" """Test that environment variables override default values."""
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': 'test_bot_token', os.environ,
'GUILD_ID': '123456789', {
'API_TOKEN': 'test_api_token', "BOT_TOKEN": "test_bot_token",
'DB_URL': 'https://api.example.com', "GUILD_ID": "123456789",
'SBA_SEASON': '15', "API_TOKEN": "test_api_token",
'LOG_LEVEL': 'DEBUG', "DB_URL": "https://api.example.com",
'ENVIRONMENT': 'production', "SBA_SEASON": "15",
'TESTING': 'true' "LOG_LEVEL": "DEBUG",
}): "ENVIRONMENT": "production",
"TESTING": "true",
},
):
config = BotConfig() config = BotConfig()
assert config.sba_season == 15 assert config.sba_season == 15
assert config.log_level == "DEBUG" assert config.log_level == "DEBUG"
@ -65,111 +76,138 @@ class TestBotConfig:
def test_config_ignores_extra_env_vars(self): def test_config_ignores_extra_env_vars(self):
"""Test that extra environment variables are ignored.""" """Test that extra environment variables are ignored."""
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': 'test_bot_token', os.environ,
'GUILD_ID': '123456789', {
'API_TOKEN': 'test_api_token', "BOT_TOKEN": "test_bot_token",
'DB_URL': 'https://api.example.com', "GUILD_ID": "123456789",
'RANDOM_EXTRA_VAR': 'should_be_ignored', "API_TOKEN": "test_api_token",
'ANOTHER_RANDOM_VAR': 'also_ignored' "DB_URL": "https://api.example.com",
}): "RANDOM_EXTRA_VAR": "should_be_ignored",
"ANOTHER_RANDOM_VAR": "also_ignored",
},
):
# Should not raise validation error # Should not raise validation error
config = BotConfig() config = BotConfig()
assert config.bot_token == 'test_bot_token' assert config.bot_token == "test_bot_token"
# Extra vars should not be accessible # Extra vars should not be accessible
assert not hasattr(config, 'random_extra_var') assert not hasattr(config, "random_extra_var")
assert not hasattr(config, 'another_random_var') assert not hasattr(config, "another_random_var")
def test_config_converts_string_to_int(self): def test_config_converts_string_to_int(self):
"""Test that guild_id is properly converted from string to int.""" """Test that guild_id is properly converted from string to int."""
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': 'test_bot_token', os.environ,
'GUILD_ID': '987654321', # String input {
'API_TOKEN': 'test_api_token', "BOT_TOKEN": "test_bot_token",
'DB_URL': 'https://api.example.com' "GUILD_ID": "987654321", # String input
}): "API_TOKEN": "test_api_token",
"DB_URL": "https://api.example.com",
},
):
config = BotConfig() config = BotConfig()
assert config.guild_id == 987654321 assert config.guild_id == 987654321
assert isinstance(config.guild_id, int) assert isinstance(config.guild_id, int)
def test_config_converts_string_to_bool(self): def test_config_converts_string_to_bool(self):
"""Test that boolean fields are properly converted.""" """Test that boolean fields are properly converted."""
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': 'test_bot_token', os.environ,
'GUILD_ID': '123456789', {
'API_TOKEN': 'test_api_token', "BOT_TOKEN": "test_bot_token",
'DB_URL': 'https://api.example.com', "GUILD_ID": "123456789",
'TESTING': 'false' "API_TOKEN": "test_api_token",
}): "DB_URL": "https://api.example.com",
"TESTING": "false",
},
):
config = BotConfig() config = BotConfig()
assert config.testing is False assert config.testing is False
assert isinstance(config.testing, bool) assert isinstance(config.testing, bool)
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': 'test_bot_token', os.environ,
'GUILD_ID': '123456789', {
'API_TOKEN': 'test_api_token', "BOT_TOKEN": "test_bot_token",
'DB_URL': 'https://api.example.com', "GUILD_ID": "123456789",
'TESTING': '1' "API_TOKEN": "test_api_token",
}): "DB_URL": "https://api.example.com",
"TESTING": "1",
},
):
config = BotConfig() config = BotConfig()
assert config.testing is True assert config.testing is True
def test_config_case_insensitive(self): def test_config_case_insensitive(self):
"""Test that environment variables are case insensitive.""" """Test that environment variables are case insensitive."""
with patch.dict(os.environ, { with patch.dict(
'bot_token': 'test_bot_token', # lowercase os.environ,
'GUILD_ID': '123456789', # uppercase {
'Api_Token': 'test_api_token', # mixed case "bot_token": "test_bot_token", # lowercase
'db_url': 'https://api.example.com' "GUILD_ID": "123456789", # uppercase
}): "Api_Token": "test_api_token", # mixed case
"db_url": "https://api.example.com",
},
):
config = BotConfig() config = BotConfig()
assert config.bot_token == 'test_bot_token' assert config.bot_token == "test_bot_token"
assert config.api_token == 'test_api_token' assert config.api_token == "test_api_token"
assert config.db_url == 'https://api.example.com' assert config.db_url == "https://api.example.com"
def test_is_development_property(self): def test_is_development_property(self):
"""Test the is_development property.""" """Test the is_development property."""
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': 'test_bot_token', os.environ,
'GUILD_ID': '123456789', {
'API_TOKEN': 'test_api_token', "BOT_TOKEN": "test_bot_token",
'DB_URL': 'https://api.example.com', "GUILD_ID": "123456789",
'ENVIRONMENT': 'development' "API_TOKEN": "test_api_token",
}): "DB_URL": "https://api.example.com",
"ENVIRONMENT": "development",
},
):
config = BotConfig() config = BotConfig()
assert config.is_development is True assert config.is_development is True
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': 'test_bot_token', os.environ,
'GUILD_ID': '123456789', {
'API_TOKEN': 'test_api_token', "BOT_TOKEN": "test_bot_token",
'DB_URL': 'https://api.example.com', "GUILD_ID": "123456789",
'ENVIRONMENT': 'production' "API_TOKEN": "test_api_token",
}): "DB_URL": "https://api.example.com",
"ENVIRONMENT": "production",
},
):
config = BotConfig() config = BotConfig()
assert config.is_development is False assert config.is_development is False
def test_is_testing_property(self): def test_is_testing_property(self):
"""Test the is_testing property.""" """Test the is_testing property."""
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': 'test_bot_token', os.environ,
'GUILD_ID': '123456789', {
'API_TOKEN': 'test_api_token', "BOT_TOKEN": "test_bot_token",
'DB_URL': 'https://api.example.com', "GUILD_ID": "123456789",
'TESTING': 'true' "API_TOKEN": "test_api_token",
}): "DB_URL": "https://api.example.com",
"TESTING": "true",
},
):
config = BotConfig() config = BotConfig()
assert config.is_testing is True assert config.is_testing is True
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': 'test_bot_token', os.environ,
'GUILD_ID': '123456789', {
'API_TOKEN': 'test_api_token', "BOT_TOKEN": "test_bot_token",
'DB_URL': 'https://api.example.com', "GUILD_ID": "123456789",
'TESTING': 'false' "API_TOKEN": "test_api_token",
}): "DB_URL": "https://api.example.com",
"TESTING": "false",
},
):
config = BotConfig() config = BotConfig()
assert config.is_testing is False assert config.is_testing is False
@ -180,62 +218,79 @@ class TestConfigValidation:
def test_missing_required_field_raises_error(self): def test_missing_required_field_raises_error(self):
"""Test that missing required fields raise validation errors.""" """Test that missing required fields raise validation errors."""
# Missing BOT_TOKEN # Missing BOT_TOKEN
with patch.dict(os.environ, { with patch.dict(
'GUILD_ID': '123456789', os.environ,
'API_TOKEN': 'test_api_token', {
'DB_URL': 'https://api.example.com' "GUILD_ID": "123456789",
}, clear=True): "API_TOKEN": "test_api_token",
"DB_URL": "https://api.example.com",
},
clear=True,
):
with pytest.raises(Exception): # Pydantic ValidationError with pytest.raises(Exception): # Pydantic ValidationError
BotConfig(_env_file=None) BotConfig(_env_file=None)
# Missing GUILD_ID # Missing GUILD_ID
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': 'test_bot_token', os.environ,
'API_TOKEN': 'test_api_token', {
'DB_URL': 'https://api.example.com' "BOT_TOKEN": "test_bot_token",
}, clear=True): "API_TOKEN": "test_api_token",
"DB_URL": "https://api.example.com",
},
clear=True,
):
with pytest.raises(Exception): # Pydantic ValidationError with pytest.raises(Exception): # Pydantic ValidationError
BotConfig(_env_file=None) BotConfig(_env_file=None)
def test_invalid_guild_id_raises_error(self): def test_invalid_guild_id_raises_error(self):
"""Test that invalid guild_id values raise validation errors.""" """Test that invalid guild_id values raise validation errors."""
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': 'test_bot_token', os.environ,
'GUILD_ID': 'not_a_number', {
'API_TOKEN': 'test_api_token', "BOT_TOKEN": "test_bot_token",
'DB_URL': 'https://api.example.com' "GUILD_ID": "not_a_number",
}): "API_TOKEN": "test_api_token",
"DB_URL": "https://api.example.com",
},
):
with pytest.raises(Exception): # Pydantic ValidationError with pytest.raises(Exception): # Pydantic ValidationError
BotConfig() BotConfig()
def test_empty_required_field_is_allowed(self): def test_empty_required_field_is_allowed(self):
"""Test that empty required fields are allowed (Pydantic default behavior).""" """Test that empty required fields are allowed (Pydantic default behavior)."""
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': '', # Empty string os.environ,
'GUILD_ID': '123456789', {
'API_TOKEN': 'test_api_token', "BOT_TOKEN": "", # Empty string
'DB_URL': 'https://api.example.com' "GUILD_ID": "123456789",
}): "API_TOKEN": "test_api_token",
"DB_URL": "https://api.example.com",
},
):
# Should not raise - Pydantic allows empty strings by default # Should not raise - Pydantic allows empty strings by default
config = BotConfig() config = BotConfig()
assert config.bot_token == '' assert config.bot_token == ""
@pytest.fixture @pytest.fixture
def valid_config(): def valid_config():
"""Provide a valid configuration for testing.""" """Provide a valid configuration for testing."""
with patch.dict(os.environ, { with patch.dict(
'BOT_TOKEN': 'test_bot_token', os.environ,
'GUILD_ID': '123456789', {
'API_TOKEN': 'test_api_token', "BOT_TOKEN": "test_bot_token",
'DB_URL': 'https://api.example.com' "GUILD_ID": "123456789",
}): "API_TOKEN": "test_api_token",
"DB_URL": "https://api.example.com",
},
):
return BotConfig() return BotConfig()
def test_config_fixture(valid_config): def test_config_fixture(valid_config):
"""Test that the valid_config fixture works correctly.""" """Test that the valid_config fixture works correctly."""
assert valid_config.bot_token == 'test_bot_token' assert valid_config.bot_token == "test_bot_token"
assert valid_config.guild_id == 123456789 assert valid_config.guild_id == 123456789
assert valid_config.api_token == 'test_api_token' assert valid_config.api_token == "test_api_token"
assert valid_config.db_url == 'https://api.example.com' assert valid_config.db_url == "https://api.example.com"

View File

@ -3,6 +3,7 @@ Simplified tests for Custom Command models in Discord Bot v2.0
Testing dataclass models without Pydantic validation. Testing dataclass models without Pydantic validation.
""" """
import pytest import pytest
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -11,7 +12,7 @@ from models.custom_command import (
CustomCommandCreator, CustomCommandCreator,
CustomCommandSearchFilters, CustomCommandSearchFilters,
CustomCommandSearchResult, CustomCommandSearchResult,
CustomCommandStats CustomCommandStats,
) )
@ -28,7 +29,7 @@ class TestCustomCommandCreator:
display_name="Test User", display_name="Test User",
created_at=now, created_at=now,
total_commands=10, total_commands=10,
active_commands=5 active_commands=5,
) )
assert creator.id == 1 assert creator.id == 1
@ -49,7 +50,7 @@ class TestCustomCommandCreator:
display_name=None, display_name=None,
created_at=now, created_at=now,
total_commands=0, total_commands=0,
active_commands=0 active_commands=0,
) )
assert creator.display_name is None assert creator.display_name is None
@ -70,7 +71,7 @@ class TestCustomCommand:
display_name="Test User", display_name="Test User",
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
total_commands=5, total_commands=5,
active_commands=5 active_commands=5,
) )
def test_command_basic_creation(self, sample_creator: CustomCommandCreator): def test_command_basic_creation(self, sample_creator: CustomCommandCreator):
@ -88,7 +89,7 @@ class TestCustomCommand:
use_count=0, use_count=0,
warning_sent=False, warning_sent=False,
is_active=True, is_active=True,
tags=None tags=None,
) )
assert command.id == 1 assert command.id == 1
@ -121,7 +122,7 @@ class TestCustomCommand:
use_count=25, use_count=25,
warning_sent=True, warning_sent=True,
is_active=True, is_active=True,
tags=["fun", "utility"] tags=["fun", "utility"],
) )
assert command.use_count == 25 assert command.use_count == 25
@ -147,15 +148,19 @@ class TestCustomCommand:
use_count=1, use_count=1,
warning_sent=False, warning_sent=False,
is_active=True, is_active=True,
tags=None tags=None,
) )
# Mock datetime.utcnow for consistent testing # Mock datetime.utcnow for consistent testing
with pytest.MonkeyPatch().context() as m: with pytest.MonkeyPatch().context() as m:
m.setattr('models.custom_command.datetime', type('MockDateTime', (), { m.setattr(
'utcnow': lambda: now, "models.custom_command.datetime",
'now': lambda: now type(
})) "MockDateTime",
(),
{"utcnow": lambda: now, "now": lambda tz=None: now},
),
)
assert command.days_since_last_use == 5 assert command.days_since_last_use == 5
# Command never used # Command never used
@ -171,7 +176,7 @@ class TestCustomCommand:
use_count=0, use_count=0,
warning_sent=False, warning_sent=False,
is_active=True, is_active=True,
tags=None tags=None,
) )
assert unused_command.days_since_last_use is None assert unused_command.days_since_last_use is None
@ -193,14 +198,18 @@ class TestCustomCommand:
use_count=50, use_count=50,
warning_sent=False, warning_sent=False,
is_active=True, is_active=True,
tags=None tags=None,
) )
with pytest.MonkeyPatch().context() as m: with pytest.MonkeyPatch().context() as m:
m.setattr('models.custom_command.datetime', type('MockDateTime', (), { m.setattr(
'utcnow': lambda: now, "models.custom_command.datetime",
'now': lambda: now type(
})) "MockDateTime",
(),
{"utcnow": lambda: now, "now": lambda tz=None: now},
),
)
score = recent_command.popularity_score score = recent_command.popularity_score
assert 0 <= score <= 15 # Can be higher due to recency bonus assert 0 <= score <= 15 # Can be higher due to recency bonus
assert score > 0 # Should have some score due to usage assert score > 0 # Should have some score due to usage
@ -218,7 +227,7 @@ class TestCustomCommand:
use_count=0, use_count=0,
warning_sent=False, warning_sent=False,
is_active=True, is_active=True,
tags=None tags=None,
) )
assert unused_command.popularity_score == 0 assert unused_command.popularity_score == 0
@ -250,7 +259,7 @@ class TestCustomCommandSearchFilters:
sort_by="popularity", sort_by="popularity",
sort_desc=True, sort_desc=True,
page=2, page=2,
page_size=10 page_size=10,
) )
assert filters.name_contains == "test" assert filters.name_contains == "test"
@ -275,7 +284,7 @@ class TestCustomCommandSearchResult:
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
display_name=None, display_name=None,
total_commands=3, total_commands=3,
active_commands=3 active_commands=3,
) )
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@ -292,7 +301,7 @@ class TestCustomCommandSearchResult:
use_count=0, use_count=0,
warning_sent=False, warning_sent=False,
is_active=True, is_active=True,
tags=None tags=None,
) )
for i in range(3) for i in range(3)
] ]
@ -305,7 +314,7 @@ class TestCustomCommandSearchResult:
page=1, page=1,
page_size=20, page_size=20,
total_pages=1, total_pages=1,
has_more=False has_more=False,
) )
assert result.commands == sample_commands assert result.commands == sample_commands
@ -323,11 +332,11 @@ class TestCustomCommandSearchResult:
page=2, page=2,
page_size=20, page_size=20,
total_pages=3, total_pages=3,
has_more=True has_more=True,
) )
assert result.start_index == 21 # (2-1) * 20 + 1 assert result.start_index == 21 # (2-1) * 20 + 1
assert result.end_index == 40 # min(2 * 20, 47) assert result.end_index == 40 # min(2 * 20, 47)
class TestCustomCommandStats: class TestCustomCommandStats:
@ -342,7 +351,7 @@ class TestCustomCommandStats:
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
display_name=None, display_name=None,
total_commands=50, total_commands=50,
active_commands=45 active_commands=45,
) )
command = CustomCommand( command = CustomCommand(
@ -357,7 +366,7 @@ class TestCustomCommandStats:
use_count=100, use_count=100,
warning_sent=False, warning_sent=False,
is_active=True, is_active=True,
tags=None tags=None,
) )
stats = CustomCommandStats( stats = CustomCommandStats(
@ -369,7 +378,7 @@ class TestCustomCommandStats:
most_active_creator=creator, most_active_creator=creator,
recent_commands_count=15, recent_commands_count=15,
commands_needing_warning=5, commands_needing_warning=5,
commands_eligible_for_deletion=2 commands_eligible_for_deletion=2,
) )
assert stats.total_commands == 100 assert stats.total_commands == 100
@ -394,7 +403,7 @@ class TestCustomCommandStats:
most_active_creator=None, most_active_creator=None,
recent_commands_count=0, recent_commands_count=0,
commands_needing_warning=0, commands_needing_warning=0,
commands_eligible_for_deletion=0 commands_eligible_for_deletion=0,
) )
assert stats.average_uses_per_command == 20.0 # 1000 / 50 assert stats.average_uses_per_command == 20.0 # 1000 / 50
@ -410,7 +419,7 @@ class TestCustomCommandStats:
most_active_creator=None, most_active_creator=None,
recent_commands_count=0, recent_commands_count=0,
commands_needing_warning=0, commands_needing_warning=0,
commands_eligible_for_deletion=0 commands_eligible_for_deletion=0,
) )
assert empty_stats.average_uses_per_command == 0.0 assert empty_stats.average_uses_per_command == 0.0
@ -430,7 +439,7 @@ class TestModelIntegration:
display_name="Test User", display_name="Test User",
created_at=now, created_at=now,
total_commands=3, total_commands=3,
active_commands=3 active_commands=3,
) )
command = CustomCommand( command = CustomCommand(
@ -445,7 +454,7 @@ class TestModelIntegration:
use_count=0, use_count=0,
warning_sent=False, warning_sent=False,
is_active=True, is_active=True,
tags=None tags=None,
) )
# Verify relationship # Verify relationship
@ -457,11 +466,7 @@ class TestModelIntegration:
def test_search_result_with_filters(self): def test_search_result_with_filters(self):
"""Test search result creation with filters.""" """Test search result creation with filters."""
filters = CustomCommandSearchFilters( filters = CustomCommandSearchFilters(
name_contains="test", name_contains="test", min_uses=5, sort_by="popularity", page=2, page_size=10
min_uses=5,
sort_by="popularity",
page=2,
page_size=10
) )
creator = CustomCommandCreator( creator = CustomCommandCreator(
@ -471,7 +476,7 @@ class TestModelIntegration:
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
display_name=None, display_name=None,
total_commands=1, total_commands=1,
active_commands=1 active_commands=1,
) )
commands = [ commands = [
@ -487,7 +492,7 @@ class TestModelIntegration:
use_count=0, use_count=0,
warning_sent=False, warning_sent=False,
is_active=True, is_active=True,
tags=None tags=None,
) )
] ]
@ -497,7 +502,7 @@ class TestModelIntegration:
page=filters.page, page=filters.page,
page_size=filters.page_size, page_size=filters.page_size,
total_pages=3, total_pages=3,
has_more=True has_more=True,
) )
assert result.page == 2 assert result.page == 2

View File

@ -3,15 +3,16 @@ Tests for Help Command models
Validates model creation, validation, and business logic. Validates model creation, validation, and business logic.
""" """
import pytest import pytest
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from pydantic import ValidationError from pydantic import ValidationError
from models.help_command import ( from models.help_command import (
HelpCommand, HelpCommand,
HelpCommandSearchFilters, HelpCommandSearchFilters,
HelpCommandSearchResult, HelpCommandSearchResult,
HelpCommandStats HelpCommandStats,
) )
@ -22,133 +23,133 @@ class TestHelpCommandModel:
"""Test help command creation with minimal required fields.""" """Test help command creation with minimal required fields."""
help_cmd = HelpCommand( help_cmd = HelpCommand(
id=1, id=1,
name='test-topic', name="test-topic",
title='Test Topic', title="Test Topic",
content='This is test content', content="This is test content",
created_by_discord_id='123456789', created_by_discord_id="123456789",
created_at=datetime.now() created_at=datetime.now(UTC),
) )
assert help_cmd.id == 1 assert help_cmd.id == 1
assert help_cmd.name == 'test-topic' assert help_cmd.name == "test-topic"
assert help_cmd.title == 'Test Topic' assert help_cmd.title == "Test Topic"
assert help_cmd.content == 'This is test content' assert help_cmd.content == "This is test content"
assert help_cmd.created_by_discord_id == '123456789' assert help_cmd.created_by_discord_id == "123456789"
assert help_cmd.is_active is True assert help_cmd.is_active is True
assert help_cmd.view_count == 0 assert help_cmd.view_count == 0
def test_help_command_creation_with_optional_fields(self): def test_help_command_creation_with_optional_fields(self):
"""Test help command creation with all optional fields.""" """Test help command creation with all optional fields."""
now = datetime.now() now = datetime.now(UTC)
help_cmd = HelpCommand( help_cmd = HelpCommand(
id=2, id=2,
name='trading-rules', name="trading-rules",
title='Trading Rules & Guidelines', title="Trading Rules & Guidelines",
content='Complete trading rules...', content="Complete trading rules...",
category='rules', category="rules",
created_by_discord_id='123456789', created_by_discord_id="123456789",
created_at=now, created_at=now,
updated_at=now, updated_at=now,
last_modified_by='987654321', last_modified_by="987654321",
is_active=True, is_active=True,
view_count=100, view_count=100,
display_order=10 display_order=10,
) )
assert help_cmd.category == 'rules' assert help_cmd.category == "rules"
assert help_cmd.updated_at == now assert help_cmd.updated_at == now
assert help_cmd.last_modified_by == '987654321' assert help_cmd.last_modified_by == "987654321"
assert help_cmd.view_count == 100 assert help_cmd.view_count == 100
assert help_cmd.display_order == 10 assert help_cmd.display_order == 10
def test_help_command_name_validation(self): def test_help_command_name_validation(self):
"""Test help command name validation.""" """Test help command name validation."""
base_data = { base_data = {
'id': 3, "id": 3,
'title': 'Test', "title": "Test",
'content': 'Content', "content": "Content",
'created_by_discord_id': '123', "created_by_discord_id": "123",
'created_at': datetime.now() "created_at": datetime.now(UTC),
} }
# Valid names # Valid names
valid_names = ['test', 'test-topic', 'test_topic', 'test123', 'abc'] valid_names = ["test", "test-topic", "test_topic", "test123", "abc"]
for name in valid_names: for name in valid_names:
help_cmd = HelpCommand(name=name, **base_data) help_cmd = HelpCommand(name=name, **base_data)
assert help_cmd.name == name.lower() assert help_cmd.name == name.lower()
# Invalid names - too short # Invalid names - too short
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
HelpCommand(name='a', **base_data) HelpCommand(name="a", **base_data)
# Invalid names - too long # Invalid names - too long
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
HelpCommand(name='a' * 33, **base_data) HelpCommand(name="a" * 33, **base_data)
# Invalid names - special characters # Invalid names - special characters
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
HelpCommand(name='test@topic', **base_data) HelpCommand(name="test@topic", **base_data)
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
HelpCommand(name='test topic', **base_data) HelpCommand(name="test topic", **base_data)
def test_help_command_title_validation(self): def test_help_command_title_validation(self):
"""Test help command title validation.""" """Test help command title validation."""
base_data = { base_data = {
'id': 4, "id": 4,
'name': 'test', "name": "test",
'content': 'Content', "content": "Content",
'created_by_discord_id': '123', "created_by_discord_id": "123",
'created_at': datetime.now() "created_at": datetime.now(UTC),
} }
# Valid title # Valid title
help_cmd = HelpCommand(title='Test Topic', **base_data) help_cmd = HelpCommand(title="Test Topic", **base_data)
assert help_cmd.title == 'Test Topic' assert help_cmd.title == "Test Topic"
# Empty title # Empty title
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
HelpCommand(title='', **base_data) HelpCommand(title="", **base_data)
# Title too long # Title too long
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
HelpCommand(title='a' * 201, **base_data) HelpCommand(title="a" * 201, **base_data)
def test_help_command_content_validation(self): def test_help_command_content_validation(self):
"""Test help command content validation.""" """Test help command content validation."""
base_data = { base_data = {
'id': 5, "id": 5,
'name': 'test', "name": "test",
'title': 'Test', "title": "Test",
'created_by_discord_id': '123', "created_by_discord_id": "123",
'created_at': datetime.now() "created_at": datetime.now(UTC),
} }
# Valid content # Valid content
help_cmd = HelpCommand(content='Test content', **base_data) help_cmd = HelpCommand(content="Test content", **base_data)
assert help_cmd.content == 'Test content' assert help_cmd.content == "Test content"
# Empty content # Empty content
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
HelpCommand(content='', **base_data) HelpCommand(content="", **base_data)
# Content too long # Content too long
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
HelpCommand(content='a' * 4001, **base_data) HelpCommand(content="a" * 4001, **base_data)
def test_help_command_category_validation(self): def test_help_command_category_validation(self):
"""Test help command category validation.""" """Test help command category validation."""
base_data = { base_data = {
'id': 6, "id": 6,
'name': 'test', "name": "test",
'title': 'Test', "title": "Test",
'content': 'Content', "content": "Content",
'created_by_discord_id': '123', "created_by_discord_id": "123",
'created_at': datetime.now() "created_at": datetime.now(UTC),
} }
# Valid categories # Valid categories
valid_categories = ['rules', 'guides', 'resources', 'info', 'faq'] valid_categories = ["rules", "guides", "resources", "info", "faq"]
for category in valid_categories: for category in valid_categories:
help_cmd = HelpCommand(category=category, **base_data) help_cmd = HelpCommand(category=category, **base_data)
assert help_cmd.category == category.lower() assert help_cmd.category == category.lower()
@ -159,28 +160,28 @@ class TestHelpCommandModel:
# Invalid category - special characters # Invalid category - special characters
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
HelpCommand(category='test@category', **base_data) HelpCommand(category="test@category", **base_data)
def test_help_command_is_deleted_property(self): def test_help_command_is_deleted_property(self):
"""Test is_deleted property.""" """Test is_deleted property."""
active = HelpCommand( active = HelpCommand(
id=7, id=7,
name='active', name="active",
title='Active Topic', title="Active Topic",
content='Content', content="Content",
created_by_discord_id='123', created_by_discord_id="123",
created_at=datetime.now(), created_at=datetime.now(UTC),
is_active=True is_active=True,
) )
deleted = HelpCommand( deleted = HelpCommand(
id=8, id=8,
name='deleted', name="deleted",
title='Deleted Topic', title="Deleted Topic",
content='Content', content="Content",
created_by_discord_id='123', created_by_discord_id="123",
created_at=datetime.now(), created_at=datetime.now(UTC),
is_active=False is_active=False,
) )
assert active.is_deleted is False assert active.is_deleted is False
@ -191,24 +192,24 @@ class TestHelpCommandModel:
# No updates # No updates
no_update = HelpCommand( no_update = HelpCommand(
id=9, id=9,
name='test', name="test",
title='Test', title="Test",
content='Content', content="Content",
created_by_discord_id='123', created_by_discord_id="123",
created_at=datetime.now(), created_at=datetime.now(UTC),
updated_at=None updated_at=None,
) )
assert no_update.days_since_update is None assert no_update.days_since_update is None
# Recent update # Recent update
recent = HelpCommand( recent = HelpCommand(
id=10, id=10,
name='test', name="test",
title='Test', title="Test",
content='Content', content="Content",
created_by_discord_id='123', created_by_discord_id="123",
created_at=datetime.now(), created_at=datetime.now(UTC),
updated_at=datetime.now() - timedelta(days=5) updated_at=datetime.now(UTC) - timedelta(days=5),
) )
assert recent.days_since_update == 5 assert recent.days_since_update == 5
@ -216,11 +217,11 @@ class TestHelpCommandModel:
"""Test days_since_creation property.""" """Test days_since_creation property."""
old = HelpCommand( old = HelpCommand(
id=11, id=11,
name='test', name="test",
title='Test', title="Test",
content='Content', content="Content",
created_by_discord_id='123', created_by_discord_id="123",
created_at=datetime.now() - timedelta(days=30) created_at=datetime.now(UTC) - timedelta(days=30),
) )
assert old.days_since_creation == 30 assert old.days_since_creation == 30
@ -229,24 +230,24 @@ class TestHelpCommandModel:
# No views # No views
no_views = HelpCommand( no_views = HelpCommand(
id=12, id=12,
name='test', name="test",
title='Test', title="Test",
content='Content', content="Content",
created_by_discord_id='123', created_by_discord_id="123",
created_at=datetime.now(), created_at=datetime.now(UTC),
view_count=0 view_count=0,
) )
assert no_views.popularity_score == 0.0 assert no_views.popularity_score == 0.0
# New topic with views # New topic with views
new_popular = HelpCommand( new_popular = HelpCommand(
id=13, id=13,
name='test', name="test",
title='Test', title="Test",
content='Content', content="Content",
created_by_discord_id='123', created_by_discord_id="123",
created_at=datetime.now() - timedelta(days=5), created_at=datetime.now(UTC) - timedelta(days=5),
view_count=50 view_count=50,
) )
score = new_popular.popularity_score score = new_popular.popularity_score
assert score > 5.0 # Base score (5.0) with new topic bonus (1.5x) assert score > 5.0 # Base score (5.0) with new topic bonus (1.5x)
@ -254,12 +255,12 @@ class TestHelpCommandModel:
# Old topic with views # Old topic with views
old_popular = HelpCommand( old_popular = HelpCommand(
id=14, id=14,
name='test', name="test",
title='Test', title="Test",
content='Content', content="Content",
created_by_discord_id='123', created_by_discord_id="123",
created_at=datetime.now() - timedelta(days=100), created_at=datetime.now(UTC) - timedelta(days=100),
view_count=50 view_count=50,
) )
old_score = old_popular.popularity_score old_score = old_popular.popularity_score
assert old_score < new_popular.popularity_score # Older topics get penalty assert old_score < new_popular.popularity_score # Older topics get penalty
@ -275,7 +276,7 @@ class TestHelpCommandSearchFilters:
assert filters.name_contains is None assert filters.name_contains is None
assert filters.category is None assert filters.category is None
assert filters.is_active is True assert filters.is_active is True
assert filters.sort_by == 'name' assert filters.sort_by == "name"
assert filters.sort_desc is False assert filters.sort_desc is False
assert filters.page == 1 assert filters.page == 1
assert filters.page_size == 25 assert filters.page_size == 25
@ -283,19 +284,19 @@ class TestHelpCommandSearchFilters:
def test_search_filters_custom_values(self): def test_search_filters_custom_values(self):
"""Test search filters with custom values.""" """Test search filters with custom values."""
filters = HelpCommandSearchFilters( filters = HelpCommandSearchFilters(
name_contains='trading', name_contains="trading",
category='rules', category="rules",
is_active=False, is_active=False,
sort_by='view_count', sort_by="view_count",
sort_desc=True, sort_desc=True,
page=2, page=2,
page_size=50 page_size=50,
) )
assert filters.name_contains == 'trading' assert filters.name_contains == "trading"
assert filters.category == 'rules' assert filters.category == "rules"
assert filters.is_active is False assert filters.is_active is False
assert filters.sort_by == 'view_count' assert filters.sort_by == "view_count"
assert filters.sort_desc is True assert filters.sort_desc is True
assert filters.page == 2 assert filters.page == 2
assert filters.page_size == 50 assert filters.page_size == 50
@ -303,14 +304,22 @@ class TestHelpCommandSearchFilters:
def test_search_filters_sort_by_validation(self): def test_search_filters_sort_by_validation(self):
"""Test sort_by field validation.""" """Test sort_by field validation."""
# Valid sort fields # Valid sort fields
valid_sorts = ['name', 'title', 'category', 'created_at', 'updated_at', 'view_count', 'display_order'] valid_sorts = [
"name",
"title",
"category",
"created_at",
"updated_at",
"view_count",
"display_order",
]
for sort_field in valid_sorts: for sort_field in valid_sorts:
filters = HelpCommandSearchFilters(sort_by=sort_field) filters = HelpCommandSearchFilters(sort_by=sort_field)
assert filters.sort_by == sort_field assert filters.sort_by == sort_field
# Invalid sort field # Invalid sort field
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
HelpCommandSearchFilters(sort_by='invalid_field') HelpCommandSearchFilters(sort_by="invalid_field")
def test_search_filters_page_validation(self): def test_search_filters_page_validation(self):
"""Test page number validation.""" """Test page number validation."""
@ -353,11 +362,11 @@ class TestHelpCommandSearchResult:
help_commands = [ help_commands = [
HelpCommand( HelpCommand(
id=i, id=i,
name=f'topic-{i}', name=f"topic-{i}",
title=f'Topic {i}', title=f"Topic {i}",
content=f'Content {i}', content=f"Content {i}",
created_by_discord_id='123', created_by_discord_id="123",
created_at=datetime.now() created_at=datetime.now(UTC),
) )
for i in range(1, 11) for i in range(1, 11)
] ]
@ -368,7 +377,7 @@ class TestHelpCommandSearchResult:
page=1, page=1,
page_size=10, page_size=10,
total_pages=5, total_pages=5,
has_more=True has_more=True,
) )
assert len(result.help_commands) == 10 assert len(result.help_commands) == 10
@ -386,7 +395,7 @@ class TestHelpCommandSearchResult:
page=3, page=3,
page_size=25, page_size=25,
total_pages=4, total_pages=4,
has_more=True has_more=True,
) )
assert result.start_index == 51 # (3-1) * 25 + 1 assert result.start_index == 51 # (3-1) * 25 + 1
@ -400,7 +409,7 @@ class TestHelpCommandSearchResult:
page=3, page=3,
page_size=25, page_size=25,
total_pages=3, total_pages=3,
has_more=False has_more=False,
) )
assert result.end_index == 55 # min(3 * 25, 55) assert result.end_index == 55 # min(3 * 25, 55)
@ -412,7 +421,7 @@ class TestHelpCommandSearchResult:
page=2, page=2,
page_size=25, page_size=25,
total_pages=4, total_pages=4,
has_more=True has_more=True,
) )
assert result.end_index == 50 # min(2 * 25, 100) assert result.end_index == 50 # min(2 * 25, 100)
@ -428,7 +437,7 @@ class TestHelpCommandStats:
active_commands=45, active_commands=45,
total_views=1000, total_views=1000,
most_viewed_command=None, most_viewed_command=None,
recent_commands_count=5 recent_commands_count=5,
) )
assert stats.total_commands == 50 assert stats.total_commands == 50
@ -441,12 +450,12 @@ class TestHelpCommandStats:
"""Test stats with most viewed command.""" """Test stats with most viewed command."""
most_viewed = HelpCommand( most_viewed = HelpCommand(
id=1, id=1,
name='popular-topic', name="popular-topic",
title='Popular Topic', title="Popular Topic",
content='Content', content="Content",
created_by_discord_id='123', created_by_discord_id="123",
created_at=datetime.now(), created_at=datetime.now(UTC),
view_count=500 view_count=500,
) )
stats = HelpCommandStats( stats = HelpCommandStats(
@ -454,11 +463,11 @@ class TestHelpCommandStats:
active_commands=45, active_commands=45,
total_views=1000, total_views=1000,
most_viewed_command=most_viewed, most_viewed_command=most_viewed,
recent_commands_count=5 recent_commands_count=5,
) )
assert stats.most_viewed_command is not None assert stats.most_viewed_command is not None
assert stats.most_viewed_command.name == 'popular-topic' assert stats.most_viewed_command.name == "popular-topic"
assert stats.most_viewed_command.view_count == 500 assert stats.most_viewed_command.view_count == 500
def test_stats_average_views_per_command(self): def test_stats_average_views_per_command(self):
@ -469,7 +478,7 @@ class TestHelpCommandStats:
active_commands=40, active_commands=40,
total_views=800, total_views=800,
most_viewed_command=None, most_viewed_command=None,
recent_commands_count=5 recent_commands_count=5,
) )
assert stats.average_views_per_command == 20.0 # 800 / 40 assert stats.average_views_per_command == 20.0 # 800 / 40
@ -480,7 +489,7 @@ class TestHelpCommandStats:
active_commands=0, active_commands=0,
total_views=0, total_views=0,
most_viewed_command=None, most_viewed_command=None,
recent_commands_count=0 recent_commands_count=0,
) )
assert stats.average_views_per_command == 0.0 assert stats.average_views_per_command == 0.0
@ -492,44 +501,44 @@ class TestHelpCommandFromAPIData:
def test_from_api_data_complete(self): def test_from_api_data_complete(self):
"""Test from_api_data with complete data.""" """Test from_api_data with complete data."""
api_data = { api_data = {
'id': 1, "id": 1,
'name': 'trading-rules', "name": "trading-rules",
'title': 'Trading Rules & Guidelines', "title": "Trading Rules & Guidelines",
'content': 'Complete trading rules...', "content": "Complete trading rules...",
'category': 'rules', "category": "rules",
'created_by_discord_id': '123456789', "created_by_discord_id": "123456789",
'created_at': '2025-01-01T12:00:00', "created_at": "2025-01-01T12:00:00",
'updated_at': '2025-01-10T15:30:00', "updated_at": "2025-01-10T15:30:00",
'last_modified_by': '987654321', "last_modified_by": "987654321",
'is_active': True, "is_active": True,
'view_count': 100, "view_count": 100,
'display_order': 10 "display_order": 10,
} }
help_cmd = HelpCommand.from_api_data(api_data) help_cmd = HelpCommand.from_api_data(api_data)
assert help_cmd.id == 1 assert help_cmd.id == 1
assert help_cmd.name == 'trading-rules' assert help_cmd.name == "trading-rules"
assert help_cmd.title == 'Trading Rules & Guidelines' assert help_cmd.title == "Trading Rules & Guidelines"
assert help_cmd.content == 'Complete trading rules...' assert help_cmd.content == "Complete trading rules..."
assert help_cmd.category == 'rules' assert help_cmd.category == "rules"
assert help_cmd.view_count == 100 assert help_cmd.view_count == 100
def test_from_api_data_minimal(self): def test_from_api_data_minimal(self):
"""Test from_api_data with minimal required data.""" """Test from_api_data with minimal required data."""
api_data = { api_data = {
'id': 2, "id": 2,
'name': 'simple-topic', "name": "simple-topic",
'title': 'Simple Topic', "title": "Simple Topic",
'content': 'Simple content', "content": "Simple content",
'created_by_discord_id': '123456789', "created_by_discord_id": "123456789",
'created_at': '2025-01-01T12:00:00' "created_at": "2025-01-01T12:00:00",
} }
help_cmd = HelpCommand.from_api_data(api_data) help_cmd = HelpCommand.from_api_data(api_data)
assert help_cmd.id == 2 assert help_cmd.id == 2
assert help_cmd.name == 'simple-topic' assert help_cmd.name == "simple-topic"
assert help_cmd.category is None assert help_cmd.category is None
assert help_cmd.updated_at is None assert help_cmd.updated_at is None
assert help_cmd.view_count == 0 assert help_cmd.view_count == 0

File diff suppressed because it is too large Load Diff