๐Ÿ“„ athletes.py 11,001 bytes Yesterday 03:56 ๐Ÿ“‹ Raw

"""
Athletes API Endpoints (v0.3 Frontend Spec)
GET /api/v1/athletes/{athlete_id} - Returns AthleteSchema per frontend spec
GET /api/v1/athletes/{athlete_id}/case - Returns combined athlete + active case
GET /api/v1/cases/{case_id} - Returns single case by ID
"""
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from sqlalchemy import and_
from pydantic import BaseModel

from app.database import get_db
from app.models import Athlete, Case, Event, Milestone, School, User
from app.schemas import AthleteSchema, CaseSchema, MilestoneSchema
from app.auth import get_current_user, require_role, require_at_or_coach

router = APIRouter(tags=["athletes"])

============== SCHEMAS ==============

class TimelineEvent(BaseModel):
"""Timeline event for case display"""
date: str
title: str
note: str
by: str
type: str # assessment, restriction, milestone, clearance, note
visibility: List[str] = ["at", "coach"] # FERPA visibility

class MilestoneDisplay(BaseModel):
"""Milestone for case display"""
num: int
label: str
status: str # done, current, pending
completed: Optional[str] # ISO date or null
note: str

class CaseDetail(BaseModel):
"""Extended case schema matching frontend spec"""
case_id: str
athlete_id: str
injury: str
injury_date: str
body_part: str
side: Optional[str] # Left, Right, null
severity: str # mild, moderate, severe
attention: str # stable, urgent, warning
status: str # active, cleared, referred
phase: int
total_phases: int
phase_label: str
projected_return: Optional[str]
last_updated: str
last_updated_by: str
notified: List[str]
milestones: List[MilestoneDisplay]
timeline: List[TimelineEvent]

class FrontendAthlete(BaseModel):
"""Athlete schema matching frontend spec"""
id: str
first_name: str
last_name: str
sport: str
team: str
grade: int
initials: str
active: bool

class CombinedAthleteCaseResponse(BaseModel):
"""Combined athlete + case response"""
athlete: FrontendAthlete
case: Optional[CaseDetail]

============== HELPER FUNCTIONS ==============

def get_initials(first_name: str, last_name: str) -> str:
"""Generate initials from first and last name"""
first = first_name[0] if first_name else ""
last = last_name[0] if last_name else ""
return f"{first}{last}".upper()

def extract_body_part(title: str) -> str:
"""Extract body part from case title"""
title_lower = title.lower()
parts = ["ankle", "knee", "shoulder", "wrist", "head", "concussion",
"hip", "back", "neck", "elbow", "hand", "foot", "leg", "arm",
"thigh", "calf", "groin", "chest", "ribs", "abdomen", "face"]
for part in parts:
if part in title_lower:
return part.capitalize()
return "Unknown"

def extract_side(title: str) -> Optional[str]:
"""Extract left/right side from case title"""
title_lower = title.lower()
if "left" in title_lower or " - left" in title_lower:
return "Left"
elif "right" in title_lower or " - right" in title_lower:
return "Right"
return None

def get_phase_label(phase: int, total: int) -> str:
"""Get human-readable phase label"""
labels = {
1: "Initial Assessment",
2: "Active Rehab",
3: "Return to Play",
4: "Full Clearance"
}
return labels.get(phase, f"Phase {phase}")

============== ROUTER ENDPOINTS ==============

@router.get("/{athlete_id}", response_model=FrontendAthlete)
async def get_athlete_frontend(
request: Request,
athlete_id: str,
current_user: User = Depends(require_at_or_coach),
db: Session = Depends(get_db),
):
"""
Get athlete profile in frontend format

Matches the schema expected by the AT dashboard frontend.
"""
# Validate school matches user's school
if current_user.school_id != current_user.school_id:
    raise HTTPException(
        status_code=status.HTTP_403_FORBIDDEN,
        detail="Access denied for this school"
    )

school_id = current_user.school_id

# Query athlete
athlete = db.query(Athlete).filter(
    and_(Athlete.id == athlete_id, Athlete.school_id == school_id)
).first()

if not athlete:
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail=f"Athlete '{athlete_id}' not found"
    )

# Coach: check team access
if current_user.role == "coach":
    coach_teams = request.headers.get("X-Coach-Teams", "").split(",")
    coach_teams = [t.strip() for t in coach_teams if t.strip()]
    if coach_teams and athlete.team not in coach_teams:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Coach does not have access to this athlete's team"
        )

return FrontendAthlete(
    id=athlete.id,
    first_name=athlete.first_name,
    last_name=athlete.last_name,
    sport=athlete.sports[0] if athlete.sports else "Unknown",
    team=athlete.team or "",
    grade=athlete.grade,
    initials=get_initials(athlete.first_name, athlete.last_name),
    active=athlete.current_status != "out"
)

@router.get("/{athlete_id}/case", response_model=CombinedAthleteCaseResponse)
async def get_athlete_with_case(
request: Request,
athlete_id: str,
current_user: User = Depends(require_at_or_coach),
db: Session = Depends(get_db),
):
"""
Get combined athlete + active case data

Returns athlete profile and their active case (if any) in a single response.
This saves a round-trip from the frontend which previously made two separate calls.
"""
school_id = current_user.school_id

# Query athlete
athlete = db.query(Athlete).filter(
    and_(Athlete.id == athlete_id, Athlete.school_id == school_id)
).first()

if not athlete:
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail=f"Athlete '{athlete_id}' not found"
    )

# Coach: check team access
if current_user.role == "coach":
    coach_teams = request.headers.get("X-Coach-Teams", "").split(",")
    coach_teams = [t.strip() for t in coach_teams if t.strip()]
    if coach_teams and athlete.team not in coach_teams:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Coach does not have access to this athlete's team"
        )

# Get active case (most recent)
active_case = db.query(Case).filter(
    and_(
        Case.athlete_id == athlete_id,
        Case.school_id == school_id,
        Case.status == "active"
    )
).order_by(Case.opened_at.desc()).first()

athlete_data = FrontendAthlete(
    id=athlete.id,
    first_name=athlete.first_name,
    last_name=athlete.last_name,
    sport=athlete.sports[0] if athlete.sports else "Unknown",
    team=athlete.team or "",
    grade=athlete.grade,
    initials=get_initials(athlete.first_name, athlete.last_name),
    active=athlete.current_status != "out"
)

if not active_case:
    return CombinedAthleteCaseResponse(athlete=athlete_data, case=None)

# Get milestones for this case
milestones_db = db.query(Milestone).filter(
    Milestone.case_id == active_case.id
).order_by(Milestone.target_date).all()

milestones = []
current_phase = 1
for i, m in enumerate(milestones_db, 1):
    status = "done" if m.status == "achieved" else "current" if m.status == "pending" and current_phase == i else "pending"
    if status == "current":
        current_phase = i
    milestones.append(MilestoneDisplay(
        num=i,
        label=m.title,
        status=status,
        completed=m.achieved_at.strftime("%Y-%m-%d") if m.achieved_at else None,
        note=m.title
    ))

if not milestones:
    # Default milestones if none exist
    milestones = [
        MilestoneDisplay(num=1, label="Initial Assessment", status="done", completed=None, note=""),
        MilestoneDisplay(num=2, label="Active Rehab", status="current", completed=None, note=""),
        MilestoneDisplay(num=3, label="Return to Play", status="pending", completed=None, note=""),
        MilestoneDisplay(num=4, label="Full Clearance", status="pending", completed=None, note=""),
    ]
    current_phase = 2

total_phases = len(milestones)

# Get timeline events
events_db = db.query(Event).filter(
    Event.case_id == active_case.id
).order_by(Event.timestamp.desc()).all()

timeline = []
for event in events_db:
    event_type_map = {
        "note": "note",
        "status_change": "milestone",
        "restriction_added": "restriction",
        "clearance_granted": "clearance"
    }

    content_text = event.content.get("text", "") if isinstance(event.content, dict) else ""

    timeline.append(TimelineEvent(
        date=event.timestamp.strftime("%b %d ยท %I:%M %p"),
        title=event.event_type.replace("_", " ").title(),
        note=content_text[:100] + "..." if len(content_text) > 100 else content_text,
        by=event.author_id[:6] if event.author_id else "You",
        type=event_type_map.get(event.event_type, "note"),
        visibility=event.visibility or ["at", "coach"]
    ))

# Build notified list (simplified - would come from event visibility)
notified = ["Coach"]
if active_case.attention_level == "urgent":
    notified.append("Parent")

case_detail = CaseDetail(
    case_id=active_case.id,
    athlete_id=athlete.id,
    injury=active_case.title,
    injury_date=active_case.opened_at.strftime("%Y-%m-%d"),
    body_part=extract_body_part(active_case.title),
    side=extract_side(active_case.title),
    severity=active_case.severity,
    attention=active_case.attention_level,
    status=active_case.status,
    phase=current_phase,
    total_phases=total_phases,
    phase_label=get_phase_label(current_phase, total_phases),
    projected_return=None,  # Would be calculated from milestones
    last_updated=active_case.updated_at.strftime("%Y-%m-%d %H:%M"),
    last_updated_by="You",
    notified=notified,
    milestones=milestones,
    timeline=timeline
)

return CombinedAthleteCaseResponse(athlete=athlete_data, case=case_detail)

Note: Single case endpoint is added to cases.py router but mounted here for cleaner URL

GET /api/v1/cases/{case_id} is defined in cases.py and included in main.py