Source code for gamesheet_sdk.admin.seasons

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

"""GameSheet seasons: time periods within a league.

A season is a time period during which games are played within a league (e.g., "2024-2025", "Fall 2024",
etc.). Each season belongs to exactly one league. The dashboard displays seasons after navigating into a
league view. This module talks to the GameSheet JSON:API at ``/api/seasons?league_id={league_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`). When filters are provided, the BFF API
endpoint is used instead.
"""

from __future__ import annotations

from datetime import datetime
from typing import Any

from pydantic import BaseModel, Field

from gamesheet_sdk.common import errors
from gamesheet_sdk.common.constants import BFF_API_BASE_URL
from gamesheet_sdk.common.exceptions import AuthenticationError, GameSheetError
from gamesheet_sdk.common.session import Session
from gamesheet_sdk.common.shared import JSONAPI_HEADERS
from gamesheet_sdk.common.shared.constants import (
    FIELD_DESC_PARENT_ASSOCIATION_ID,
    FIELD_DESC_PARENT_LEAGUE_ID,
)

_ENDPOINT = "/api/seasons"


[docs] class Season(BaseModel): """A single season. Maps the ``data[*]`` items in the JSON:API response of ``GET /api/seasons?league_id={id}`` to a flat typed model. """ id: str = Field(description="Season identifier (string in JSON:API).") league_id: str = Field(description=FIELD_DESC_PARENT_LEAGUE_ID) title: str = Field(description="Display name of the season.") created_at: datetime = Field(description="When the season was created.") updated_at: datetime = Field(description="Last time the season was updated.")
[docs] class SeasonDetail(BaseModel): """Detailed information about a specific season. Maps the ``data`` object in the JSON:API response of ``GET /api/seasons/{id}`` to a flat typed model, including all attributes and relationships. """ id: str = Field(description="Season identifier (string in JSON:API).") association_id: str = Field(description=FIELD_DESC_PARENT_ASSOCIATION_ID) league_id: str = Field(description=FIELD_DESC_PARENT_LEAGUE_ID) title: str = Field(description="Display name of the season.") external_id: str = Field(description="External UUID identifier for the season.") start_date: str = Field(description="Season start date (ISO format).") end_date: str = Field(description="Season end date (ISO format).") sport: str = Field(description="Sport type (e.g., 'hockey').") stats_year: str = Field(description="Statistics year label (e.g., '2026-2027').") live_scoring_mode: str | None = Field( default=None, description="Live scoring visibility mode (e.g., 'public', 'private').", ) player_of_the_game: str | None = Field( default=None, description="Player of the game configuration.", ) flagging_criteria: dict[str, Any] = Field( default_factory=dict, description="Criteria for flagging events (e.g., penalties, notes).", ) flagged_penalties: list[str] = Field( default_factory=list, description="List of penalty codes that are flagged.", ) settings: dict[str, Any] = Field( default_factory=dict, description="Season-specific settings and configuration.", ) vendor_data: dict[str, Any] = Field( default_factory=dict, description="Vendor-specific metadata.", ) created_at: datetime = Field(description="When the season was created.") updated_at: datetime = Field(description="Last time the season was updated.")
def _parse(item: dict[str, Any]) -> Season: """Flatten a JSON:API resource object into a :class:`Season`.""" attrs = item.get("attributes", {}) # Extract league_id from relationships league_id = item.get("relationships", {}).get("league", {}).get("data", {}).get("id", "") return Season( id=item["id"], league_id=league_id, **attrs, ) def _parse_detail(data: dict[str, Any]) -> SeasonDetail: """Flatten a detailed JSON:API resource object into a :class:`SeasonDetail`.""" attrs = data.get("attributes", {}) relationships = data.get("relationships", {}) # Extract IDs from relationships association_id = relationships.get("association", {}).get("data", {}).get("id", "") league_id = relationships.get("league", {}).get("data", {}).get("id", "") return SeasonDetail( id=data["id"], association_id=association_id, league_id=league_id, **attrs, ) def _parse_bff_season(item: dict[str, Any], league_id: str) -> Season: """Parse a season from the BFF API response. Note: The BFF API does not return created_at/updated_at, so we use the current time. """ now = datetime.now() return Season( id=str(item["id"]), league_id=league_id, title=item.get("title", ""), created_at=now, updated_at=now, ) # pylint: disable-next=too-many-branches def _list_seasons_bff( session: Session, league_id: str, *, starts_after: str | None, ends_before: str | None, status: str | None, stats_year: str | None, title: str | None, ) -> list[Season]: """List seasons using the BFF API with filters.""" params: dict[str, str] = { "page[number]": "1", "page[size]": "1000", "sort": "-start_date", } if starts_after: params["filter[starts_after]"] = starts_after if ends_before: params["filter[ends_before]"] = ends_before if status: params["filter[status]"] = status if stats_year: params["filter[stats_year]"] = stats_year if title: params["filter[title]"] = title url = f"{BFF_API_BASE_URL}/leagues/{league_id}/seasons" response = session.get(url, params=params) if response.status_code == 401: raise AuthenticationError(errors.ERROR_MSG_401_EXPIRED) if response.status_code == 404: _err_msg = errors.ERROR_MSG_404_LEAGUE.format(league_id=league_id) raise GameSheetError(_err_msg) if response.status_code >= 400: _err_msg = errors.ERROR_MSG_HTTP_GET.format( endpoint=url, status_code=response.status_code, text=repr(response.text[:200]), ) raise GameSheetError(_err_msg) body: dict[str, Any] = response.json() if body.get("status") != "success": status = body.get("status") _err_msg = errors.ERROR_MSG_BFF_NON_SUCCESS_SIMPLE.format(status=status) raise GameSheetError(_err_msg) data = body.get("data", []) if isinstance(data, dict): items = data.get("items", []) else: items = data return [_parse_bff_season(item, league_id) for item in items]
[docs] def list_seasons( session: Session, league_id: str, *, starts_after: str | None = None, ends_before: str | None = None, status: str | None = None, stats_year: str | None = None, title: str | None = None, ) -> list[Season]: """Return every season in the specified league. 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. When filters are provided, the BFF API endpoint is used. Otherwise, the JSON:API endpoint is used and results are filtered client-side. :param session: An authenticated :class:`Session`. :type session: Session :param league_id: The league identifier whose seasons to list. :type league_id: str :param starts_after: Optional filter for seasons starting after this date (ISO format: YYYY-MM-DD). :type starts_after: str | None :param ends_before: Optional filter for seasons ending before this date (ISO format: YYYY-MM-DD). :type ends_before: str | None :param status: Optional status filter (e.g., 'archived', 'active', 'all'). :type status: str | None :param stats_year: Optional statistics year filter (e.g., '2026-2027'). :type stats_year: str | None :param title: Optional title search filter (free-form text). :type title: str | None :returns: A list of :class:`Season`, in the order the server returned them. The list may be empty if the league has no seasons. :rtype: list[Season] :raises AuthenticationError: If the server returns 401 (the bearer is missing, malformed, or expired -- run ``gamesheet-admin login`` to refresh). :raises GameSheetError: For any other non-2xx response. """ has_filters = any([starts_after, ends_before, status, stats_year, title]) if has_filters: return _list_seasons_bff( session, league_id, starts_after=starts_after, ends_before=ends_before, status=status, stats_year=stats_year, title=title, ) response = session.get( _ENDPOINT, headers=JSONAPI_HEADERS, ) if response.status_code == 401: raise AuthenticationError(errors.ERROR_MSG_401_EXPIRED) if response.status_code >= 400: _err_msg = errors.ERROR_MSG_HTTP_GET.format( endpoint=_ENDPOINT, status_code=response.status_code, text=repr(response.text[:200]), ) raise GameSheetError(_err_msg) body: dict[str, Any] = response.json() # Parse all seasons and filter to only those belonging to the requested league all_seasons = [_parse(item) for item in body.get("data", [])] return [s for s in all_seasons if s.league_id == league_id]
[docs] def get_season(session: Session, season_id: str) -> SeasonDetail: """Return detailed information about a specific 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 retrieve. :type season_id: str :returns: A :class:`SeasonDetail` with complete season information. :rtype: SeasonDetail :raises AuthenticationError: If the server returns 401 (the bearer is missing, malformed, or expired -- run ``gamesheet-admin login`` to refresh). :raises GameSheetError: For any other non-2xx response (including 404 if the season doesn't exist). """ endpoint = f"{_ENDPOINT}/{season_id}" response = session.get( endpoint, headers=JSONAPI_HEADERS, ) if response.status_code == 401: raise AuthenticationError(errors.ERROR_MSG_401_EXPIRED) if response.status_code == 404: _err_msg = errors.ERROR_MSG_404_SEASON.format(season_id=season_id) raise GameSheetError(_err_msg) if response.status_code >= 400: _err_msg = errors.ERROR_MSG_HTTP_GET.format( endpoint=endpoint, status_code=response.status_code, text=repr(response.text[:200]), ) raise GameSheetError(_err_msg) body: dict[str, Any] = response.json() return _parse_detail(body["data"])