📄 rtsport_coach_api.py 21,675 bytes Wednesday 01:30 📋 Raw

"""RTSport Coach Dashboard API — Status & Messages endpoints.

Provides 5 endpoints for the Coach Dashboard:
1. GET /api/v1/dashboard/coach/status — Team-wide injury/recovery analytics
2. GET /api/v1/dashboard/coach/athlete/{id}/timeline — Athlete case milestones
3. GET /api/v1/messages/coach — Conversation threads
4. GET /api/v1/messages/coach/{athlete_id} — Message thread history
5. POST /api/v1/messages/coach/{athlete_id} — Send a message

Uses Bearer JWT from the existing HoffDesk auth pattern.
Mock data features Preble High School athletes.
"""

import uuid
import time
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional

from fastapi import APIRouter, Header, HTTPException, Query
from pydantic import BaseModel

logger = logging.getLogger("rtsport.coach_api")

router = APIRouter(tags=["rtsport-coach"])

──────────────────────────────────────────────────────────────────────────────

Mock data — Preble High School Football athletes

──────────────────────────────────────────────────────────────────────────────

ATHLETES = {
"ath_001": {
"id": "ath_001",
"name": "Jake Larson",
"sport": "Football",
"school_id": "schl_001",
"status": "out",
"position": "QB",
"grade": 12,
"cases": [
{
"id": "case_001",
"body_part": "Ankle - Right",
"opened": "2026-04-15T00:00:00Z",
"status": "active",
"phase": "active_rehab",
"milestones": [
{"name": "Initial Assessment", "completed": True, "date": "2026-04-15"},
{"name": "Active Rehab", "completed": False, "date": None},
{"name": "Return to Play", "completed": False, "date": None},
{"name": "Full Clearance", "completed": False, "date": None},
],
}
],
},
"ath_002": {
"id": "ath_002",
"name": "Marcus Johnson",
"sport": "Football",
"school_id": "schl_001",
"status": "out",
"position": "LB",
"grade": 11,
"cases": [
{
"id": "case_002",
"body_part": "Knee - Left (ACL)",
"opened": "2026-04-01T00:00:00Z",
"status": "active",
"phase": "initial_assessment",
"milestones": [
{"name": "Initial Assessment", "completed": True, "date": "2026-04-01"},
{"name": "Active Rehab", "completed": False, "date": None},
{"name": "Return to Play", "completed": False, "date": None},
{"name": "Full Clearance", "completed": False, "date": None},
],
}
],
},
"ath_003": {
"id": "ath_003",
"name": "Tyler Wilson",
"sport": "Football",
"school_id": "schl_001",
"status": "modified",
"position": "WR",
"grade": 10,
"cases": [
{
"id": "case_003",
"body_part": "Knee",
"opened": "2026-05-05T12:00:00Z",
"status": "active",
"phase": "return_to_play",
"milestones": [
{"name": "Initial Assessment", "completed": True, "date": "2026-05-05"},
{"name": "Active Rehab", "completed": True, "date": "2026-05-10"},
{"name": "Return to Play", "completed": False, "date": None},
{"name": "Full Clearance", "completed": False, "date": None},
],
}
],
},
"ath_004": {
"id": "ath_004",
"name": "Derek Lee",
"sport": "Football",
"school_id": "schl_001",
"status": "modified",
"position": "RB",
"grade": 11,
"cases": [
{
"id": "case_004",
"body_part": "Shoulder - Left",
"opened": "2026-05-01T00:00:00Z",
"status": "active",
"phase": "return_to_play",
"milestones": [
{"name": "Initial Assessment", "completed": True, "date": "2026-05-01"},
{"name": "Active Rehab", "completed": True, "date": "2026-05-10"},
{"name": "Return to Play", "completed": False, "date": None},
{"name": "Full Clearance", "completed": False, "date": None},
],
}
],
},
"ath_005": {
"id": "ath_005",
"name": "Cameron Davis",
"sport": "Football",
"school_id": "schl_001",
"status": "out",
"position": "OL",
"grade": 12,
"cases": [
{
"id": "case_005",
"body_part": "Hamstring - Right",
"opened": "2026-04-28T00:00:00Z",
"status": "active",
"phase": "initial_assessment",
"milestones": [
{"name": "Initial Assessment", "completed": True, "date": "2026-04-28"},
{"name": "Active Rehab", "completed": False, "date": None},
{"name": "Return to Play", "completed": False, "date": None},
{"name": "Full Clearance", "completed": False, "date": None},
],
}
],
},
"ath_006": {
"id": "ath_006",
"name": "Ethan Brooks",
"sport": "Football",
"school_id": "schl_001",
"status": "cleared",
"position": "DB",
"grade": 11,
"cases": [
{
"id": "case_006",
"body_part": "Concussion",
"opened": "2026-03-20T00:00:00Z",
"status": "closed",
"phase": "full_clearance",
"milestones": [
{"name": "Initial Assessment", "completed": True, "date": "2026-03-20"},
{"name": "Active Rehab", "completed": True, "date": "2026-03-28"},
{"name": "Return to Play", "completed": True, "date": "2026-04-05"},
{"name": "Full Clearance", "completed": True, "date": "2026-04-12"},
],
}
],
},
}

Map phase → estimated days remaining (rough duration estimates)

PHASE_DAYS = {
"initial_assessment": 21,
"active_rehab": 14,
"return_to_play": 3,
"full_clearance": 0,
}

TOTAL_ROSTER_FOOTBALL = 50

Recent activity log (most recent first)

RECENT_ACTIVITY = [
{
"type": "status_change",
"athlete_name": "Jake Larson",
"from": "modified",
"to": "out",
"timestamp": "2026-05-06T00:00:00Z",
},
{
"type": "new_case",
"athlete_name": "Tyler Wilson",
"body_part": "Knee",
"timestamp": "2026-05-05T12:00:00Z",
},
{
"type": "status_change",
"athlete_name": "Derek Lee",
"from": "cleared",
"to": "modified",
"timestamp": "2026-05-03T08:00:00Z",
},
{
"type": "new_case",
"athlete_name": "Cameron Davis",
"body_part": "Hamstring - Right",
"timestamp": "2026-04-28T14:00:00Z",
},
{
"type": "clearance",
"athlete_name": "Ethan Brooks",
"body_part": "Concussion",
"timestamp": "2026-04-12T10:00:00Z",
},
]

──────────────────────────────────────────────────────────────────────────────

In-memory message store

──────────────────────────────────────────────────────────────────────────────

MESSAGES: dict[str, list[dict]] = {
"ath_001": [
{
"id": "msg_001",
"sender": "coach",
"text": "Any update on the ankle? Heard he's been doing PT.",
"timestamp": "2026-05-05T20:00:00Z",
"status": "read",
},
{
"id": "msg_002",
"sender": "at",
"text": "He's progressing well. Should be back next week. ROM is nearly full.",
"timestamp": "2026-05-05T20:15:00Z",
"status": "read",
},
{
"id": "msg_003",
"sender": "at",
"text": "Update: Jake started light jogging today. No pain reported.",
"timestamp": "2026-05-05T22:00:00Z",
"status": "delivered",
},
],
"ath_002": [
{
"id": "msg_004",
"sender": "at",
"text": "Marcus has his MRI scheduled for Thursday. I'll share results as soon as they come in.",
"timestamp": "2026-05-04T14:30:00Z",
"status": "read",
},
{
"id": "msg_005",
"sender": "coach",
"text": "Thanks, keep me posted. Need to know if we should plan for him being out for the season opener.",
"timestamp": "2026-05-04T15:00:00Z",
"status": "read",
},
],
"ath_003": [
{
"id": "msg_006",
"sender": "at",
"text": "Tyler's knee is responding well to treatment. He should be cleared for modified practice this week.",
"timestamp": "2026-05-05T16:00:00Z",
"status": "unread",
},
{
"id": "msg_007",
"sender": "at",
"text": "He'll need a brace for contact drills. I'll fit him tomorrow.",
"timestamp": "2026-05-05T16:05:00Z",
"status": "unread",
},
],
"ath_004": [
{
"id": "msg_008",
"sender": "at",
"text": "Derek is almost ready. Shoulder strength test came back at 90%. One more week of modified work.",
"timestamp": "2026-05-05T10:00:00Z",
"status": "read",
},
{
"id": "msg_009",
"sender": "coach",
"text": "Good to hear. Can he do non-contact drills this Friday?",
"timestamp": "2026-05-05T10:30:00Z",
"status": "read",
},
{
"id": "msg_010",
"sender": "at",
"text": "Yes, cleared for non-contact. I'll send the modified practice protocol.",
"timestamp": "2026-05-05T10:45:00Z",
"status": "read",
},
],
"ath_005": [
{
"id": "msg_011",
"sender": "at",
"text": "Cam's hamstring is still tight. Keeping him in assessment phase. No timeline yet.",
"timestamp": "2026-05-05T09:00:00Z",
"status": "unread",
},
],
}

Track per-athlete unread counts for conversation list

UNREAD_COUNTS: dict[str, int] = {
"ath_001": 0, # coach already read latest
"ath_002": 0,
"ath_003": 2,
"ath_004": 0,
"ath_005": 1,
}

AT names by sport

AT_NAMES: dict[str, str] = {
"Football": "Sarah (AT)",
"Basketball": "Mike (AT)",
"Soccer": "Emily (AT)",
}

──────────────────────────────────────────────────────────────────────────────

Pydantic models

──────────────────────────────────────────────────────────────────────────────

class SendMessageRequest(BaseModel):
text: str

──────────────────────────────────────────────────────────────────────────────

JWT auth helper

──────────────────────────────────────────────────────────────────────────────

def _verify_coach_token(authorization: str | None) -> str:
"""Validate Bearer JWT and return the email.

Currently accepts any token that exists in the mock auth table.
This will be hardened when unified auth is wired in.
"""
if not authorization:
    raise HTTPException(status_code=401, detail="Missing Authorization header")
if not authorization.startswith("Bearer "):
    raise HTTPException(status_code=401, detail="Invalid Authorization scheme")

token = authorization[7:]

# Mock token validation — accept any non-empty token for demo
if not token or len(token) < 8:
    raise HTTPException(status_code=401, detail="Invalid token")

# For mock purposes, derive the email from token (matches frontend login flow)
# In production this would decode the JWT
return "coach@preble.k12.wi.us"

──────────────────────────────────────────────────────────────────────────────

Helper functions

──────────────────────────────────────────────────────────────────────────────

def _compute_status_summary(sport: str, school_id: str) -> dict:
"""Compute out/modified/cleared counts from athlete data."""
sport_athletes = [
a for a in ATHLETES.values()
if a["sport"] == sport and a["school_id"] == school_id
]
out_count = sum(1 for a in sport_athletes if a["status"] == "out")
mod_count = sum(1 for a in sport_athletes if a["status"] == "modified")
cleared_count = sum(1 for a in sport_athletes if a["status"] == "cleared")

# Estimate total roster = cleared + reported cases + buffer for healthy
total = max(out_count + mod_count + cleared_count, TOTAL_ROSTER_FOOTBALL)

return {
    "out": out_count,
    "modified": mod_count,
    "cleared": total - out_count - mod_count,
}

def _compute_injury_breakdown(sport: str, school_id: str) -> list[dict]:
"""Aggregate body parts for active cases."""
breakdown: dict[str, int] = {}
for a in ATHLETES.values():
if a["sport"] != sport or a["school_id"] != school_id:
continue
for case in a["cases"]:
if case["status"] != "active":
continue
bp = case["body_part"].split(" - ")[0] # Normalize "Knee - Left" → "Knee"
breakdown[bp] = breakdown.get(bp, 0) + 1

return sorted(
    [{"body_part": bp, "count": c} for bp, c in breakdown.items()],
    key=lambda x: x["count"],
    reverse=True,
)

def _compute_recovery_timeline(sport: str, school_id: str) -> list[dict]:
"""Build recovery timeline for athletes with active cases."""
timeline = []
for a in ATHLETES.values():
if a["sport"] != sport or a["school_id"] != school_id:
continue
for case in a["cases"]:
if case["status"] != "active":
continue
phase = case.get("phase", "initial_assessment")
days_remaining = PHASE_DAYS.get(phase, 14)
timeline.append({
"athlete_name": a["name"],
"status": a["status"],
"phase": phase,
"days_remaining": days_remaining,
})
return sorted(timeline, key=lambda x: x["days_remaining"])

def _compute_conversations(sport: str, school_id: str) -> list[dict]:
"""Build conversation list from message store."""
sport_athlete_ids = {
aid: a["name"]
for aid, a in ATHLETES.items()
if a["sport"] == sport and a["school_id"] == school_id
}
convos = []
for aid, name in sport_athlete_ids.items():
msgs = MESSAGES.get(aid, [])
if not msgs:
continue
last = msgs[-1]
at_name = AT_NAMES.get(sport, "AT Staff")
convos.append({
"athlete_name": name,
"athlete_id": aid,
"last_message": last["text"],
"last_message_time": last["timestamp"],
"unread_count": UNREAD_COUNTS.get(aid, 0),
"at_name": at_name,
})

# Sort by most recent message first
convos.sort(key=lambda c: c["last_message_time"], reverse=True)
return convos

──────────────────────────────────────────────────────────────────────────────

Endpoints

──────────────────────────────────────────────────────────────────────────────

@router.get("/dashboard/coach/status")
async def get_coach_status(
school_id: str = Query(..., description="School ID (e.g. schl_001)"),
sport: str = Query("Football", description="Sport name"),
authorization: str | None = Header(None, alias="Authorization"),
):
"""GET /api/v1/dashboard/coach/status?school_id=X&sport=Football

Returns status summary, injury breakdown, recovery timeline, and recent activity.
"""
coach_email = _verify_coach_token(authorization)
logger.info(f"Coach {coach_email} fetching status for {sport} @ {school_id}")

return {
    "sport": sport,
    "total_athletes": TOTAL_ROSTER_FOOTBALL,
    "status_summary": _compute_status_summary(sport, school_id),
    "injury_breakdown": _compute_injury_breakdown(sport, school_id),
    "recovery_timeline": _compute_recovery_timeline(sport, school_id),
    "recent_activity": RECENT_ACTIVITY[:10],
}

@router.get("/dashboard/coach/athlete/{athlete_id}/timeline")
async def get_athlete_timeline(
athlete_id: str,
authorization: str | None = Header(None, alias="Authorization"),
):
"""GET /api/v1/dashboard/coach/athlete/{athlete_id}/timeline

Returns case milestones for a specific athlete.
"""
_verify_coach_token(authorization)

athlete = ATHLETES.get(athlete_id)
if not athlete:
    raise HTTPException(status_code=404, detail=f"Athlete {athlete_id} not found")

return {
    "athlete_name": athlete["name"],
    "cases": athlete["cases"],
}

@router.get("/messages/coach")
async def get_coach_messages(
school_id: str = Query(..., description="School ID"),
sport: str = Query("Football", description="Sport name"),
authorization: str | None = Header(None, alias="Authorization"),
):
"""GET /api/v1/messages/coach?school_id=X&sport=Football

Returns conversation threads with unread counts.
"""
_verify_coach_token(authorization)

convos = _compute_conversations(sport, school_id)
return {"conversations": convos}

@router.get("/messages/coach/{athlete_id}")
async def get_message_thread(
athlete_id: str,
before: str | None = Query(None, description="ISO-8601 timestamp for pagination"),
authorization: str | None = Header(None, alias="Authorization"),
):
"""GET /api/v1/messages/coach/{athlete_id}?before=X

Returns message history for an athlete thread.
Supports ?before= for cursor-based pagination.
"""
_verify_coach_token(authorization)

athlete = ATHLETES.get(athlete_id)
if not athlete:
    raise HTTPException(status_code=404, detail=f"Athlete {athlete_id} not found")

msgs = MESSAGES.get(athlete_id, [])

if before:
    # Filter messages before the given timestamp
    msgs = [m for m in msgs if m["timestamp"] < before]

return {
    "athlete_name": athlete["name"],
    "messages": msgs,
}

@router.post("/messages/coach/{athlete_id}", status_code=201)
async def send_message(
athlete_id: str,
body: SendMessageRequest,
authorization: str | None = Header(None, alias="Authorization"),
):
"""POST /api/v1/messages/coach/{athlete_id}

Sends a message from the coach to the athlete's AT.
Body: {"text": "..."}
Returns 201 Created with the message object.
"""
_verify_coach_token(authorization)

athlete = ATHLETES.get(athlete_id)
if not athlete:
    raise HTTPException(status_code=404, detail=f"Athlete {athlete_id} not found")

if not body.text or not body.text.strip():
    raise HTTPException(status_code=400, detail="Message text cannot be empty")

now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
new_msg = {
    "id": f"msg_{uuid.uuid4().hex[:8]}",
    "sender": "coach",
    "text": body.text.strip(),
    "timestamp": now,
    "status": "sent",
}

if athlete_id not in MESSAGES:
    MESSAGES[athlete_id] = []
MESSAGES[athlete_id].append(new_msg)

return new_msg