major-domo-v2/models/team.py
Cal Corum 2409c27c1d CLAUDE: Add comprehensive scorecard submission system
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>
2025-10-16 00:21:32 -05:00

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}"