Implements full Google Sheets scorecard submission with: - Complete game data extraction (68 play fields, pitching decisions, box score) - Transaction rollback support at 3 states (plays/game/complete) - Duplicate game detection with confirmation dialog - Permission-based submission (GMs only) - Automated results posting to news channel - Automatic standings recalculation - Key plays display with WPA sorting New Components: - Play, Decision, Game models with full validation - SheetsService for Google Sheets integration - GameService, PlayService, DecisionService for data management - ConfirmationView for user confirmations - Discord helper utilities for channel operations Services Enhanced: - StandingsService: Added recalculate_standings() method - CustomCommandsService: Fixed creator endpoint path - Team/Player models: Added helper methods for display Configuration: - Added SHEETS_CREDENTIALS_PATH environment variable - Added SBA_NETWORK_NEWS_CHANNEL and role constants - Enabled pygsheets dependency Documentation: - Comprehensive README updates across all modules - Added command, service, model, and view documentation - Detailed workflow and error handling documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
225 lines
8.3 KiB
Python
225 lines
8.3 KiB
Python
"""
|
|
Team model for SBA teams
|
|
|
|
Represents a team in the league with all associated metadata.
|
|
"""
|
|
from typing import Optional
|
|
from enum import Enum
|
|
from pydantic import Field
|
|
|
|
from config import get_config
|
|
from models.base import SBABaseModel
|
|
from models.division import Division
|
|
from models.manager import Manager
|
|
|
|
|
|
class RosterType(Enum):
|
|
"""Roster designation types."""
|
|
MAJOR_LEAGUE = "ml"
|
|
MINOR_LEAGUE = "mil"
|
|
INJURED_LIST = "il"
|
|
FREE_AGENCY = "fa"
|
|
|
|
|
|
class Team(SBABaseModel):
|
|
"""Team model representing an SBA team."""
|
|
|
|
# Override base model to make id required for database entities
|
|
id: int = Field(..., description="Team ID from database")
|
|
|
|
abbrev: str = Field(..., description="Team abbreviation (e.g., 'NYY')")
|
|
sname: str = Field(..., description="Short team name")
|
|
lname: str = Field(..., description="Long team name")
|
|
season: int = Field(..., description="Season number")
|
|
|
|
# Manager information
|
|
gmid: Optional[int] = Field(None, description="Primary general manager ID")
|
|
gmid2: Optional[int] = Field(None, description="Secondary general manager ID")
|
|
manager1_id: Optional[int] = Field(None, description="Primary manager ID")
|
|
manager1: Optional[Manager] = Field(None, description="Manager object")
|
|
manager2_id: Optional[int] = Field(None, description="Secondary manager ID")
|
|
manager2: Optional[Manager] = Field(None, description="Manager object")
|
|
|
|
# Team metadata
|
|
division_id: Optional[int] = Field(None, description="Division ID")
|
|
division: Optional[Division] = Field(None, description="Division object (populated from API)")
|
|
stadium: Optional[str] = Field(None, description="Home stadium name")
|
|
thumbnail: Optional[str] = Field(None, description="Team thumbnail URL")
|
|
color: Optional[str] = Field(None, description="Primary team color")
|
|
dice_color: Optional[str] = Field(None, description="Dice rolling color")
|
|
|
|
@classmethod
|
|
def from_api_data(cls, data: dict) -> 'Team':
|
|
"""
|
|
Create Team instance from API data, handling nested division structure.
|
|
|
|
The API returns division data as a nested object, but our model expects
|
|
both division_id (int) and division (optional Division object).
|
|
"""
|
|
# Make a copy to avoid modifying original data
|
|
team_data = data.copy()
|
|
|
|
# Handle nested division structure
|
|
if 'division' in team_data and isinstance(team_data['division'], dict):
|
|
division_data = team_data['division']
|
|
# Extract division_id from nested division object
|
|
team_data['division_id'] = division_data.get('id')
|
|
# Keep division object for optional population
|
|
if division_data.get('id'):
|
|
team_data['division'] = Division.from_api_data(division_data)
|
|
|
|
return super().from_api_data(team_data)
|
|
|
|
def roster_type(self) -> RosterType:
|
|
"""Determine the roster type based on team abbreviation and name."""
|
|
if len(self.abbrev) <= 3:
|
|
return RosterType.MAJOR_LEAGUE
|
|
|
|
# Use sname as the definitive source of truth for IL teams
|
|
# If "IL" is in sname and abbrev ends in "IL" → Injured List
|
|
if self.abbrev.upper().endswith('IL') and 'IL' in self.sname:
|
|
return RosterType.INJURED_LIST
|
|
|
|
# If abbrev ends with "MiL" (exact case) and "IL" not in sname → Minor League
|
|
if self.abbrev.endswith('MiL') and 'IL' not in self.sname:
|
|
return RosterType.MINOR_LEAGUE
|
|
|
|
# Handle other patterns
|
|
abbrev_lower = self.abbrev.lower()
|
|
if abbrev_lower.endswith('mil'):
|
|
return RosterType.MINOR_LEAGUE
|
|
elif abbrev_lower.endswith('il'):
|
|
return RosterType.INJURED_LIST
|
|
else:
|
|
return RosterType.MAJOR_LEAGUE
|
|
|
|
def _get_base_abbrev(self) -> str:
|
|
"""
|
|
Extract the base team abbreviation from potentially extended abbreviation.
|
|
|
|
Returns:
|
|
Base team abbreviation (typically 3 characters)
|
|
"""
|
|
abbrev_lower = self.abbrev.lower()
|
|
|
|
# If 3 chars or less, it's already the base team
|
|
if len(self.abbrev) <= 3:
|
|
return self.abbrev
|
|
|
|
# Handle teams ending in 'mil' - use sname to determine if IL or MiL
|
|
if abbrev_lower.endswith('mil'):
|
|
# If "IL" is in sname and abbrev ends in "IL" → It's [Team]IL
|
|
if self.abbrev.upper().endswith('IL') and 'IL' in self.sname:
|
|
return self.abbrev[:-2] # Remove 'IL'
|
|
# Otherwise it's minor league → remove 'MIL'
|
|
return self.abbrev[:-3]
|
|
|
|
# Handle injured list: ends with 'il' but not 'mil'
|
|
if abbrev_lower.endswith('il'):
|
|
return self.abbrev[:-2] # Remove 'IL'
|
|
|
|
# Unknown pattern, return as-is
|
|
return self.abbrev
|
|
|
|
async def major_league_affiliate(self) -> 'Team':
|
|
"""
|
|
Get the major league team for this organization via API call.
|
|
|
|
Returns:
|
|
Team instance representing the major league affiliate
|
|
|
|
Raises:
|
|
APIException: If the affiliate team cannot be found
|
|
"""
|
|
from services.team_service import team_service
|
|
|
|
base_abbrev = self._get_base_abbrev()
|
|
if base_abbrev == self.abbrev:
|
|
return self # Already the major league team
|
|
|
|
team = await team_service.get_team_by_abbrev(base_abbrev, self.season)
|
|
if team is None:
|
|
raise ValueError(f"Major league affiliate not found for team {self.abbrev} (looking for {base_abbrev})")
|
|
return team
|
|
|
|
async def minor_league_affiliate(self) -> 'Team':
|
|
"""
|
|
Get the minor league team for this organization via API call.
|
|
|
|
Returns:
|
|
Team instance representing the minor league affiliate
|
|
|
|
Raises:
|
|
APIException: If the affiliate team cannot be found
|
|
"""
|
|
from services.team_service import team_service
|
|
|
|
base_abbrev = self._get_base_abbrev()
|
|
mil_abbrev = f"{base_abbrev}MIL"
|
|
|
|
if mil_abbrev == self.abbrev:
|
|
return self # Already the minor league team
|
|
|
|
team = await team_service.get_team_by_abbrev(mil_abbrev, self.season)
|
|
if team is None:
|
|
raise ValueError(f"Minor league affiliate not found for team {self.abbrev} (looking for {mil_abbrev})")
|
|
return team
|
|
|
|
async def injured_list_affiliate(self) -> 'Team':
|
|
"""
|
|
Get the injured list team for this organization via API call.
|
|
|
|
Returns:
|
|
Team instance representing the injured list affiliate
|
|
|
|
Raises:
|
|
APIException: If the affiliate team cannot be found
|
|
"""
|
|
from services.team_service import team_service
|
|
|
|
base_abbrev = self._get_base_abbrev()
|
|
il_abbrev = f"{base_abbrev}IL"
|
|
|
|
if il_abbrev == self.abbrev:
|
|
return self # Already the injured list team
|
|
|
|
team = await team_service.get_team_by_abbrev(il_abbrev, self.season)
|
|
if team is None:
|
|
raise ValueError(f"Injured list affiliate not found for team {self.abbrev} (looking for {il_abbrev})")
|
|
return team
|
|
|
|
def is_same_organization(self, other_team: 'Team') -> bool:
|
|
"""
|
|
Check if this team and another team are from the same organization.
|
|
|
|
Args:
|
|
other_team: Another team to compare
|
|
|
|
Returns:
|
|
True if both teams are from the same organization
|
|
"""
|
|
return self._get_base_abbrev() == other_team._get_base_abbrev()
|
|
|
|
def gm_names(self) -> str:
|
|
if any([self.manager1, self.manager2]):
|
|
names = ''
|
|
if self.manager1:
|
|
names += f'{self.manager1}'
|
|
if self.manager2:
|
|
names += f', {self.manager2}'
|
|
return names
|
|
if any([self.manager1_id, self.manager2_id]):
|
|
mgr_count = sum(1 for x in [self.manager1_id, self.manager2_id] if x is not None)
|
|
return f'{mgr_count} GM{"s" if mgr_count > 1 else ""}'
|
|
return 'Unknown'
|
|
|
|
def get_color_int(self, default_color: Optional[str] = None) -> int:
|
|
if self.color is not None:
|
|
return int(self.color, 16)
|
|
if default_color is not None:
|
|
return int(default_color, 16)
|
|
config = get_config()
|
|
return int(config.sba_color, 16)
|
|
|
|
def __str__(self):
|
|
return f"{self.abbrev} - {self.lname}" |