Source code for gamesheet_sdk.admin.divisions

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

"""GameSheet divisions: organizational groupings within a season.

A division is a grouping of teams within a season (e.g., "U13 AAA", "Bantam A", etc.). Each division belongs
to exactly one season. The dashboard displays divisions after navigating into a season view. This module talks
to the GameSheet JSON:API at ``/api/divisions?season_id={season_id}`` directly with the lightweight
:class:`gamesheet_sdk.Session` path -- no Playwright needed for read-only access once a bearer token has been
obtained (typically by reading the SPA's ``accessToken`` from the saved browser storage state via
:func:`gamesheet_sdk.common.auth.load_access_token`).
"""

from __future__ import annotations

from datetime import datetime
from typing import Any

from pydantic import BaseModel, Field

from gamesheet_sdk.admin.shared import (
    build_invitation_code_lookup,
    get_invitation_code_from_relationship,
    parse_jsonapi_resource,
)
from gamesheet_sdk.admin.teams import Team
from gamesheet_sdk.common.session import Session
from gamesheet_sdk.common.shared import JSONAPI_HEADERS, handle_response

_ENDPOINT = "/api/divisions"


[docs] class Division(BaseModel): """A single division. Maps the ``data[*]`` items in the JSON:API response of ``GET /api/divisions?season_id={id}`` to a flat typed model. :var id: Division identifier (string in JSON:API). :var season_id: Parent season identifier. :var title: Display name of the division. :var external_id: External identifier for integration with third-party systems. :var team_count: Number of teams in this division (populated when fetched with include_team_counts=True). :var created_at: When the division was created. :var updated_at: Last time the division was updated. """ id: str = Field(description="Division identifier (string in JSON:API).") season_id: str = Field(description="Parent season identifier.") title: str = Field(description="Display name of the division.") external_id: str | None = Field( default=None, description="External identifier for integration with third-party systems.", ) team_count: int | None = Field( default=None, description=( "Number of teams in this division (populated when fetched with include_team_counts=True)." ), ) created_at: datetime = Field(description="When the division was created.") updated_at: datetime = Field(description="Last time the division was updated.")
def _parse(item: dict[str, Any]) -> Division: """Flatten a JSON:API resource object into a :class:`Division`. :param item: A JSON:API resource object with ``id`` and ``attributes`` keys. :type item: dict[str, Any] :returns: Parsed Division model instance. :rtype: Division """ data = parse_jsonapi_resource(item, relationship_map={"season": "season_id"}) return Division(**data)
[docs] def list_division_teams(session: Session, division_id: str) -> list[Team]: """Return every team in the specified division. 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 division_id: The division identifier whose teams to list. :type division_id: str :returns: A list of :class:`Team`, in the order the server returned them. The list may be empty if the division has no teams. :rtype: list[Team] """ from gamesheet_sdk.admin.teams import _parse as parse_team endpoint = f"/api/divisions/{division_id}/teams" # Request sparse fieldset including logo_url and roster (for player/coach counts) # Include invitations relationship to get invitation codes params = { "fields[teams]": "title,logo_url,roster,created_at,updated_at", "include": "invitations", } response = session.get(endpoint, headers=JSONAPI_HEADERS, params=params) handle_response(response, endpoint, "GET division teams") body: dict[str, Any] = response.json() # Build invitation code lookup from included resources invitation_codes = build_invitation_code_lookup(body) # Parse teams and match invitation codes via relationships teams = [] for item in body.get("data", []): team = parse_team(item) # Look up invitation code from relationship invitation_code = get_invitation_code_from_relationship(item, invitation_codes) if invitation_code: # Update the team with the invitation code using model_copy team = team.model_copy(update={"invitation_code": invitation_code}) teams.append(team) return teams
[docs] def get_division( session: Session, division_id: str, *, include_team_count: bool = True, ) -> Division: """Get a single division 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 division_id: The division identifier to retrieve. :type division_id: str :param include_team_count: If ``True`` (default), fetch and populate ``team_count`` for the division (requires an additional API call). :type include_team_count: bool :returns: The requested Division model instance. :rtype: Division :raises AuthenticationError: If the server returns 401. :raises GameSheetError: For any other non-2xx response. """ endpoint = f"{_ENDPOINT}/{division_id}" response = session.get(endpoint, headers=JSONAPI_HEADERS) handle_response(response, endpoint, "GET division") body: dict[str, Any] = response.json() division = _parse(body["data"]) # If requested, fetch team count for this division if include_team_count: teams = list_division_teams(session, division.id) division.team_count = len(teams) return division
[docs] def list_divisions( session: Session, season_id: str, *, include_team_counts: bool = False, ) -> list[Division]: """Return every division 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. .. note:: The GameSheet API returns all divisions, so this function filters client-side to only include divisions that belong to the specified season (via the ``relationships.season.data.id`` field). :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier whose divisions to list. :type season_id: str :param include_team_counts: If ``True``, fetch and populate ``team_count`` for each division (requires an additional API call per division). :type include_team_counts: bool :returns: A list of :class:`Division`, in the order the server returned them. The list may be empty if the season has no divisions. :rtype: list[Division] :raises AuthenticationError: If the server returns 401. :raises GameSheetError: For any other non-2xx response. """ response = session.get(_ENDPOINT, headers=JSONAPI_HEADERS) handle_response(response, _ENDPOINT, "GET divisions") body: dict[str, Any] = response.json() # Parse all divisions and filter to only those belonging to the requested season all_divisions = [_parse(item) for item in body.get("data", [])] divisions = [d for d in all_divisions if d.season_id == season_id] # If requested, fetch team counts for each division if include_team_counts: for division in divisions: teams = list_division_teams(session, division.id) division.team_count = len(teams) return divisions
[docs] def create_division( session: Session, season_id: str, title: str, *, external_id: str | None = None, ) -> Division: """Create a new division 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 in which to create the division. :type season_id: str :param title: The display name of the division. :type title: str :param external_id: Optional external identifier for integration with third-party systems. If not provided, a UUID will be generated automatically. :type external_id: str | None :returns: The newly created Division model instance. :rtype: Division :raises AuthenticationError: If the server returns 401. :raises GameSheetError: For any other non-2xx response. """ import uuid endpoint = f"/api/seasons/{season_id}/divisions" # Generate external_id if not provided if external_id is None: external_id = str(uuid.uuid4()) payload = { "data": { "type": "divisions", "attributes": { "title": title, "external_id": external_id, "settings": {}, }, "relationships": { "season": { "data": { "id": season_id, "type": "seasons", }, }, }, }, } response = session.post(endpoint, json=payload, headers=JSONAPI_HEADERS) handle_response(response, endpoint, "POST division") body: dict[str, Any] = response.json() return _parse(body["data"])
[docs] def update_division( session: Session, season_id: str, division_id: str, *, title: str | None = None, external_id: str | None = None, ) -> Division: """Update an existing division. 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 of title or external_id must be provided. :param session: An authenticated :class:`Session`. :type session: Session :param season_id: The season identifier containing the division. :type season_id: str :param division_id: The division identifier to update. :type division_id: str :param title: Optional new display name for the division. :type title: str | None :param external_id: Optional new external identifier. :type external_id: str | None :returns: The updated :class:`Division`. :rtype: Division :raises ValueError: If neither title nor external_id is provided. """ if title is None is external_id: msg = "At least one of title or external_id must be provided" raise ValueError(msg) # GameSheet API requires title to always be present in PATCH payload. # If user didn't provide a new title, fetch the current one. if title is None: # Fetch current division to get existing title get_response = session.get( f"/api/divisions/{division_id}", headers=JSONAPI_HEADERS, ) if get_response.status_code == 200: current_data = get_response.json() title = current_data.get("data", {}).get("attributes", {}).get("title", "") else: # If we can't fetch the current title, the PATCH will fail anyway # Let it proceed and return the API's error title = "" endpoint = f"/api/seasons/{season_id}/divisions/{division_id}" # Build attributes dict - title is always required by the API attributes: dict[str, Any] = { "title": title, "settings": {}, # GameSheet API requires settings to be present in PATCH } if external_id is not None: attributes["external_id"] = external_id payload = { "data": { "type": "divisions", "id": division_id, "attributes": attributes, "relationships": { "season": { "data": { "id": season_id, "type": "seasons", }, }, }, }, } response = session.patch(endpoint, json=payload, headers=JSONAPI_HEADERS) handle_response(response, endpoint, "PATCH division") body: dict[str, Any] = response.json() return _parse(body["data"])
[docs] def delete_division( session: Session, season_id: str, division_id: str, ) -> None: """Delete a division. 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 division. :type season_id: str :param division_id: The division identifier to delete. :type division_id: str """ endpoint = f"/api/seasons/{season_id}/divisions/{division_id}" response = session.delete(endpoint, headers=JSONAPI_HEADERS) handle_response(response, endpoint, "DELETE division")