📄 roster.py 10,769 bytes Sunday 12:34 📋 Raw

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