""" Roster API Endpoints GET /api/v1/roster - Returns AthleteSchema array """ from typing import Optional, List from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query, status, Request from pydantic import BaseModel from sqlalchemy.orm import Session from sqlalchemy import and_, or_ from app.database import get_db from app.models import Athlete, Case, School, User from app.schemas import AthleteSchema, RosterResponse, AthleteCreate, AthleteUpdate from app.utils.id_generator import generate_id from app.auth import get_current_user, require_role, require_at_or_coach, require_coach router = APIRouter(tags=["roster"]) # ============== VALIDATION HELPERS ============== VALID_SEVERITY = {"mild", "moderate", "severe"} VALID_STATUS = {"active", "resolved"} VALID_ATTENTION_LEVELS = {"urgent", "warning", "stable"} 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}" ) # ============== ROUTER ENDPOINTS ============== class RosterFilterParams(BaseModel): """Query parameters for roster filtering""" school_id: str sport: Optional[str] = None team: Optional[str] = None @router.get("", response_model=RosterResponse) async def get_roster( request: Request, school_id: str = Query(..., description="School identifier for tenant isolation"), sport: Optional[str] = Query(None, description="Filter by sport"), team: Optional[str] = Query(None, description="Filter by team (e.g., Varsity)"), current_user: User = Depends(require_at_or_coach), db: Session = Depends(get_db), ): """ Get roster for a school Role-based filtering: - AT: Full access to all roster data - Coach: Team-scoped only - Parent: No access (403) """ role = current_user.role # Validate school matches user's school (tenant isolation) if current_user.school_id != school_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied for this school" ) # 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" ) # Build query query = db.query(Athlete).filter(Athlete.school_id == school_id) # Coach: enforce sport and team scoping from user profile if role == "coach": assigned_sports = current_user.assigned_sports or [] assigned_teams = current_user.assigned_teams or [] # If sport param provided, verify coach is assigned to it if sport and sport not in assigned_sports: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Coach not assigned to sport: {sport}" ) # Filter to only assigned sports (if no sport param, show all assigned) if not sport and assigned_sports: # Use OR to match any of the coach's assigned sports sport_filters = [Athlete.sports.contains([s]) for s in assigned_sports] query = query.filter(or_(*sport_filters)) # Filter to assigned teams if specified if assigned_teams: query = query.filter(Athlete.team.in_(assigned_teams)) # Apply optional filters if sport: query = query.filter(Athlete.sports.contains([sport])) if team: query = query.filter(Athlete.team == team) athletes = query.all() return RosterResponse( athletes=[AthleteSchema.model_validate(a) for a in athletes], total=len(athletes), school_id=school_id ) @router.get("/{athlete_id}", response_model=AthleteSchema) async def get_athlete( request: Request, athlete_id: str, school_id: str = Query(..., description="School identifier for tenant isolation"), current_user: User = Depends(require_at_or_coach), db: Session = Depends(get_db), ): """Get single athlete by ID""" role = current_user.role # Validate school matches user's school if current_user.school_id != school_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied for this school" ) # 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" ) # Query athlete athlete = db.query(Athlete).filter( and_(Athlete.id == athlete_id, Athlete.school_id == school_id) ).first() if not athlete: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Athlete '{athlete_id}' not found" ) # Coach: check sport and team access from user profile if role == "coach": assigned_sports = current_user.assigned_sports or [] assigned_teams = current_user.assigned_teams or [] # Check if athlete's sport is in coach's assigned sports athlete_sports = athlete.sports or [] if assigned_sports and not any(s in assigned_sports for s in athlete_sports): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Coach does not have access to this athlete's sport" ) # Check team access if teams are assigned if assigned_teams and athlete.team not in assigned_teams: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Coach does not have access to this athlete's team" ) return AthleteSchema.model_validate(athlete) @router.post("", response_model=AthleteSchema, status_code=status.HTTP_201_CREATED) async def create_athlete( request: Request, athlete_data: AthleteCreate, current_user: User = Depends(require_role(["at"])), db: Session = Depends(get_db), ): """ Create a new athlete Restricted to AT role """ # Validate user's school matches if current_user.school_id != athlete_data.school_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot create athlete for different school" ) # Validate school exists school = db.query(School).filter(School.id == athlete_data.school_id).first() if not school: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"School '{athlete_data.school_id}' not found" ) # Validate grade if athlete_data.grade < 9 or athlete_data.grade > 12: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Grade must be between 9 and 12" ) # Create athlete athlete = Athlete( id=generate_id("ath_"), school_id=athlete_data.school_id, first_name=athlete_data.first_name, last_name=athlete_data.last_name, grade=athlete_data.grade, sports=athlete_data.sports or [], team=athlete_data.team, parent_ids=athlete_data.parent_ids or [], current_status="cleared", active_case_ids=[], created_at=datetime.utcnow(), updated_at=datetime.utcnow(), ) db.add(athlete) db.commit() db.refresh(athlete) return AthleteSchema.model_validate(athlete) @router.patch("/{athlete_id}", response_model=AthleteSchema) async def update_athlete( request: Request, athlete_id: str, athlete_data: AthleteUpdate, school_id: str = Query(..., description="School identifier"), current_user: User = Depends(require_role(["at"])), db: Session = Depends(get_db), ): """ Update an athlete Restricted to AT role """ # Validate user's school matches if current_user.school_id != school_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot update athlete in different school" ) # Query athlete athlete = db.query(Athlete).filter( and_(Athlete.id == athlete_id, Athlete.school_id == school_id) ).first() if not athlete: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Athlete '{athlete_id}' not found" ) # Update fields if athlete_data.first_name is not None: athlete.first_name = athlete_data.first_name if athlete_data.last_name is not None: athlete.last_name = athlete_data.last_name if athlete_data.grade is not None: if athlete_data.grade < 9 or athlete_data.grade > 12: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Grade must be between 9 and 12" ) athlete.grade = athlete_data.grade if athlete_data.sports is not None: athlete.sports = athlete_data.sports if athlete_data.team is not None: athlete.team = athlete_data.team if athlete_data.parent_ids is not None: athlete.parent_ids = athlete_data.parent_ids athlete.updated_at = datetime.utcnow() db.commit() db.refresh(athlete) return AthleteSchema.model_validate(athlete) @router.delete("/{athlete_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_athlete( request: Request, athlete_id: str, school_id: str = Query(..., description="School identifier"), current_user: User = Depends(require_role(["at"])), db: Session = Depends(get_db), ): """ Delete an athlete Restricted to AT role """ # Validate user's school matches if current_user.school_id != school_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete athlete in different school" ) # Query athlete athlete = db.query(Athlete).filter( and_(Athlete.id == athlete_id, Athlete.school_id == school_id) ).first() if not athlete: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Athlete '{athlete_id}' not found" ) db.delete(athlete) db.commit() return None