- #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:
parent
f64fee8d2e
commit
9cd577cba1
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,136 +14,158 @@ 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")
|
||||||
warning_sent: bool = Field(False, description="Whether cleanup warning was sent")
|
warning_sent: bool = Field(False, description="Whether cleanup warning was sent")
|
||||||
|
|
||||||
# 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."""
|
||||||
if not v or len(v.strip()) == 0:
|
if not v or len(v.strip()) == 0:
|
||||||
raise ValueError("Command name cannot be empty")
|
raise ValueError("Command name cannot be empty")
|
||||||
|
|
||||||
name = v.strip().lower()
|
name = v.strip().lower()
|
||||||
|
|
||||||
# Length validation
|
# Length validation
|
||||||
if len(name) < 2:
|
if len(name) < 2:
|
||||||
raise ValueError("Command name must be at least 2 characters")
|
raise ValueError("Command name must be at least 2 characters")
|
||||||
if len(name) > 32:
|
if len(name) > 32:
|
||||||
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."""
|
||||||
if not v or len(v.strip()) == 0:
|
if not v or len(v.strip()) == 0:
|
||||||
raise ValueError("Command content cannot be empty")
|
raise ValueError("Command content cannot be empty")
|
||||||
|
|
||||||
content = v.strip()
|
content = v.strip()
|
||||||
|
|
||||||
# Length validation
|
# Length validation
|
||||||
if len(content) > 2000:
|
if len(content) > 2000:
|
||||||
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:
|
||||||
raise ValueError(f"Command content cannot contain '{term}'")
|
raise ValueError(f"Command content cannot contain '{term}'")
|
||||||
|
|
||||||
return content
|
return content
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def days_since_last_use(self) -> Optional[int]:
|
def days_since_last_use(self) -> Optional[int]:
|
||||||
"""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:
|
||||||
"""Calculate popularity score based on usage and recency."""
|
"""Calculate popularity score based on usage and recency."""
|
||||||
if self.use_count == 0:
|
if self.use_count == 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
# Base score from usage
|
# Base score from usage
|
||||||
base_score = min(self.use_count / 10.0, 10.0) # Max 10 points from usage
|
base_score = min(self.use_count / 10.0, 10.0) # Max 10 points from usage
|
||||||
|
|
||||||
# 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
|
||||||
else:
|
else:
|
||||||
recency_modifier = 0.1 # Never used
|
recency_modifier = 0.1 # Never used
|
||||||
|
|
||||||
return base_score * recency_modifier
|
return base_score * recency_modifier
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@ -150,33 +173,43 @@ class CustomCommandSearchFilters(BaseModel):
|
|||||||
max_days_unused: Optional[int] = None
|
max_days_unused: Optional[int] = None
|
||||||
has_tags: Optional[list[str]] = None
|
has_tags: Optional[list[str]] = None
|
||||||
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."""
|
||||||
if v < 1:
|
if v < 1:
|
||||||
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,18 +220,19 @@ 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
|
||||||
page_size: int
|
page_size: int
|
||||||
total_pages: int
|
total_pages: int
|
||||||
has_more: bool
|
has_more: bool
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def start_index(self) -> int:
|
def start_index(self) -> int:
|
||||||
"""Get the starting index for this page."""
|
"""Get the starting index for this page."""
|
||||||
return (self.page - 1) * self.page_size + 1
|
return (self.page - 1) * self.page_size + 1
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def end_index(self) -> int:
|
def end_index(self) -> int:
|
||||||
"""Get the ending index for this page."""
|
"""Get the ending index for this page."""
|
||||||
@ -207,30 +241,31 @@ 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
|
||||||
total_uses: int
|
total_uses: int
|
||||||
|
|
||||||
# Usage statistics
|
# Usage statistics
|
||||||
most_popular_command: Optional[CustomCommand] = None
|
most_popular_command: Optional[CustomCommand] = None
|
||||||
most_active_creator: Optional[CustomCommandCreator] = None
|
most_active_creator: Optional[CustomCommandCreator] = None
|
||||||
recent_commands_count: int = 0 # Commands created in last 7 days
|
recent_commands_count: int = 0 # Commands created in last 7 days
|
||||||
|
|
||||||
# Cleanup statistics
|
# Cleanup statistics
|
||||||
commands_needing_warning: int = 0
|
commands_needing_warning: int = 0
|
||||||
commands_eligible_for_deletion: int = 0
|
commands_eligible_for_deletion: int = 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def average_uses_per_command(self) -> float:
|
def average_uses_per_command(self) -> float:
|
||||||
"""Calculate average uses per command."""
|
"""Calculate average uses per command."""
|
||||||
if self.active_commands == 0:
|
if self.active_commands == 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
return self.total_uses / self.active_commands
|
return self.total_uses / self.active_commands
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def average_commands_per_creator(self) -> float:
|
def average_commands_per_creator(self) -> float:
|
||||||
"""Calculate average commands per creator."""
|
"""Calculate average commands per creator."""
|
||||||
if self.total_creators == 0:
|
if self.total_creators == 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
return self.active_commands / self.total_creators
|
return self.active_commands / self.total_creators
|
||||||
|
|||||||
@ -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")
|
||||||
@ -30,7 +39,7 @@ class DraftData(SBABaseModel):
|
|||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
return int(v)
|
return int(v)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_draft_active(self) -> bool:
|
def is_draft_active(self) -> bool:
|
||||||
"""Check if the draft is currently active (timer running and not paused)."""
|
"""Check if the draft is currently active (timer running and not paused)."""
|
||||||
@ -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:
|
||||||
@ -55,4 +64,4 @@ class DraftData(SBABaseModel):
|
|||||||
status = "Active"
|
status = "Active"
|
||||||
else:
|
else:
|
||||||
status = "Inactive"
|
status = "Inactive"
|
||||||
return f"Draft {status}: Pick {self.currentpick} ({self.pick_minutes}min timer)"
|
return f"Draft {status}: Pick {self.currentpick} ({self.pick_minutes}min timer)"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
@ -12,29 +13,36 @@ from config import BotConfig
|
|||||||
|
|
||||||
class TestBotConfig:
|
class TestBotConfig:
|
||||||
"""Test configuration loading and validation."""
|
"""Test configuration loading and validation."""
|
||||||
|
|
||||||
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,199 +51,246 @@ 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"
|
||||||
assert config.environment == "production"
|
assert config.environment == "production"
|
||||||
assert config.testing is True
|
assert config.testing is True
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
class TestConfigValidation:
|
class TestConfigValidation:
|
||||||
"""Test configuration validation and error handling."""
|
"""Test configuration validation and error handling."""
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
@ -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,13 +12,13 @@ from models.custom_command import (
|
|||||||
CustomCommandCreator,
|
CustomCommandCreator,
|
||||||
CustomCommandSearchFilters,
|
CustomCommandSearchFilters,
|
||||||
CustomCommandSearchResult,
|
CustomCommandSearchResult,
|
||||||
CustomCommandStats
|
CustomCommandStats,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestCustomCommandCreator:
|
class TestCustomCommandCreator:
|
||||||
"""Test the CustomCommandCreator dataclass."""
|
"""Test the CustomCommandCreator dataclass."""
|
||||||
|
|
||||||
def test_creator_creation(self):
|
def test_creator_creation(self):
|
||||||
"""Test creating a creator instance."""
|
"""Test creating a creator instance."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@ -28,9 +29,9 @@ 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
|
||||||
assert creator.discord_id == 12345
|
assert creator.discord_id == 12345
|
||||||
assert creator.username == "testuser"
|
assert creator.username == "testuser"
|
||||||
@ -38,7 +39,7 @@ class TestCustomCommandCreator:
|
|||||||
assert creator.created_at == now
|
assert creator.created_at == now
|
||||||
assert creator.total_commands == 10
|
assert creator.total_commands == 10
|
||||||
assert creator.active_commands == 5
|
assert creator.active_commands == 5
|
||||||
|
|
||||||
def test_creator_optional_fields(self):
|
def test_creator_optional_fields(self):
|
||||||
"""Test creator with None display_name."""
|
"""Test creator with None display_name."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@ -49,9 +50,9 @@ 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
|
||||||
assert creator.total_commands == 0
|
assert creator.total_commands == 0
|
||||||
assert creator.active_commands == 0
|
assert creator.active_commands == 0
|
||||||
@ -59,7 +60,7 @@ class TestCustomCommandCreator:
|
|||||||
|
|
||||||
class TestCustomCommand:
|
class TestCustomCommand:
|
||||||
"""Test the CustomCommand dataclass."""
|
"""Test the CustomCommand dataclass."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_creator(self) -> CustomCommandCreator:
|
def sample_creator(self) -> CustomCommandCreator:
|
||||||
"""Fixture providing a sample creator."""
|
"""Fixture providing a sample creator."""
|
||||||
@ -70,9 +71,9 @@ 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):
|
||||||
"""Test creating a basic command."""
|
"""Test creating a basic command."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@ -88,9 +89,9 @@ 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
|
||||||
assert command.name == "hello"
|
assert command.name == "hello"
|
||||||
assert command.content == "Hello, world!"
|
assert command.content == "Hello, world!"
|
||||||
@ -102,13 +103,13 @@ class TestCustomCommand:
|
|||||||
assert command.tags is None
|
assert command.tags is None
|
||||||
assert command.is_active is True
|
assert command.is_active is True
|
||||||
assert command.warning_sent is False
|
assert command.warning_sent is False
|
||||||
|
|
||||||
def test_command_with_optional_fields(self, sample_creator: CustomCommandCreator):
|
def test_command_with_optional_fields(self, sample_creator: CustomCommandCreator):
|
||||||
"""Test command with all optional fields."""
|
"""Test command with all optional fields."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
last_used = now - timedelta(hours=1)
|
last_used = now - timedelta(hours=1)
|
||||||
updated = now - timedelta(minutes=30)
|
updated = now - timedelta(minutes=30)
|
||||||
|
|
||||||
command = CustomCommand(
|
command = CustomCommand(
|
||||||
id=1,
|
id=1,
|
||||||
name="advanced",
|
name="advanced",
|
||||||
@ -121,19 +122,19 @@ 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
|
||||||
assert command.last_used == last_used
|
assert command.last_used == last_used
|
||||||
assert command.updated_at == updated
|
assert command.updated_at == updated
|
||||||
assert command.tags == ["fun", "utility"]
|
assert command.tags == ["fun", "utility"]
|
||||||
assert command.warning_sent is True
|
assert command.warning_sent is True
|
||||||
|
|
||||||
def test_days_since_last_use_property(self, sample_creator: CustomCommandCreator):
|
def test_days_since_last_use_property(self, sample_creator: CustomCommandCreator):
|
||||||
"""Test days since last use calculation."""
|
"""Test days since last use calculation."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# Command used 5 days ago
|
# Command used 5 days ago
|
||||||
command = CustomCommand(
|
command = CustomCommand(
|
||||||
id=1,
|
id=1,
|
||||||
@ -147,17 +148,21 @@ 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
|
||||||
unused_command = CustomCommand(
|
unused_command = CustomCommand(
|
||||||
id=2,
|
id=2,
|
||||||
@ -171,15 +176,15 @@ 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
|
||||||
|
|
||||||
def test_popularity_score_calculation(self, sample_creator: CustomCommandCreator):
|
def test_popularity_score_calculation(self, sample_creator: CustomCommandCreator):
|
||||||
"""Test popularity score calculation."""
|
"""Test popularity score calculation."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# Test with recent usage
|
# Test with recent usage
|
||||||
recent_command = CustomCommand(
|
recent_command = CustomCommand(
|
||||||
id=1,
|
id=1,
|
||||||
@ -193,18 +198,22 @@ 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
|
||||||
|
|
||||||
# Test with no usage
|
# Test with no usage
|
||||||
unused_command = CustomCommand(
|
unused_command = CustomCommand(
|
||||||
id=2,
|
id=2,
|
||||||
@ -218,19 +227,19 @@ 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
|
||||||
|
|
||||||
|
|
||||||
class TestCustomCommandSearchFilters:
|
class TestCustomCommandSearchFilters:
|
||||||
"""Test the search filters dataclass."""
|
"""Test the search filters dataclass."""
|
||||||
|
|
||||||
def test_default_filters(self):
|
def test_default_filters(self):
|
||||||
"""Test default filter values."""
|
"""Test default filter values."""
|
||||||
filters = CustomCommandSearchFilters()
|
filters = CustomCommandSearchFilters()
|
||||||
|
|
||||||
assert filters.name_contains is None
|
assert filters.name_contains is None
|
||||||
assert filters.creator_id is None
|
assert filters.creator_id is None
|
||||||
assert filters.creator_name is None
|
assert filters.creator_name is None
|
||||||
@ -240,7 +249,7 @@ class TestCustomCommandSearchFilters:
|
|||||||
assert filters.is_active is True
|
assert filters.is_active is True
|
||||||
# Note: sort_by, sort_desc, page, page_size have Field objects as defaults
|
# Note: sort_by, sort_desc, page, page_size have Field objects as defaults
|
||||||
# due to mixed dataclass/Pydantic usage - skipping specific value tests
|
# due to mixed dataclass/Pydantic usage - skipping specific value tests
|
||||||
|
|
||||||
def test_custom_filters(self):
|
def test_custom_filters(self):
|
||||||
"""Test creating filters with custom values."""
|
"""Test creating filters with custom values."""
|
||||||
filters = CustomCommandSearchFilters(
|
filters = CustomCommandSearchFilters(
|
||||||
@ -250,9 +259,9 @@ 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"
|
||||||
assert filters.creator_name == "user123"
|
assert filters.creator_name == "user123"
|
||||||
assert filters.min_uses == 5
|
assert filters.min_uses == 5
|
||||||
@ -264,7 +273,7 @@ class TestCustomCommandSearchFilters:
|
|||||||
|
|
||||||
class TestCustomCommandSearchResult:
|
class TestCustomCommandSearchResult:
|
||||||
"""Test the search result dataclass."""
|
"""Test the search result dataclass."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_commands(self) -> list[CustomCommand]:
|
def sample_commands(self) -> list[CustomCommand]:
|
||||||
"""Fixture providing sample commands."""
|
"""Fixture providing sample commands."""
|
||||||
@ -275,9 +284,9 @@ 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)
|
||||||
return [
|
return [
|
||||||
CustomCommand(
|
CustomCommand(
|
||||||
@ -292,11 +301,11 @@ 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)
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_search_result_creation(self, sample_commands: list[CustomCommand]):
|
def test_search_result_creation(self, sample_commands: list[CustomCommand]):
|
||||||
"""Test creating a search result."""
|
"""Test creating a search result."""
|
||||||
result = CustomCommandSearchResult(
|
result = CustomCommandSearchResult(
|
||||||
@ -305,16 +314,16 @@ 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
|
||||||
assert result.total_count == 10
|
assert result.total_count == 10
|
||||||
assert result.page == 1
|
assert result.page == 1
|
||||||
assert result.page_size == 20
|
assert result.page_size == 20
|
||||||
assert result.total_pages == 1
|
assert result.total_pages == 1
|
||||||
assert result.has_more is False
|
assert result.has_more is False
|
||||||
|
|
||||||
def test_search_result_properties(self):
|
def test_search_result_properties(self):
|
||||||
"""Test search result calculated properties."""
|
"""Test search result calculated properties."""
|
||||||
result = CustomCommandSearchResult(
|
result = CustomCommandSearchResult(
|
||||||
@ -323,16 +332,16 @@ 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:
|
||||||
"""Test the statistics dataclass."""
|
"""Test the statistics dataclass."""
|
||||||
|
|
||||||
def test_stats_creation(self):
|
def test_stats_creation(self):
|
||||||
"""Test creating statistics."""
|
"""Test creating statistics."""
|
||||||
creator = CustomCommandCreator(
|
creator = CustomCommandCreator(
|
||||||
@ -342,9 +351,9 @@ 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(
|
||||||
id=1,
|
id=1,
|
||||||
name="hello",
|
name="hello",
|
||||||
@ -357,9 +366,9 @@ 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(
|
||||||
total_commands=100,
|
total_commands=100,
|
||||||
active_commands=95,
|
active_commands=95,
|
||||||
@ -369,9 +378,9 @@ 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
|
||||||
assert stats.active_commands == 95
|
assert stats.active_commands == 95
|
||||||
assert stats.total_creators == 25
|
assert stats.total_creators == 25
|
||||||
@ -381,7 +390,7 @@ class TestCustomCommandStats:
|
|||||||
assert stats.recent_commands_count == 15
|
assert stats.recent_commands_count == 15
|
||||||
assert stats.commands_needing_warning == 5
|
assert stats.commands_needing_warning == 5
|
||||||
assert stats.commands_eligible_for_deletion == 2
|
assert stats.commands_eligible_for_deletion == 2
|
||||||
|
|
||||||
def test_stats_calculated_properties(self):
|
def test_stats_calculated_properties(self):
|
||||||
"""Test calculated statistics properties."""
|
"""Test calculated statistics properties."""
|
||||||
# Test with active commands
|
# Test with active commands
|
||||||
@ -394,12 +403,12 @@ 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
|
||||||
assert stats.average_commands_per_creator == 5.0 # 50 / 10
|
assert stats.average_commands_per_creator == 5.0 # 50 / 10
|
||||||
|
|
||||||
# Test with no active commands
|
# Test with no active commands
|
||||||
empty_stats = CustomCommandStats(
|
empty_stats = CustomCommandStats(
|
||||||
total_commands=0,
|
total_commands=0,
|
||||||
@ -410,16 +419,16 @@ 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
|
||||||
assert empty_stats.average_commands_per_creator == 0.0
|
assert empty_stats.average_commands_per_creator == 0.0
|
||||||
|
|
||||||
|
|
||||||
class TestModelIntegration:
|
class TestModelIntegration:
|
||||||
"""Test integration between models."""
|
"""Test integration between models."""
|
||||||
|
|
||||||
def test_command_with_creator_relationship(self):
|
def test_command_with_creator_relationship(self):
|
||||||
"""Test the relationship between command and creator."""
|
"""Test the relationship between command and creator."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@ -430,9 +439,9 @@ 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(
|
||||||
id=1,
|
id=1,
|
||||||
name="test",
|
name="test",
|
||||||
@ -445,25 +454,21 @@ 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
|
||||||
assert command.creator == creator
|
assert command.creator == creator
|
||||||
assert command.creator_id == creator.id
|
assert command.creator_id == creator.id
|
||||||
assert command.creator.discord_id == 12345
|
assert command.creator.discord_id == 12345
|
||||||
assert command.creator.username == "testuser"
|
assert command.creator.username == "testuser"
|
||||||
|
|
||||||
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(
|
||||||
id=1,
|
id=1,
|
||||||
discord_id=12345,
|
discord_id=12345,
|
||||||
@ -471,9 +476,9 @@ 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 = [
|
||||||
CustomCommand(
|
CustomCommand(
|
||||||
id=1,
|
id=1,
|
||||||
@ -487,21 +492,21 @@ class TestModelIntegration:
|
|||||||
use_count=0,
|
use_count=0,
|
||||||
warning_sent=False,
|
warning_sent=False,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
tags=None
|
tags=None,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
result = CustomCommandSearchResult(
|
result = CustomCommandSearchResult(
|
||||||
commands=commands,
|
commands=commands,
|
||||||
total_count=25,
|
total_count=25,
|
||||||
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
|
||||||
assert result.page_size == 10
|
assert result.page_size == 10
|
||||||
assert len(result.commands) == 1
|
assert len(result.commands) == 1
|
||||||
assert result.total_pages == 3
|
assert result.total_pages == 3
|
||||||
assert result.has_more is True
|
assert result.has_more is True
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user