- Add player_positions JSONB column to roster_links (migration 006) - Add player_data JSONB column to cache name/image/headshot (migration 007) - Add is_pitcher/is_batter computed properties for two-way player support - Update lineup submission to populate RosterLink with all players + positions - Update get_bench handler to use cached data (no runtime API calls) - Add BenchPlayer type to frontend with proper filtering - Add new Lineup components: InlineSubstitutionPanel, LineupSlotRow, PositionSelector, UnifiedLineupTab - Add integration tests for get_bench_players Bench players now load instantly without API dependency, and properly filter batters vs pitchers (including CP closer position). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
162 lines
5.0 KiB
Python
162 lines
5.0 KiB
Python
"""Pydantic models for roster link type safety
|
|
|
|
Provides league-specific type-safe models for roster operations:
|
|
- PdRosterLinkData: PD league card-based rosters
|
|
- SbaRosterLinkData: SBA league player-based rosters
|
|
"""
|
|
|
|
from abc import ABC, abstractmethod
|
|
from uuid import UUID
|
|
|
|
from pydantic import BaseModel, ConfigDict, computed_field, field_validator
|
|
|
|
|
|
class BaseRosterLinkData(BaseModel, ABC):
|
|
"""Abstract base for roster link data
|
|
|
|
Common fields shared across all leagues
|
|
"""
|
|
|
|
model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True)
|
|
|
|
id: int | None = None # Database ID (populated after save)
|
|
game_id: UUID
|
|
team_id: int
|
|
|
|
@abstractmethod
|
|
def get_entity_id(self) -> int:
|
|
"""Get the entity ID (card_id or player_id)"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_entity_type(self) -> str:
|
|
"""Get entity type identifier ('card' or 'player')"""
|
|
pass
|
|
|
|
|
|
class PdRosterLinkData(BaseRosterLinkData):
|
|
"""PD league roster link - tracks cards
|
|
|
|
Used for Paper Dynasty league games where rosters are composed of cards.
|
|
Each card represents a player with detailed scouting data.
|
|
"""
|
|
|
|
card_id: int
|
|
|
|
@field_validator("card_id")
|
|
@classmethod
|
|
def validate_card_id(cls, v: int) -> int:
|
|
if v <= 0:
|
|
raise ValueError("card_id must be positive")
|
|
return v
|
|
|
|
def get_entity_id(self) -> int:
|
|
return self.card_id
|
|
|
|
def get_entity_type(self) -> str:
|
|
return "card"
|
|
|
|
|
|
class SbaRosterLinkData(BaseRosterLinkData):
|
|
"""SBA league roster link - tracks players
|
|
|
|
Used for SBA league games where rosters are composed of players.
|
|
Players are identified directly by player_id without a card system.
|
|
|
|
The player_positions field stores the player's natural positions from the API
|
|
for use in substitution UI filtering (batters vs pitchers). This supports
|
|
two-way players like Shohei Ohtani who have both pitching and batting positions.
|
|
|
|
The player_data field caches essential SbaPlayer fields (name, image, headshot)
|
|
to avoid runtime API calls when loading bench players.
|
|
"""
|
|
|
|
player_id: int
|
|
player_positions: list[str] = []
|
|
player_data: dict | None = None # Cached SbaPlayer fields: {name, image, headshot}
|
|
|
|
@field_validator("player_id")
|
|
@classmethod
|
|
def validate_player_id(cls, v: int) -> int:
|
|
if v <= 0:
|
|
raise ValueError("player_id must be positive")
|
|
return v
|
|
|
|
def get_entity_id(self) -> int:
|
|
return self.player_id
|
|
|
|
def get_entity_type(self) -> str:
|
|
return "player"
|
|
|
|
# Pitcher positions used for filtering
|
|
# CP = Closer Pitcher (alternate notation for CL)
|
|
_PITCHER_POSITIONS: set[str] = {"P", "SP", "RP", "CL", "CP"}
|
|
|
|
@computed_field
|
|
@property
|
|
def is_pitcher(self) -> bool:
|
|
"""True if player has any pitching position (supports two-way players)
|
|
|
|
Examples:
|
|
- ["SP"] -> True
|
|
- ["SP", "DH"] -> True (two-way player like Ohtani)
|
|
- ["CF", "DH"] -> False
|
|
"""
|
|
return any(pos in self._PITCHER_POSITIONS for pos in self.player_positions)
|
|
|
|
@computed_field
|
|
@property
|
|
def is_batter(self) -> bool:
|
|
"""True if player has any non-pitching position (supports two-way players)
|
|
|
|
Examples:
|
|
- ["CF", "DH"] -> True
|
|
- ["SP", "DH"] -> True (two-way player like Ohtani)
|
|
- ["SP"] -> False (pitcher-only)
|
|
"""
|
|
return any(pos not in self._PITCHER_POSITIONS for pos in self.player_positions)
|
|
|
|
|
|
class RosterLinkCreate(BaseModel):
|
|
"""Request model for creating a roster link"""
|
|
|
|
game_id: UUID
|
|
team_id: int
|
|
card_id: int | None = None
|
|
player_id: int | None = None
|
|
player_positions: list[str] = [] # Natural positions for substitution filtering
|
|
|
|
@field_validator("team_id")
|
|
@classmethod
|
|
def validate_team_id(cls, v: int) -> int:
|
|
if v <= 0:
|
|
raise ValueError("team_id must be positive")
|
|
return v
|
|
|
|
def model_post_init(self, __context) -> None:
|
|
"""Validate that exactly one ID is populated"""
|
|
has_card = self.card_id is not None
|
|
has_player = self.player_id is not None
|
|
|
|
if has_card == has_player: # XOR check (both True or both False = invalid)
|
|
raise ValueError("Exactly one of card_id or player_id must be provided")
|
|
|
|
def to_pd_data(self) -> PdRosterLinkData:
|
|
"""Convert to PD roster data (validates card_id is present)"""
|
|
if self.card_id is None:
|
|
raise ValueError("card_id required for PD roster")
|
|
return PdRosterLinkData(
|
|
game_id=self.game_id, team_id=self.team_id, card_id=self.card_id
|
|
)
|
|
|
|
def to_sba_data(self) -> SbaRosterLinkData:
|
|
"""Convert to SBA roster data (validates player_id is present)"""
|
|
if self.player_id is None:
|
|
raise ValueError("player_id required for SBA roster")
|
|
return SbaRosterLinkData(
|
|
game_id=self.game_id,
|
|
team_id=self.team_id,
|
|
player_id=self.player_id,
|
|
player_positions=self.player_positions,
|
|
)
|