"""RTSport Parent Dashboard API — 6 endpoints for parent-facing views.
Endpoints:
1. GET /api/v1/dashboard/parent — Full parent home data (athlete + case + activity)
2. GET /api/v1/dashboard/parent/children — Children linked to parent
3. GET /api/v1/dashboard/parent/timeline — Chronological timeline (FERPA-filtered)
4. GET /api/v1/messages/parent — Conversations with unread counts
5. GET /api/v1/messages/parent/{contact_id} — Message thread history
6. POST /api/v1/messages/parent/{contact_id} — Send a message
Uses Bearer JWT auth (same pattern as coach API).
Shares in-memory message store with coach API via importing from rtsport_coach_api.
FERPA-aware: timeline NEVER includes clinical_note type events.
"""
import uuid
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Header, HTTPException, Query
from pydantic import BaseModel
logger = logging.getLogger("rtsport.parent_api")
router = APIRouter(tags=["rtsport-parent"])
── Share coach API's message store ─────────────────────────────────────
Import the shared MESSAGES dict and ATHLETES from coach API so messages
sent by parent ↔ coach → AT are visible bidirectionally.
try:
from rtsport_coach_api import MESSAGES as _COACH_MESSAGES, ATHLETES as _COACH_ATHLETES
MESSAGES = _COACH_MESSAGES
ATHLETES = _COACH_ATHLETES
except ImportError:
# Fallback: stand-alone message store (won't cross-pollinate)
MESSAGES: dict[str, list[dict]] = {}
ATHLETES: dict[str, dict] = {}
logger.info("Parent API linked to coach API message store")
── Mock Parent Data ─────────────────────────────────────────────────────
Two children linked to a parent account:
Jake Larson (ath_001) — active ankle case
Emma Larson (ath_005) — healthy / fully cleared
We reuse ath_001 from the coach ATHLETES dict and add ath_005.
ath_005 already exists in coach data as "Ethan Brooks" with id ath_006,
but we keep things simple by using the same IDs where possible.
PARENT_CHILDREN = [
{
"id": "ath_001",
"first_name": "Jake",
"last_name": "Larson",
"sport": "Football",
"team": "Varsity",
"grade": 11,
"jersey_number": 12,
"current_status": "out",
"has_active_case": True,
},
{
"id": "ath_005",
"first_name": "Emma",
"last_name": "Larson",
"sport": "Soccer",
"team": "JV",
"grade": 9,
"jersey_number": 7,
"current_status": "cleared",
"has_active_case": False,
},
]
── Mock Case Data ───────────────────────────────────────────────────────
Jake's active ankle case
JAKE_CASE = {
"id": "case_001",
"status": "active",
"body_part": "Ankle",
"side": "Right",
"injury_type": "Sprain",
"severity": "moderate",
"opened_date": "2026-04-15",
"phase": "active_rehab",
"phase_number": 2,
"total_phases": 4,
"estimated_return": "2026-05-20",
"next_appointment": "2026-05-08T15:30:00Z",
"restrictions": [
"No running",
"Non-contact only",
"Must wear ankle brace",
],
"milestones": [
{"label": "Initial Assessment", "status": "done", "completed_date": "2026-04-15"},
{"label": "Active Rehab", "status": "current", "target_date": "2026-05-10"},
{"label": "Return to Play", "status": "pending", "target_date": "2026-05-20"},
{"label": "Full Clearance", "status": "pending", "target_date": "2026-05-25"},
],
"care_team": [
{"name": "Lisa Johnson, LAT", "role": "Athletic Trainer", "initials": "LJ", "email": "ljohnson@preble.k12.wi.us"},
{"name": "Coach Andrews", "role": "Head Coach — Football", "initials": "CA", "email": "andrews@preble.k12.wi.us"},
],
}
── Activity Feed (at least 5 events, varied types) ─────────────────────
ACTIVITY_FEED = [
{
"type": "milestone",
"title": "Advanced to Active Rehab",
"date": "2026-05-01",
"note": "Range of motion restored. Starting strengthening exercises.",
},
{
"type": "message",
"title": "Message from Lisa Johnson, LAT",
"date": "2026-04-28",
"note": "Jake is progressing well. We'll re-evaluate next week.",
},
{
"type": "restriction",
"title": "Restrictions Updated",
"date": "2026-04-22",
"note": "Non-contact practice only. Must wear ankle brace.",
},
{
"type": "assessment",
"title": "Initial Assessment",
"date": "2026-04-15",
"note": "Right ankle sprain — Grade 2. Moderate swelling.",
},
{
"type": "clearance",
"title": "Case Opened",
"date": "2026-04-15",
"note": "Injury reported during football practice. AT notified immediately.",
},
]
── Timeline Events (FERPA-filtered: no clinical_notes) ─────────────────
TIMELINE_EVENTS = [
{
"type": "milestone",
"title": "Advanced to Active Rehab",
"date": "2026-05-01",
"description": "Range of motion restored. Starting strengthening exercises — 3x/week.",
"icon": "🏃",
},
{
"type": "message",
"title": "AT Update",
"date": "2026-04-28",
"description": 'Lisa Johnson shared: "Jake is progressing well. We\'ll re-evaluate next week."',
"icon": "💬",
},
{
"type": "restriction",
"title": "Restrictions Updated",
"date": "2026-04-22",
"description": "Non-contact practice only. Must wear ankle brace during all activities.",
"icon": "⚠️",
},
{
"type": "assessment",
"title": "Initial Assessment",
"date": "2026-04-15",
"description": "Right ankle sprain — Grade 2. Moderate swelling. Immobilized, crutches for 3 days.",
"icon": "🏥",
},
{
"type": "clearance",
"title": "Case Opened",
"date": "2026-04-15",
"description": "Injury reported during football practice. AT notified immediately.",
"icon": "📋",
},
# This event would be filtered out by FERPA if type were clinical_note
# Explicitly NOT including clinical_notes here — FERPA compliance.
]
Emma's healthy timeline (no case)
HEALTHY_TIMELINE = [] # empty — no active case = no events
── Pydantic Models ──────────────────────────────────────────────────────
class SendMessageRequest(BaseModel):
text: str
── Parent Conversation Data ─────────────────────────────────────────────
Map athlete IDs to their parent-facing contact list.
Each contact is a care team member the parent can message.
PARENT_CONTACTS = {
"ath_001": [
{
"contact_id": "care_lj_001",
"contact_name": "Lisa Johnson, LAT",
"contact_role": "Athletic Trainer",
"contact_initials": "LJ",
},
{
"contact_id": "care_ca_001",
"contact_name": "Coach Andrews",
"contact_role": "Head Coach — Football",
"contact_initials": "CA",
},
],
"ath_005": [
{
"contact_id": "care_cs_005",
"contact_name": "Coach Stevens",
"contact_role": "Head Coach — Soccer",
"contact_initials": "CS",
},
],
}
Parent-side messages — keyed by contact_id so each thread is per-contact, not per-athlete.
These link to the coach/AT message store where relevant.
PARENT_MESSAGES: dict[str, list[dict]] = {
"care_lj_001": [
{
"id": "pmsg_001",
"sender": "at",
"text": "Hi, Jake's initial assessment is complete. Grade 2 right ankle sprain.",
"timestamp": "2026-04-15T14:00:00Z",
"status": "read",
},
{
"id": "pmsg_002",
"sender": "parent",
"text": "Thank you for letting me know. What's the recovery timeline?",
"timestamp": "2026-04-15T14:30:00Z",
"status": "read",
},
{
"id": "pmsg_003",
"sender": "at",
"text": "Jake is progressing well. We'll re-evaluate next week.",
"timestamp": "2026-04-28T15:30:00Z",
"status": "delivered",
},
],
"care_ca_001": [
{
"id": "pmsg_010",
"sender": "parent",
"text": "Hi Coach Andrews, just checking in on Jake's status for Friday's game.",
"timestamp": "2026-04-20T09:00:00Z",
"status": "read",
},
{
"id": "pmsg_011",
"sender": "at",
"text": "Jake will not be playing Friday. Still in active rehab. We'll keep you updated.",
"timestamp": "2026-04-20T09:15:00Z",
"status": "read",
},
],
"care_cs_005": [], # Emma — no messages yet
}
PARENT_UNREAD_COUNTS: dict[str, int] = {
"care_lj_001": 1,
"care_ca_001": 0,
"care_cs_005": 0,
}
Build per-athlete conversation summary from PARENT_CONTACTS and messages
def _compute_parent_conversations(athlete_id: str) -> list[dict]:
"""Get conversation summary for a parent, grouped by contact."""
contacts = PARENT_CONTACTS.get(athlete_id, [])
conversations = []
for contact in contacts:
cid = contact["contact_id"]
msgs = PARENT_MESSAGES.get(cid, [])
if msgs:
last = msgs[-1]
last_msg = last["text"]
last_ts = last["timestamp"]
else:
last_msg = None
last_ts = None
conversations.append({
"contact_name": contact["contact_name"],
"contact_role": contact["contact_role"],
"contact_initials": contact["contact_initials"],
"contact_id": cid,
"last_message": last_msg,
"last_message_time": last_ts,
"unread_count": PARENT_UNREAD_COUNTS.get(cid, 0),
})
return conversations
── Auth ─────────────────────────────────────────────────────────────────
def _verify_parent_token(authorization: str | None) -> str:
"""Validate Bearer JWT for parent.
Same mock pattern as coach API — accepts any non-empty token for demo.
"""
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:]
if not token or len(token) < 8:
raise HTTPException(status_code=401, detail="Invalid token")
return "parent@preble.k12.wi.us"
── Athlete Lookup ───────────────────────────────────────────────────────
def _get_child(athlete_id: str) -> dict:
"""Look up a child by ID; raise 404 if not found."""
for child in PARENT_CHILDREN:
if child["id"] == athlete_id:
return child
raise HTTPException(status_code=404, detail=f"Athlete {athlete_id} not found")
=========================================================================
ENDPOINT 1: GET /dashboard/parent
Full parent home data: athlete info, case, milestones, care team, feed
=========================================================================
@router.get("/dashboard/parent")
async def get_parent_dashboard(
school_id: str = Query(..., description="School ID (e.g. schl_001)"),
athlete_id: str = Query(..., description="Athlete ID (e.g. ath_001)"),
authorization: str | None = Header(None, alias="Authorization"),
):
"""GET /api/v1/dashboard/parent?school_id=X&athlete_id=Y
Returns athlete info, active case (if any), milestones, care team, restrictions,
next appointment, and recent activity feed.
If athlete has no active case, returns case=None and an empty activity_feed.
"""
parent_email = _verify_parent_token(authorization)
child = _get_child(athlete_id)
logger.info(f"Parent {parent_email} fetching dashboard for {child['first_name']} {child['last_name']}")
# Build athlete info from child data
athlete_info = {
"id": child["id"],
"first_name": child["first_name"],
"last_name": child["last_name"],
"sport": child["sport"],
"team": child["team"],
"grade": child["grade"],
"jersey_number": child.get("jersey_number"),
"current_status": child["current_status"],
}
# Return case data only if athlete has an active case (Jake = ath_001)
case_data = JAKE_CASE if athlete_id == "ath_001" else None
# Activity feed only for active cases
feed = ACTIVITY_FEED if athlete_id == "ath_001" else []
return {
"athlete": athlete_info,
"case": case_data,
"activity_feed": feed,
}
=========================================================================
ENDPOINT 2: GET /dashboard/parent/children
List children linked to the authenticated parent
=========================================================================
@router.get("/dashboard/parent/children")
async def get_parent_children(
school_id: str = Query(..., description="School ID (e.g. schl_001)"),
authorization: str | None = Header(None, alias="Authorization"),
):
"""GET /api/v1/dashboard/parent/children?school_id=X
Returns all children linked to the authenticated parent account.
"""
_verify_parent_token(authorization)
return {"children": PARENT_CHILDREN}
=========================================================================
ENDPOINT 3: GET /dashboard/parent/timeline
Chronological timeline events — FERPA-filtered (no clinical_notes)
=========================================================================
@router.get("/dashboard/parent/timeline")
async def get_parent_timeline(
school_id: str = Query(..., description="School ID (e.g. schl_001)"),
athlete_id: str = Query(..., description="Athlete ID (e.g. ath_001)"),
authorization: str | None = Header(None, alias="Authorization"),
):
"""GET /api/v1/dashboard/parent/timeline?school_id=X&athlete_id=Y
Returns chronological timeline events for the athlete's recovery journey.
FERPA-compliant: events of type 'clinical_note' are NEVER included.
Returns 200 with empty events array if the athlete has no active case.
"""
parent_email = _verify_parent_token(authorization)
child = _get_child(athlete_id)
logger.info(f"Parent {parent_email} fetching timeline for {child['first_name']} {child['last_name']}")
if athlete_id == "ath_001":
# Active case — return timeline events (all types except clinical_note)
# Our mock data already excludes clinical_notes.
events = TIMELINE_EVENTS
case_info = {
"id": "case_001",
"phase": "active_rehab",
"phase_number": 2,
"total_phases": 4,
}
else:
# Healthy athlete — no events
events = []
case_info = None
return {
"athlete_name": f"{child['first_name']} {child['last_name']}",
"case": case_info,
"events": events,
}
=========================================================================
ENDPOINT 4: GET /messages/parent
Conversations with care team, unread counts per contact
=========================================================================
@router.get("/messages/parent")
async def get_parent_messages(
athlete_id: str = Query(..., description="Athlete ID (e.g. ath_001)"),
authorization: str | None = Header(None, alias="Authorization"),
):
"""GET /api/v1/messages/parent?athlete_id=Y
Returns conversation threads with each care team member for the given athlete.
Each conversation includes the last message, timestamp, and unread count.
"""
_verify_parent_token(authorization)
child = _get_child(athlete_id)
conversations = _compute_parent_conversations(athlete_id)
return {
"athlete_name": f"{child['first_name']} {child['last_name']}",
"conversations": conversations,
}
=========================================================================
ENDPOINT 5: GET /messages/parent/{contact_id}
Message thread history with a specific contact
=========================================================================
@router.get("/messages/parent/{contact_id}")
async def get_parent_message_thread(
contact_id: str,
before: str | None = Query(None, description="ISO-8601 timestamp for cursor pagination"),
authorization: str | None = Header(None, alias="Authorization"),
):
"""GET /api/v1/messages/parent/{contact_id}?before=X
Returns message history for a parent ↔ care team thread.
Supports optional ?before= for cursor-based pagination.
"""
_verify_parent_token(authorization)
# Look up which contact this ID belongs to
contact_name = None
for athlete_contacts in PARENT_CONTACTS.values():
for c in athlete_contacts:
if c["contact_id"] == contact_id:
contact_name = c["contact_name"]
break
if contact_name:
break
if not contact_name:
raise HTTPException(status_code=404, detail=f"Contact {contact_id} not found")
msgs = PARENT_MESSAGES.get(contact_id, [])
if before:
msgs = [m for m in msgs if m["timestamp"] < before]
# Build the response: contact_name + messages from parent perspective
# Map sender: "parent" means the parent wrote it, "at" means care team wrote it
return {
"contact_name": contact_name,
"messages": msgs,
}
=========================================================================
ENDPOINT 6: POST /messages/parent/{contact_id}
Send a message to a care team member
=========================================================================
@router.post("/messages/parent/{contact_id}", status_code=201)
async def send_parent_message(
contact_id: str,
body: SendMessageRequest,
authorization: str | None = Header(None, alias="Authorization"),
):
"""POST /api/v1/messages/parent/{contact_id}
Sends a message from the parent to a care team member.
Body: {"text": "..."}
Returns 201 Created with the new message object.
Also mirrors the message into the coach API shared store (athlete-level),
so coaches/ATs see parent messages in their own view (bidirectional).
"""
_verify_parent_token(authorization)
# Verify contact exists
contact_name = None
linked_athlete_id = None
for aid, athlete_contacts in PARENT_CONTACTS.items():
for c in athlete_contacts:
if c["contact_id"] == contact_id:
contact_name = c["contact_name"]
linked_athlete_id = aid
break
if contact_name:
break
if not contact_name:
raise HTTPException(status_code=404, detail=f"Contact {contact_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"pmsg_{uuid.uuid4().hex[:8]}",
"sender": "parent",
"text": body.text.strip(),
"timestamp": now,
"status": "sent",
}
# Store in parent message store
if contact_id not in PARENT_MESSAGES:
PARENT_MESSAGES[contact_id] = []
PARENT_MESSAGES[contact_id].append(new_msg)
# Also mirror into the shared coach message store so AT/coach sees it.
# Use the linked athlete's conversation thread in the coach store.
if linked_athlete_id and linked_athlete_id in MESSAGES:
mirrored_msg = dict(new_msg)
mirrored_msg["sender"] = "parent"
mirrored_msg["id"] = f"msg_{uuid.uuid4().hex[:8]}"
MESSAGES[linked_athlete_id].append(mirrored_msg)
return new_msg