📄 rtsport_parent_api.py 21,078 bytes Wednesday 01:46 📋 Raw

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