"""
Events API Endpoints
POST /api/v1/events/sideline-entry - 3-tap rapid entry form
"""
from typing import Optional, List
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from pydantic import BaseModel
from app.database import get_db
from app.models import Athlete, Case, Event, School, User
from app.schemas import (
SidelineEntryRequest,
SidelineEntryResponse,
EventContent,
CaseCreate
)
from app.utils.id_generator import generate_id
from app.auth import get_current_user, require_role
router = APIRouter(tags=["events"])
============== VALIDATION HELPERS ==============
VALID_SEVERITY = {"mild", "moderate", "severe"}
VALID_STATUS = {"active", "resolved"}
VALID_ATTENTION_LEVELS = {"urgent", "warning", "stable"}
VALID_EVENT_TYPES = {"note", "status_change", "restriction_added", "clearance_granted"}
VALID_VISIBILITY_ROLES = {"at", "coach", "parent"}
VALID_CURRENT_STATUS = {"cleared", "restricted", "out"}
def validate_enum(value: str, valid_set: set, field_name: str):
"""Validate enum values"""
if value not in valid_set:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid {field_name}: '{value}'. Must be one of: {valid_set}"
)
============== SIDELINE ENTRY LOGIC ==============
def find_active_case_for_body_part(
db: Session,
athlete_id: str,
body_part: str,
school_id: str
) -> Optional[Case]:
"""
Find active case for athlete + body_part
Uses fuzzy matching on case title
"""
body_part_lower = body_part.lower()
active_cases = db.query(Case).filter(
and_(
Case.athlete_id == athlete_id,
Case.school_id == school_id,
Case.status == "active"
)
).all()
for case in active_cases:
if body_part_lower in case.title.lower():
return case
return None
def find_recent_resolved_case(
db: Session,
athlete_id: str,
body_part: str,
school_id: str,
days: int = 30
) -> Optional[Case]:
"""
Find recently resolved case (within 30 days) for case reopening logic
"""
thirty_days_ago = datetime.utcnow() - timedelta(days=days)
body_part_lower = body_part.lower()
recent_cases = db.query(Case).filter(
and_(
Case.athlete_id == athlete_id,
Case.school_id == school_id,
Case.status == "resolved",
Case.resolved_at >= thirty_days_ago
)
).order_by(Case.resolved_at.desc()).all()
for case in recent_cases:
if body_part_lower in case.title.lower():
return case
return None
def create_case_from_sideline_entry(
db: Session,
school_id: str,
athlete_id: str,
body_part: str,
severity: str,
attention_level: str,
primary_at_id: str,
reopened_from_case_id: Optional[str] = None
) -> Case:
"""Create a new case from sideline entry"""
case = Case(
id=generate_id("cas_"),
school_id=school_id,
athlete_id=athlete_id,
title=f"{body_part} Injury",
severity=severity,
attention_level=attention_level,
status="active",
opened_at=datetime.utcnow(),
reopened_from_case_id=reopened_from_case_id,
reopened_at=datetime.utcnow() if reopened_from_case_id else None,
primary_at_id=primary_at_id,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(case)
db.commit()
db.refresh(case)
return case
def create_event_for_sideline_entry(
db: Session,
school_id: str,
case_id: str,
author_id: str,
removed_from_play: bool,
severity: str,
notes: Optional[str],
) -> Event:
"""Create event from sideline entry"""
# Determine event type and visibility
if removed_from_play:
event_type = "restriction_added"
# removed_from_play = True means "out" status
# visibility includes coach for operational awareness
visibility = ["at", "coach"]
else:
# Not removed from play - severity determines event type
if severity in ["moderate", "severe"]:
event_type = "restriction_added"
visibility = ["at", "coach"]
else:
event_type = "note"
visibility = ["at"]
# Build content
if removed_from_play:
text = f"Athlete removed from play due to {severity} injury."
else:
text = f"Sideline entry: {severity} severity noted. Athlete continued participation."
content = EventContent(
text=text,
clinical_notes=notes
)
event = Event(
id=generate_id("evt_"),
school_id=school_id,
case_id=case_id,
author_id=author_id,
event_type=event_type,
visibility=visibility,
content=content.model_dump(),
timestamp=datetime.utcnow(),
created_at=datetime.utcnow(),
)
db.add(event)
db.commit()
db.refresh(event)
return event
============== ROUTER ENDPOINTS ==============
@router.post("/sideline-entry", response_model=SidelineEntryResponse, status_code=status.HTTP_201_CREATED)
async def create_sideline_entry(
request: Request,
entry: SidelineEntryRequest,
current_user: User = Depends(require_role(["at"])),
db: Session = Depends(get_db),
):
"""
3-tap rapid entry form - primary data entry for ATs on the sideline
Actions:
1. Check for existing active case for athlete + body_part
2. If no active case, check for recently resolved case (30-day window)
3. If recent resolved case exists: create new case with reopened_from_case_id
4. If no case: create new Case
5. Create initial Event
6. Update athlete.current_status via trigger/hook
FERPA:
- visibility defaults to ["at"] unless explicitly expanded
- clinical_notes included in content if provided
Status Logic:
- removed_from_play=True -> athlete.current_status = "out"
- severity >= moderate -> athlete.current_status = "restricted"
"""
# Use current user's ID as author
author_id = current_user.id
# Validate user's school matches
if current_user.school_id != entry.school_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot create event for different school"
)
# Validate school exists
school = db.query(School).filter(School.id == entry.school_id).first()
if not school:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"School '{entry.school_id}' not found"
)
# Validate athlete exists
athlete = db.query(Athlete).filter(
and_(Athlete.id == entry.athlete_id, Athlete.school_id == entry.school_id)
).first()
if not athlete:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Athlete '{entry.athlete_id}' not found"
)
# Validate enums
validate_enum(entry.severity, VALID_SEVERITY, "severity")
validate_enum(entry.attention_level, VALID_ATTENTION_LEVELS, "attention_level")
# Step 1: Check for active case
existing_case = find_active_case_for_body_part(
db, entry.athlete_id, entry.body_part, entry.school_id
)
is_new_case = False
reopened_case_id = None
if existing_case:
# Use existing active case
case = existing_case
is_new_case = False
else:
# Step 2: Check for recently resolved case (30-day window)
recent_case = find_recent_resolved_case(
db, entry.athlete_id, entry.body_part, entry.school_id
)
if recent_case:
# Reopen case - create new case linked to old one
reopened_case_id = recent_case.id
case = create_case_from_sideline_entry(
db=db,
school_id=entry.school_id,
athlete_id=entry.athlete_id,
body_part=entry.body_part,
severity=entry.severity,
attention_level=entry.attention_level,
primary_at_id=author_id,
reopened_from_case_id=recent_case.id
)
is_new_case = True
else:
# Create new case
case = create_case_from_sideline_entry(
db=db,
school_id=entry.school_id,
athlete_id=entry.athlete_id,
body_part=entry.body_part,
severity=entry.severity,
attention_level=entry.attention_level,
primary_at_id=author_id
)
is_new_case = True
# Update athlete's active_case_ids
if case.id not in (athlete.active_case_ids or []):
athlete.active_case_ids = (athlete.active_case_ids or []) + [case.id]
db.commit()
# Step 3: Create event
event = create_event_for_sideline_entry(
db=db,
school_id=entry.school_id,
case_id=case.id,
author_id=author_id,
removed_from_play=entry.removed_from_play,
severity=entry.severity,
notes=entry.notes
)
# Step 4: Update athlete status
# The DB trigger handles restriction_added events, but we also update here
# to ensure immediate consistency
if entry.removed_from_play:
athlete.current_status = "out"
elif entry.severity in ["moderate", "severe"]:
athlete.current_status = "restricted"
else:
athlete.current_status = "restricted" # Mild with event still means restricted
db.commit()
return SidelineEntryResponse(
case_id=case.id,
event_id=event.id,
athlete_status=athlete.current_status,
is_new_case=is_new_case
)
============== GENERAL EVENT SCHEMA (JSON body) ==============
class GeneralEventBody(BaseModel):
"""JSON body for general event creation"""
case_id: str
event_type: str = "note"
note: str
visibility: List[str] = ["at", "coach"]
@router.post("/general", status_code=status.HTTP_201_CREATED)
async def create_event(
request: Request,
event_body: GeneralEventBody,
school_id: str = Query(..., description="School identifier"),
current_user: User = Depends(require_role(["at"])),
db: Session = Depends(get_db),
):
"""
Create a general event (JSON body version)
Accepts JSON body with case_id, event_type, note, visibility.
school_id comes from the query param for tenant isolation.
"""
author_id = current_user.id
# Validate user's school matches
if current_user.school_id != school_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot create event for different school"
)
# Validate enums
validate_enum(event_body.event_type, VALID_EVENT_TYPES, "event_type")
# Validate visibility roles
for v in event_body.visibility:
if v not in VALID_VISIBILITY_ROLES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid visibility role: '{v}'"
)
# Validate school exists
school = db.query(School).filter(School.id == school_id).first()
if not school:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"School '{school_id}' not found"
)
# Validate case exists
case = db.query(Case).filter(
and_(Case.id == event_body.case_id, Case.school_id == school_id)
).first()
if not case:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Case '{event_body.case_id}' not found"
)
# Build content
content = EventContent(
text=event_body.note
)
event = Event(
id=generate_id("evt_"),
school_id=school_id,
case_id=event_body.case_id,
author_id=author_id,
event_type=event_body.event_type,
visibility=event_body.visibility,
content=content.model_dump(),
timestamp=datetime.utcnow(),
created_at=datetime.utcnow(),
)
db.add(event)
db.commit()
db.refresh(event)
return {
"id": event.id,
"case_id": event.case_id,
"event_type": event.event_type,
"note": event_body.note,
"visibility": event_body.visibility,
"timestamp": event.timestamp.isoformat()
}