""" Pydantic v2 Schemas for RTSport API Source of truth: contract.md ยง1 """ from datetime import datetime from typing import Optional, List, Dict, Any from pydantic import BaseModel, Field, ConfigDict, field_validator import json # ============== ENUMS ============== class CurrentStatus(str): """Athlete clearance status - cached property""" CLEARED = "cleared" RESTRICTED = "restricted" OUT = "out" class Severity(str): """Clinical diagnosis - static""" MILD = "mild" MODERATE = "moderate" SEVERE = "severe" class AttentionLevel(str): """Operational priority - updated daily by AT""" URGENT = "urgent" WARNING = "warning" STABLE = "stable" class CaseStatus(str): """Case lifecycle status""" ACTIVE = "active" RESOLVED = "resolved" class MilestoneStatus(str): """Milestone completion status""" PENDING = "pending" ACHIEVED = "achieved" SKIPPED = "skipped" class EventType(str): """Event classification""" NOTE = "note" STATUS_CHANGE = "status_change" RESTRICTION_ADDED = "restriction_added" CLEARANCE_GRANTED = "clearance_granted" class VisibilityRole(str): """FERPA visibility roles""" AT = "at" COACH = "coach" PARENT = "parent" # ============== SCHOOL SCHEMA ============== class SchoolConfig(BaseModel): """School-specific configuration""" model_config = ConfigDict(extra="allow") primary_color: Optional[str] = None logo_url: Optional[str] = None sport_names: Optional[Dict[str, str]] = None class SchoolSchema(BaseModel): """ The Tenant - multi-tenant boundary Every operational model must include school_id for data isolation """ model_config = ConfigDict(populate_by_name=True, from_attributes=True) id: str = Field(..., description="Unique school identifier (e.g., schl_001)") name: str = Field(..., description="School display name") domain: str = Field(..., description="Email domain for auto-provisioning") config: Optional[SchoolConfig] = Field(default=None, description="School branding/config") class SchoolCreate(BaseModel): """Create new school""" name: str domain: str config: Optional[SchoolConfig] = None class SchoolUpdate(BaseModel): """Update existing school""" name: Optional[str] = None domain: Optional[str] = None config: Optional[SchoolConfig] = None # ============== ATHLETE SCHEMA ============== class AthleteSchema(BaseModel): """ The central node - roster queries pull this fast without clinical payload current_status is a cached property - updated via DB trigger or transaction hook """ model_config = ConfigDict(populate_by_name=True, from_attributes=True) id: str = Field(..., description="Unique athlete identifier (e.g., ath_102938)") school_id: str = Field(..., description="Tenant isolation key") first_name: str last_name: str grade: int = Field(..., ge=9, le=12, description="High school grade level") sports: List[str] = Field(default_factory=list, description="Sports participated in") team: Optional[str] = Field(default=None, description="Team assignment (e.g., Varsity)") parent_ids: List[str] = Field(default_factory=list, description="Linked parent user IDs") current_status: str = Field(default="cleared", description="Cached: cleared|restricted|out") active_case_ids: List[str] = Field(default_factory=list, description="Currently active case IDs") @field_validator('sports', 'parent_ids', 'active_case_ids', mode='before') @classmethod def parse_json_list(cls, v): """Parse JSON string fields from SQLite (ARRAY columns stored as strings).""" if isinstance(v, str): try: return json.loads(v) except (json.JSONDecodeError, ValueError): return [] if isinstance(v, list): return v return [] class AthleteCreate(BaseModel): """Create new athlete""" school_id: str first_name: str last_name: str grade: int = Field(..., ge=9, le=12) sports: List[str] = Field(default_factory=list) team: Optional[str] = None parent_ids: List[str] = Field(default_factory=list) class AthleteUpdate(BaseModel): """Update existing athlete""" first_name: Optional[str] = None last_name: Optional[str] = None grade: Optional[int] = Field(default=None, ge=9, le=12) sports: Optional[List[str]] = None team: Optional[str] = None parent_ids: Optional[List[str]] = None # ============== CASE SCHEMA ============== class CaseSchema(BaseModel): """ A single injury lifecycle - severity: clinical diagnosis (static) - attention_level: operational priority (updated daily by AT) - Resolved via clearance_granted event; reopenable within 30 days """ model_config = ConfigDict(populate_by_name=True, from_attributes=True) id: str = Field(..., description="Unique case identifier (e.g., cas_554433)") school_id: str = Field(..., description="Tenant isolation key") athlete_id: str = Field(..., description="Reference to parent athlete") title: str = Field(..., description="Human-readable injury description") severity: str = Field(..., description="Clinical: mild|moderate|severe") attention_level: str = Field(default="stable", description="Operational: urgent|warning|stable") status: str = Field(default="active", description="Lifecycle: active|resolved") opened_at: datetime = Field(..., description="Case creation timestamp") # Case reopening support (30-day window) reopened_from_case_id: Optional[str] = Field(default=None, description="Original case ID if reopened within 30 days") reopened_at: Optional[datetime] = Field(default=None, description="When case was reopened") resolved_at: Optional[datetime] = Field(default=None, description="Resolution timestamp") primary_at_id: str = Field(..., description="ID of primary athletic trainer") class CaseCreate(BaseModel): """Create new case""" school_id: str athlete_id: str title: str severity: str = Field(..., pattern=r"^(mild|moderate|severe)$") attention_level: str = Field(default="stable", pattern=r"^(urgent|warning|stable)$") primary_at_id: str class CaseUpdate(BaseModel): """Update existing case""" title: Optional[str] = None attention_level: Optional[str] = Field(default=None, pattern=r"^(urgent|warning|stable)$") status: Optional[str] = Field(default=None, pattern=r"^(active|resolved)$") resolved_at: Optional[datetime] = None reopened_from_case_id: Optional[str] = None reopened_at: Optional[datetime] = None primary_at_id: Optional[str] = None # ============== MILESTONE SCHEMA ============== class MilestoneSchema(BaseModel): """ Forward-looking targets - Phase Bar UI Separate from Events (which are backward-looking records) """ model_config = ConfigDict(populate_by_name=True, from_attributes=True) id: str = Field(..., description="Unique milestone identifier (e.g., mil_001)") school_id: str = Field(..., description="Tenant isolation key") case_id: str = Field(..., description="Reference to parent case") title: str = Field(..., description="Milestone description") target_date: datetime = Field(..., description="Target completion date") status: str = Field(default="pending", description="pending|achieved|skipped") achieved_at: Optional[datetime] = Field(default=None, description="Actual completion timestamp") class MilestoneCreate(BaseModel): """Create new milestone""" school_id: str case_id: str title: str target_date: datetime class MilestoneUpdate(BaseModel): """Update existing milestone""" title: Optional[str] = None target_date: Optional[datetime] = None status: Optional[str] = Field(default=None, pattern=r"^(pending|achieved|skipped)$") achieved_at: Optional[datetime] = None # ============== EVENT SCHEMA ============== class EventContent(BaseModel): """Event content payload""" model_config = ConfigDict(extra="allow") text: str = Field(..., description="Main event content") clinical_notes: Optional[str] = Field(default=None, description="AT-only clinical details") class EventSchema(BaseModel): """ The workhorse - every sideline entry, clinical note, or clearance is an Event FERPA RULE: visibility defaults to ["at"] unless explicitly expanded clinical_notes is stripped from responses for non-AT roles clearance_granted event atomically updates parent Case status to "resolved" """ model_config = ConfigDict(populate_by_name=True, from_attributes=True) id: str = Field(..., description="Unique event identifier (e.g., evt_776655)") school_id: str = Field(..., description="Tenant isolation key") case_id: str = Field(..., description="Reference to parent case") author_id: str = Field(..., description="ID of event creator") event_type: str = Field(..., description="note|status_change|restriction_added|clearance_granted") visibility: List[str] = Field(default_factory=lambda: ["at"], description="Roles that can view") content: EventContent = Field(..., description="Event content payload") timestamp: datetime = Field(..., description="Event timestamp") class EventCreate(BaseModel): """Create new event""" school_id: str case_id: str author_id: str event_type: str = Field(..., pattern=r"^(note|status_change|restriction_added|clearance_granted)$") visibility: List[str] = Field(default_factory=lambda: ["at"]) content: EventContent class EventUpdate(BaseModel): """Update existing event""" visibility: Optional[List[str]] = None content: Optional[EventContent] = None # ============== SIDELINE ENTRY SCHEMA ============== class SidelineEntryRequest(BaseModel): """ 3-tap rapid entry form - primary data entry path for ATs on the sideline Backend action: - Creates new Case if no active case exists for athlete + body_part - Creates initial Event - Updates athlete.current_status via trigger/hook """ school_id: str = Field(..., description="Tenant isolation key") athlete_id: str = Field(..., description="Injured athlete") body_part: str = Field(..., description="Injury location (e.g., 'Ankle - Right')") severity: str = Field(..., pattern=r"^(mild|moderate|severe)$") attention_level: str = Field(default="stable", pattern=r"^(urgent|warning|stable)$") removed_from_play: bool = Field(default=False, description="Whether athlete was removed from play") notes: Optional[str] = Field(default=None, description="Optional clinical notes") class SidelineEntryResponse(BaseModel): """Response from sideline entry""" case_id: str = Field(..., description="Created or existing case ID") event_id: str = Field(..., description="Created event ID") athlete_status: str = Field(..., description="Updated athlete current_status") is_new_case: bool = Field(..., description="Whether a new case was created") # ============== ROSTER QUERY SCHEMAS ============== class RosterQueryParams(BaseModel): """Query parameters for roster endpoint""" school_id: str sport: Optional[str] = None team: Optional[str] = None class RosterResponse(BaseModel): """Paginated roster response""" athletes: List[AthleteSchema] total: int school_id: str