Source code for gamesheet_sdk.admin.roster.coaches

# Copyright (c) 2026 bdperkin
# SPDX-License-Identifier: MIT

"""Coach roster operations."""

from __future__ import annotations

from contextlib import suppress
from typing import Any

from gamesheet_sdk.admin.roster.helpers import (
    get_team_for_roster_update,
    update_team_roster,
)
from gamesheet_sdk.admin.roster.models import Coach, parse_coach
from gamesheet_sdk.common import errors
from gamesheet_sdk.common.exceptions import GameSheetError
from gamesheet_sdk.common.session import Session
from gamesheet_sdk.common.shared import JSONAPI_HEADERS, handle_response


[docs] def get_coach(session: Session, season_id: str, coach_id: str) -> Coach: """Get a single coach by ID. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The parent season identifier. :type season_id: str :param coach_id: The coach identifier to retrieve. :type coach_id: str :returns: Return value. :rtype: Coach """ endpoint = f"/api/seasons/{season_id}/coaches/{coach_id}" response = session.get(endpoint, headers=JSONAPI_HEADERS) handle_response(response, endpoint, "GET coach") body: dict[str, Any] = response.json() return parse_coach(body["data"])
[docs] def list_coaches(session: Session, season_id: str) -> list[Coach]: """Return every coach in the specified season. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier whose coaches to list. :type season_id: str :returns: A list of :class:`Coach`, in the order the server returned them. The list may be empty if the season has no coaches. :rtype: list[Coach] """ endpoint = f"/api/seasons/{season_id}/coaches" response = session.get( endpoint, headers=JSONAPI_HEADERS, params={"include": "teams,divisions"}, ) handle_response(response, endpoint, "GET coaches") body: dict[str, Any] = response.json() # Parse all coaches all_coaches = [parse_coach(item) for item in body.get("data", [])] return all_coaches
[docs] def create_coach( session: Session, season_id: str, first_name: str, last_name: str, *, external_id: str | None = None, position: str | None = None, team_id: str | None = None, ) -> Coach: """Create a new coach in the specified season. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier to create the coach in. :type season_id: str :param first_name: Coach's first name. :type first_name: str :param last_name: Coach's last name. :type last_name: str :param external_id: Optional external identifier for the coach. :type external_id: str | None :param position: Optional position (Head Coach, Assistant Coach, etc.). :type position: str | None :param team_id: Optional team identifier to associate the coach with. :type team_id: str | None :returns: Return value. :rtype: Coach """ endpoint = f"/api/seasons/{season_id}/coaches" payload: dict[str, Any] = { "data": { "type": "coaches", "attributes": { "first_name": first_name, "last_name": last_name, }, }, } if external_id: payload["data"]["attributes"]["external_id"] = external_id if position: payload["data"]["attributes"]["position"] = position if team_id: payload["data"]["relationships"] = { "teams": {"data": [{"type": "teams", "id": team_id}]}, } response = session.post(endpoint, headers=JSONAPI_HEADERS, json=payload) handle_response(response, endpoint, "POST coach") body: dict[str, Any] = response.json() return parse_coach(body["data"])
[docs] def update_coach( session: Session, season_id: str, coach_id: str, *, first_name: str | None = None, last_name: str | None = None, external_id: str | None = None, position: str | None = None, ) -> Coach: """Update an existing coach in the specified season. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. At least one field must be provided for update. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier containing the coach. :type season_id: str :param coach_id: The coach identifier to update. :type coach_id: str :param first_name: Optional updated first name. :type first_name: str | None :param last_name: Optional updated last name. :type last_name: str | None :param external_id: Optional updated external identifier. :type external_id: str | None :param position: Optional updated position. :type position: str | None :returns: The updated :class:`Coach`. :rtype: Coach :raises ValueError: If no fields are provided for update. """ if all(v is None for v in (first_name, last_name, external_id, position)): raise ValueError(errors.ERROR_MSG_AT_LEAST_ONE_FIELD) # Fetch current coach to get all fields current_coach = get_coach(session, season_id, coach_id) # Build payload with updated values, preserving current for unchanged fields payload: dict[str, Any] = { "data": { "id": coach_id, "type": "coaches", "attributes": { "first_name": first_name if first_name is not None else current_coach.first_name, "last_name": last_name if last_name is not None else current_coach.last_name, }, }, } # Add optional fields if external_id is not None: payload["data"]["attributes"]["external_id"] = external_id elif current_coach.external_id: payload["data"]["attributes"]["external_id"] = current_coach.external_id if position is not None: payload["data"]["attributes"]["position"] = position elif current_coach.position: payload["data"]["attributes"]["position"] = current_coach.position endpoint = f"/api/seasons/{season_id}/coaches/{coach_id}" response = session.patch(endpoint, headers=JSONAPI_HEADERS, json=payload) handle_response(response, endpoint, "PATCH coach") body: dict[str, Any] = response.json() return parse_coach(body["data"])
[docs] def list_team_coaches(session: Session, season_id: str, team_id: str) -> list[Coach]: """Return every coach for the specified team. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier. :type season_id: str :param team_id: The team identifier whose coaches to list. :type team_id: str :returns: A list of :class:`Coach`, in the order the server returned them. The list may be empty if the team has no coaches. :rtype: list[Coach] """ endpoint = f"/api/seasons/{season_id}/teams/{team_id}" response = session.get( endpoint, headers=JSONAPI_HEADERS, params={"include": "players,coaches"}, ) handle_response(response, endpoint, "GET team") body: dict[str, Any] = response.json() included_coaches = { item["id"]: item for item in body.get( "included", [], ) if item.get("type") == "coaches" } roster_metadata = { str(c["id"]): c for c in body.get("data", {}).get("attributes", {}).get("roster", {}).get("coaches", []) } coaches = [] for coach_id, coach_data in included_coaches.items(): coach = parse_coach(coach_data) if coach_id in roster_metadata: metadata = roster_metadata[coach_id] coach.position = metadata.get("position") coach.status = metadata.get("status") coach.signature = metadata.get("signature") coaches.append(coach) return coaches
[docs] def get_team_coach( session: Session, season_id: str, team_id: str, coach_id: str, ) -> Coach: """Get a single coach from a team's roster. This function retrieves team roster metadata (position, status, signature) that is only available in the team context, unlike :func:`get_coach` which fetches from the season-level coaches endpoint without roster metadata. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier. :type season_id: str :param team_id: The team identifier. :type team_id: str :param coach_id: The coach identifier to retrieve. :type coach_id: str :returns: The :class:`Coach` with team roster metadata populated. :rtype: Coach :raises GameSheetError: If the coach is not found on the team's roster. """ coaches = list_team_coaches(session, season_id, team_id) for coach in coaches: if coach.id == coach_id: return coach msg = f"Coach {coach_id} not found on team {team_id}" raise GameSheetError(msg)
def _build_coach_roster_entry( coach_id: str, *, position: str | None = None, ) -> dict[str, Any]: """Build a coach roster entry dict for team roster updates. :param coach_id: The coach identifier. :type coach_id: str :param position: Optional position (Head Coach, Assistant Coach, etc.). :type position: str | None :returns: Dictionary containing roster entry data ready for team roster update. :rtype: dict[str, Any] """ entry: dict[str, Any] = { "id": coach_id, "status": "coaching", } if position: entry["position"] = position return entry def _populate_coach_metadata( coach: Coach, *, position: str | None = None, ) -> None: """Populate coach object with roster metadata. Mutates the Coach object in place to set roster-specific fields. :param coach: The Coach instance to populate. :type coach: Coach :param position: Optional position (Head Coach, Assistant Coach, etc.). :type position: str | None """ if position: coach.position = position coach.status = "coaching"
[docs] def create_team_coach( session: Session, season_id: str, team_id: str, first_name: str, last_name: str, *, external_id: str | None = None, position: str | None = None, ) -> Coach: """Create a new coach and add to the specified team's roster. This function performs two operations: (1) creates the coach at the season level, (2) updates the team's roster to include the new coach with position and other metadata. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier. :type season_id: str :param team_id: The team identifier to add the coach to. :type team_id: str :param first_name: Coach's first name. :type first_name: str :param last_name: Coach's last name. :type last_name: str :param external_id: Optional external identifier for the coach. :type external_id: str | None :param position: Optional position (Head Coach, Assistant Coach, etc.). :type position: str | None :returns: Return value. :rtype: Coach """ # Step 1: Create the coach at the season level (without "type" field for team context) endpoint = f"/api/seasons/{season_id}/coaches" payload: dict[str, Any] = { "data": {"attributes": {"first_name": first_name, "last_name": last_name}}, } if external_id: payload["data"]["attributes"]["external_id"] = external_id response = session.post(endpoint, headers=JSONAPI_HEADERS, json=payload) handle_response(response, endpoint, "POST coach") coach = parse_coach(response.json()["data"]) # Step 2: Fetch current team data and update roster team_data = get_team_for_roster_update(session, season_id, team_id) roster = team_data.get("data", {}).get("attributes", {}).get("roster", {}) coaches_roster = roster.get("coaches", []) coach_entry = _build_coach_roster_entry(coach.id, position=position) coaches_roster.append(coach_entry) roster["coaches"] = coaches_roster # Step 3: Update team roster update_team_roster( session, season_id, team_id, roster, team_data.get("data", {}).get("attributes", {}), team_data.get("data", {}).get("relationships", {}), ) # Return the coach with roster metadata populated _populate_coach_metadata(coach, position=position) return coach
[docs] def update_team_coach( session: Session, season_id: str, team_id: str, coach_id: str, *, first_name: str | None = None, last_name: str | None = None, external_id: str | None = None, position: str | None = None, ) -> Coach: """Update a coach and update the team's roster in one operation. This function performs two operations: (1) updates the coach at the season level, (2) updates the team's roster with any position changes. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier. :type season_id: str :param team_id: The team identifier. :type team_id: str :param coach_id: The coach identifier to update. :type coach_id: str :param first_name: Optional updated first name. :type first_name: str | None :param last_name: Optional updated last name. :type last_name: str | None :param external_id: Optional updated external identifier. :type external_id: str | None :param position: Optional updated position. :type position: str | None :returns: The updated :class:`Coach`. :rtype: Coach :raises ValueError: If no fields are provided for update. """ if all(v is None for v in (first_name, last_name, external_id, position)): raise ValueError(errors.ERROR_MSG_AT_LEAST_ONE_FIELD) # Step 1: Update the coach at season level (without "type" field for team context) current_coach = get_team_coach(session, season_id, team_id, coach_id) payload: dict[str, Any] = { "data": { "id": coach_id, "attributes": { "first_name": first_name if first_name is not None else current_coach.first_name, "last_name": last_name if last_name is not None else current_coach.last_name, }, }, } # Add optional fields if external_id is not None: payload["data"]["attributes"]["external_id"] = external_id elif current_coach.external_id: payload["data"]["attributes"]["external_id"] = current_coach.external_id endpoint = f"/api/seasons/{season_id}/coaches/{coach_id}" response = session.patch(endpoint, headers=JSONAPI_HEADERS, json=payload) handle_response(response, endpoint, "PATCH team coach") body: dict[str, Any] = response.json() coach = parse_coach(body["data"]) # Step 2: Update team roster with position changes if needed if position is not None and position != current_coach.position: team_data = get_team_for_roster_update(session, season_id, team_id) roster = team_data.get("data", {}).get("attributes", {}).get("roster", {}) # Update the coach's position in the roster for coach_entry in roster.get("coaches", []): if coach_entry.get("id") == coach_id: coach_entry["position"] = position break update_team_roster( session, season_id, team_id, roster, team_data.get("data", {}).get("attributes", {}), team_data.get("data", {}).get("relationships", {}), ) coach.position = position coach.status = getattr(current_coach, "status", None) or "coaching" return coach
[docs] def delete_coach(session: Session, season_id: str, coach_id: str) -> None: """Delete a coach from the specified season. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier containing the coach. :type season_id: str :param coach_id: The coach identifier to delete. :type coach_id: str """ endpoint = f"/api/seasons/{season_id}/coaches/{coach_id}" response = session.delete(endpoint, headers=JSONAPI_HEADERS) handle_response(response, endpoint, "DELETE coach")
[docs] def unassign_coach( session: Session, season_id: str, coach_id: str, team_id: str, ) -> None: """Unassign a coach from a team's roster. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier. :type season_id: str :param coach_id: The coach identifier to unassign. :type coach_id: str :param team_id: The team identifier to unassign the coach from. :type team_id: str :raises GameSheetError: If the coach is not assigned to the team. """ # Step 1: Fetch current team data team_data = get_team_for_roster_update(session, season_id, team_id) current_attrs = team_data.get("data", {}).get("attributes", {}) current_relationships = team_data.get("data", {}).get("relationships", {}) # Step 2: Remove coach from roster roster = current_attrs.get("roster", {}) coaches_roster = roster.get("coaches", []) # Find and remove the coach original_count = len(coaches_roster) coaches_roster = [c for c in coaches_roster if c.get("id") != coach_id] if len(coaches_roster) == original_count: msg = f"Coach {coach_id} is not assigned to team {team_id}" raise GameSheetError(msg) roster["coaches"] = coaches_roster # Step 3: Update team roster update_team_roster( session, season_id, team_id, roster, current_attrs, current_relationships, )
[docs] def delete_team_coach( session: Session, season_id: str, team_id: str, coach_id: str, ) -> None: """Delete a coach from a team's roster and the season. This function performs two operations: (1) removes the coach from the team's roster, (2) deletes the coach at the season level. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier. :type season_id: str :param team_id: The team identifier. :type team_id: str :param coach_id: The coach identifier to delete. :type coach_id: str """ # Step 1: Remove coach from team roster (may not be on this team's roster) with suppress(GameSheetError): unassign_coach(session, season_id, coach_id, team_id) # Step 2: Delete the coach at the season level delete_coach(session, season_id, coach_id)
[docs] def assign_coach( session: Session, season_id: str, coach_id: str, team_id: str, *, position: str | None = None, ) -> Coach: """Assign an existing coach to a team's roster. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier. :type season_id: str :param coach_id: The coach identifier to assign. :type coach_id: str :param team_id: The team identifier to assign the coach to. :type team_id: str :param position: Optional position (Head Coach, Assistant Coach, etc.). :type position: str | None :returns: The :class:`Coach` with roster metadata populated. :rtype: Coach :raises GameSheetError: If the coach is already assigned to the team. """ # Step 1: Fetch the coach to ensure it exists coach = get_coach(session, season_id, coach_id) # Step 2: Fetch current team data team_data = get_team_for_roster_update(session, season_id, team_id) current_attrs = team_data.get("data", {}).get("attributes", {}) current_relationships = team_data.get("data", {}).get("relationships", {}) # Step 3: Add coach to roster roster = current_attrs.get("roster", {}) coaches_roster = roster.get("coaches", []) # Check if coach is already on the roster for existing_coach in coaches_roster: if existing_coach.get("id") == coach_id: msg = f"Coach {coach_id} is already assigned to team {team_id}" raise GameSheetError(msg) coach_entry = _build_coach_roster_entry(coach_id, position=position) coaches_roster.append(coach_entry) roster["coaches"] = coaches_roster # Step 4: Update team roster update_team_roster( session, season_id, team_id, roster, current_attrs, current_relationships, ) # Return the coach with roster metadata populated _populate_coach_metadata(coach, position=position) return coach
[docs] def assign_team_coach( session: Session, season_id: str, team_id: str, coach_id: str, *, position: str | None = None, ) -> Coach: """Assign an existing coach to a team's roster (team-scoped alias). This is an alias for :func:`assign_coach` provided for consistency with the team-scoped command structure. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier. :type season_id: str :param team_id: The team identifier to assign the coach to. :type team_id: str :param coach_id: The coach identifier to assign. :type coach_id: str :param position: Optional position (Head Coach, Assistant Coach, etc.). :type position: str | None :returns: Return value. :rtype: Coach """ return assign_coach(session, season_id, coach_id, team_id, position=position)
[docs] def unassign_team_coach( session: Session, season_id: str, team_id: str, coach_id: str, ) -> None: """Unassign a coach from a team's roster (team-scoped alias). This is an alias for :func:`unassign_coach` provided for consistency with the team-scoped command structure. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier. :type season_id: str :param team_id: The team identifier to unassign the coach from. :type team_id: str :param coach_id: The coach identifier to unassign. :type coach_id: str """ unassign_coach(session, season_id, coach_id, team_id)
[docs] def get_coach_penalty_report( session: Session, season_id: str, coach_id: str, ) -> dict[str, Any]: """Fetch penalty report for a coach. First retrieves the coach to get their external_id, then fetches the penalty report from the BFF API. The supplied :class:`Session` must already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier. :type season_id: str :param coach_id: The coach identifier. :type coach_id: str :returns: Penalty report data including coach_games, coach_penalties, rostered_coaches, and season_coaches. :rtype: dict[str, Any] """ coach = get_coach(session, season_id, coach_id) external_id = coach.external_id bff_url = f"https://bff-dashboard-api-awy26srzoa-nn.a.run.app/reports/coach-penalty-report/{external_id}" response = session.get(bff_url) handle_response(response, bff_url, "GET coach penalty report") body: dict[str, Any] = response.json() if body.get("status") != "success": status = body.get("status") msg = errors.ERROR_MSG_PENALTY_REPORT_API_STATUS.format(status=status) raise GameSheetError(msg) data: dict[str, Any] = body["data"] return data