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