""" 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() }