diff --git a/services/draft_list_service.py b/services/draft_list_service.py new file mode 100644 index 0000000..ab6eab6 --- /dev/null +++ b/services/draft_list_service.py @@ -0,0 +1,368 @@ +""" +Draft list service for Discord Bot v2.0 + +Handles team draft list (auto-draft queue) operations. NO CACHING - lists change frequently. +""" +import logging +from typing import Optional, List + +from services.base_service import BaseService +from models.draft_list import DraftList +from exceptions import APIException + +logger = logging.getLogger(f'{__name__}.DraftListService') + + +class DraftListService(BaseService[DraftList]): + """ + Service for draft list operations. + + IMPORTANT: This service does NOT use caching decorators because draft lists + change as users add/remove players from their auto-draft queues. + + Features: + - Get team's draft list (ranked by priority) + - Add player to draft list + - Remove player from draft list + - Reorder draft list + - Clear entire draft list + """ + + def __init__(self): + """Initialize draft list service.""" + super().__init__(DraftList, 'draftlist') + logger.debug("DraftListService initialized") + + async def get_team_list( + self, + season: int, + team_id: int + ) -> List[DraftList]: + """ + Get team's draft list ordered by rank. + + NOT cached - teams update their lists frequently during draft. + + Args: + season: Draft season + team_id: Team ID + + Returns: + List of DraftList entries ordered by rank (1 = highest priority) + """ + try: + params = [ + ('season', str(season)), + ('team_id', str(team_id)), + ('sort', 'rank-asc') # Order by priority + ] + + entries = await self.get_all_items(params=params) + logger.debug(f"Found {len(entries)} draft list entries for team {team_id}") + return entries + + except Exception as e: + logger.error(f"Error getting draft list for team {team_id}: {e}") + return [] + + async def add_to_list( + self, + season: int, + team_id: int, + player_id: int, + rank: Optional[int] = None + ) -> Optional[DraftList]: + """ + Add player to team's draft list. + + If rank is not provided, adds to end of list. + + Args: + season: Draft season + team_id: Team ID + player_id: Player ID to add + rank: Priority rank (1 = highest), None = add to end + + Returns: + Created DraftList entry or None if creation failed + """ + try: + # If rank not provided, get current list and add to end + if rank is None: + current_list = await self.get_team_list(season, team_id) + rank = len(current_list) + 1 + + entry_data = { + 'season': season, + 'team_id': team_id, + 'player_id': player_id, + 'rank': rank + } + + created_entry = await self.create(entry_data) + + if created_entry: + logger.info(f"Added player {player_id} to team {team_id} draft list at rank {rank}") + else: + logger.error(f"Failed to add player {player_id} to draft list") + + return created_entry + + except Exception as e: + logger.error(f"Error adding player {player_id} to draft list: {e}") + return None + + async def remove_from_list( + self, + entry_id: int + ) -> bool: + """ + Remove entry from draft list by ID. + + Args: + entry_id: Draft list entry database ID + + Returns: + True if deletion succeeded + """ + try: + result = await self.delete(entry_id) + + if result: + logger.info(f"Removed draft list entry {entry_id}") + else: + logger.error(f"Failed to remove draft list entry {entry_id}") + + return result + + except Exception as e: + logger.error(f"Error removing draft list entry {entry_id}: {e}") + return False + + async def remove_player_from_list( + self, + season: int, + team_id: int, + player_id: int + ) -> bool: + """ + Remove specific player from team's draft list. + + Args: + season: Draft season + team_id: Team ID + player_id: Player ID to remove + + Returns: + True if player was found and removed + """ + try: + # Get team's list + entries = await self.get_team_list(season, team_id) + + # Find entry with this player + for entry in entries: + if entry.player_id == player_id: + return await self.remove_from_list(entry.id) + + logger.warning(f"Player {player_id} not found in team {team_id} draft list") + return False + + except Exception as e: + logger.error(f"Error removing player {player_id} from draft list: {e}") + return False + + async def clear_list( + self, + season: int, + team_id: int + ) -> bool: + """ + Clear entire draft list for team. + + Args: + season: Draft season + team_id: Team ID + + Returns: + True if all entries were deleted successfully + """ + try: + entries = await self.get_team_list(season, team_id) + + if not entries: + logger.debug(f"No draft list entries to clear for team {team_id}") + return True + + success = True + for entry in entries: + if not await self.remove_from_list(entry.id): + success = False + + if success: + logger.info(f"Cleared {len(entries)} draft list entries for team {team_id}") + else: + logger.warning(f"Failed to clear some draft list entries for team {team_id}") + + return success + + except Exception as e: + logger.error(f"Error clearing draft list for team {team_id}: {e}") + return False + + async def reorder_list( + self, + season: int, + team_id: int, + new_order: List[int] + ) -> bool: + """ + Reorder team's draft list. + + Args: + season: Draft season + team_id: Team ID + new_order: List of player IDs in desired order + + Returns: + True if reordering succeeded + """ + try: + # Get current list + entries = await self.get_team_list(season, team_id) + + # Build mapping of player_id -> entry + entry_map = {e.player_id: e for e in entries} + + # Update each entry with new rank + success = True + for new_rank, player_id in enumerate(new_order, start=1): + if player_id not in entry_map: + logger.warning(f"Player {player_id} not in draft list, skipping") + continue + + entry = entry_map[player_id] + if entry.rank != new_rank: + updated = await self.patch(entry.id, {'rank': new_rank}) + if not updated: + logger.error(f"Failed to update rank for entry {entry.id}") + success = False + + if success: + logger.info(f"Reordered draft list for team {team_id}") + else: + logger.warning(f"Some errors occurred reordering draft list for team {team_id}") + + return success + + except Exception as e: + logger.error(f"Error reordering draft list for team {team_id}: {e}") + return False + + async def move_entry_up( + self, + season: int, + team_id: int, + player_id: int + ) -> bool: + """ + Move player up one position in draft list (higher priority). + + Args: + season: Draft season + team_id: Team ID + player_id: Player ID to move up + + Returns: + True if move succeeded + """ + try: + entries = await self.get_team_list(season, team_id) + + # Find player's current position + current_entry = None + for entry in entries: + if entry.player_id == player_id: + current_entry = entry + break + + if not current_entry: + logger.warning(f"Player {player_id} not found in draft list") + return False + + if current_entry.rank == 1: + logger.debug(f"Player {player_id} already at top of draft list") + return False + + # Swap with entry above (rank - 1) + above_entry = next((e for e in entries if e.rank == current_entry.rank - 1), None) + if not above_entry: + logger.error(f"Could not find entry above rank {current_entry.rank}") + return False + + # Swap ranks + await self.patch(current_entry.id, {'rank': current_entry.rank - 1}) + await self.patch(above_entry.id, {'rank': above_entry.rank + 1}) + + logger.info(f"Moved player {player_id} up to rank {current_entry.rank - 1}") + return True + + except Exception as e: + logger.error(f"Error moving player {player_id} up in draft list: {e}") + return False + + async def move_entry_down( + self, + season: int, + team_id: int, + player_id: int + ) -> bool: + """ + Move player down one position in draft list (lower priority). + + Args: + season: Draft season + team_id: Team ID + player_id: Player ID to move down + + Returns: + True if move succeeded + """ + try: + entries = await self.get_team_list(season, team_id) + + # Find player's current position + current_entry = None + for entry in entries: + if entry.player_id == player_id: + current_entry = entry + break + + if not current_entry: + logger.warning(f"Player {player_id} not found in draft list") + return False + + if current_entry.rank == len(entries): + logger.debug(f"Player {player_id} already at bottom of draft list") + return False + + # Swap with entry below (rank + 1) + below_entry = next((e for e in entries if e.rank == current_entry.rank + 1), None) + if not below_entry: + logger.error(f"Could not find entry below rank {current_entry.rank}") + return False + + # Swap ranks + await self.patch(current_entry.id, {'rank': current_entry.rank + 1}) + await self.patch(below_entry.id, {'rank': below_entry.rank - 1}) + + logger.info(f"Moved player {player_id} down to rank {current_entry.rank + 1}") + return True + + except Exception as e: + logger.error(f"Error moving player {player_id} down in draft list: {e}") + return False + + +# Global service instance +draft_list_service = DraftListService() diff --git a/services/draft_service.py b/services/draft_service.py new file mode 100644 index 0000000..47d8ac6 --- /dev/null +++ b/services/draft_service.py @@ -0,0 +1,341 @@ +""" +Draft service for Discord Bot v2.0 + +Core draft business logic and state management. NO CACHING - draft state changes constantly. +""" +import logging +from typing import Optional, Dict, Any +from datetime import datetime, timedelta + +from services.base_service import BaseService +from models.draft_data import DraftData +from exceptions import APIException + +logger = logging.getLogger(f'{__name__}.DraftService') + + +class DraftService(BaseService[DraftData]): + """ + Service for core draft operations and state management. + + IMPORTANT: This service does NOT use caching decorators because draft data + changes every 2-12 minutes during an active draft. Always fetch fresh data. + + Features: + - Get/update draft configuration + - Timer management (start/stop) + - Pick advancement + - Draft state validation + """ + + def __init__(self): + """Initialize draft service.""" + super().__init__(DraftData, 'draftdata') + logger.debug("DraftService initialized") + + async def get_draft_data(self) -> Optional[DraftData]: + """ + Get current draft configuration and state. + + NOT cached - draft state changes frequently during active draft. + + Returns: + DraftData instance or None if not found + """ + try: + # Draft data endpoint typically returns single object + items = await self.get_all_items() + + if items: + draft_data = items[0] + logger.debug( + f"Retrieved draft data: pick={draft_data.currentpick}, " + f"timer={draft_data.timer}, " + f"deadline={draft_data.pick_deadline}" + ) + return draft_data + + logger.warning("No draft data found in database") + return None + + except Exception as e: + logger.error(f"Error getting draft data: {e}") + return None + + async def update_draft_data( + self, + draft_id: int, + updates: Dict[str, Any] + ) -> Optional[DraftData]: + """ + Update draft configuration. + + Args: + draft_id: DraftData database ID (typically 1) + updates: Dictionary of fields to update + + Returns: + Updated DraftData instance or None if update failed + """ + try: + updated = await self.patch(draft_id, updates) + + if updated: + logger.info(f"Updated draft data: {updates}") + else: + logger.error(f"Failed to update draft data with {updates}") + + return updated + + except Exception as e: + logger.error(f"Error updating draft data: {e}") + return None + + async def set_timer( + self, + draft_id: int, + active: bool, + pick_minutes: Optional[int] = None + ) -> Optional[DraftData]: + """ + Enable or disable draft timer. + + Args: + draft_id: DraftData database ID + active: True to enable timer, False to disable + pick_minutes: Minutes per pick (updates default if provided) + + Returns: + Updated DraftData instance + """ + try: + updates = {'timer': active} + + if pick_minutes is not None: + updates['pick_minutes'] = pick_minutes + + # Set deadline based on timer state + if active: + # Calculate new deadline + if pick_minutes: + deadline = datetime.now() + timedelta(minutes=pick_minutes) + else: + # Get current pick_minutes from existing data + current_data = await self.get_draft_data() + if current_data: + deadline = datetime.now() + timedelta(minutes=current_data.pick_minutes) + else: + deadline = datetime.now() + timedelta(minutes=2) # Default fallback + updates['pick_deadline'] = deadline + else: + # Set deadline far in future when timer inactive + updates['pick_deadline'] = datetime.now() + timedelta(days=690) + + updated = await self.update_draft_data(draft_id, updates) + + if updated: + status = "enabled" if active else "disabled" + logger.info(f"Draft timer {status}") + else: + logger.error("Failed to update draft timer") + + return updated + + except Exception as e: + logger.error(f"Error setting draft timer: {e}") + return None + + async def advance_pick( + self, + draft_id: int, + current_pick: int + ) -> Optional[DraftData]: + """ + Advance to next pick in draft. + + Automatically skips picks that have already been filled (player selected). + Posts round announcement when entering new round. + + Args: + draft_id: DraftData database ID + current_pick: Current overall pick number + + Returns: + Updated DraftData with new currentpick + """ + try: + from services.draft_pick_service import draft_pick_service + from config import get_config + + config = get_config() + season = config.sba_current_season + + # Start with next pick + next_pick = current_pick + 1 + + # Keep advancing until we find an unfilled pick or reach end + while next_pick <= 512: # 32 rounds * 16 teams + pick = await draft_pick_service.get_pick(season, next_pick) + + if not pick: + logger.error(f"Pick #{next_pick} not found in database") + break + + # If pick has no player, this is the next pick to make + if pick.player_id is None: + logger.info(f"Advanced to pick #{next_pick}") + break + + # Pick already filled, continue to next + logger.debug(f"Pick #{next_pick} already filled, skipping") + next_pick += 1 + + # Check if draft is complete + if next_pick > 512: + logger.info("Draft is complete - all picks filled") + # Disable timer + await self.set_timer(draft_id, active=False) + return await self.get_draft_data() + + # Update to next pick + updates = {'currentpick': next_pick} + + # Reset deadline if timer is active + current_data = await self.get_draft_data() + if current_data and current_data.timer: + updates['pick_deadline'] = datetime.now() + timedelta(minutes=current_data.pick_minutes) + + updated = await self.update_draft_data(draft_id, updates) + + if updated: + logger.info(f"Draft advanced from pick #{current_pick} to #{next_pick}") + else: + logger.error(f"Failed to advance draft pick") + + return updated + + except Exception as e: + logger.error(f"Error advancing draft pick: {e}") + return None + + async def set_current_pick( + self, + draft_id: int, + overall: int, + reset_timer: bool = True + ) -> Optional[DraftData]: + """ + Manually set current pick (admin operation). + + Args: + draft_id: DraftData database ID + overall: Overall pick number to jump to + reset_timer: Whether to reset the pick deadline + + Returns: + Updated DraftData + """ + try: + updates = {'currentpick': overall} + + if reset_timer: + current_data = await self.get_draft_data() + if current_data and current_data.timer: + updates['pick_deadline'] = datetime.now() + timedelta(minutes=current_data.pick_minutes) + + updated = await self.update_draft_data(draft_id, updates) + + if updated: + logger.info(f"Manually set current pick to #{overall}") + else: + logger.error(f"Failed to set current pick to #{overall}") + + return updated + + except Exception as e: + logger.error(f"Error setting current pick: {e}") + return None + + async def update_channels( + self, + draft_id: int, + ping_channel_id: Optional[int] = None, + result_channel_id: Optional[int] = None + ) -> Optional[DraftData]: + """ + Update draft Discord channel configuration. + + Args: + draft_id: DraftData database ID + ping_channel_id: Channel ID for "on the clock" pings + result_channel_id: Channel ID for draft results + + Returns: + Updated DraftData + """ + try: + updates = {} + if ping_channel_id is not None: + updates['ping_channel_id'] = ping_channel_id + if result_channel_id is not None: + updates['result_channel_id'] = result_channel_id + + if not updates: + logger.warning("No channel updates provided") + return await self.get_draft_data() + + updated = await self.update_draft_data(draft_id, updates) + + if updated: + logger.info(f"Updated draft channels: {updates}") + else: + logger.error("Failed to update draft channels") + + return updated + + except Exception as e: + logger.error(f"Error updating draft channels: {e}") + return None + + async def reset_draft_deadline( + self, + draft_id: int, + minutes: Optional[int] = None + ) -> Optional[DraftData]: + """ + Reset the current pick deadline. + + Args: + draft_id: DraftData database ID + minutes: Minutes to add (uses pick_minutes from config if not provided) + + Returns: + Updated DraftData with new deadline + """ + try: + if minutes is None: + current_data = await self.get_draft_data() + if not current_data: + logger.error("Could not get current draft data") + return None + minutes = current_data.pick_minutes + + new_deadline = datetime.now() + timedelta(minutes=minutes) + updates = {'pick_deadline': new_deadline} + + updated = await self.update_draft_data(draft_id, updates) + + if updated: + logger.info(f"Reset draft deadline to {new_deadline}") + else: + logger.error("Failed to reset draft deadline") + + return updated + + except Exception as e: + logger.error(f"Error resetting draft deadline: {e}") + return None + + +# Global service instance +draft_service = DraftService()