diff --git a/app/web/routes.py b/app/web/routes.py index cd678ca..cc84ebd 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -4,6 +4,7 @@ reached exclusively from the import CLI. """ +from dataclasses import dataclass from typing import Annotated, Any, Literal, get_args from fastapi import APIRouter, Depends, Query, Request, Response @@ -145,6 +146,7 @@ build_nav_links, ) from app.web.selection import ( + TeamOption, build_team_options, build_team_seasons_catalog, select_season, @@ -171,6 +173,21 @@ def _coerce_window(value: object) -> object: RollingWindowParam = Annotated[RollingWindow, BeforeValidator(_coerce_window)] +# The query contract every team-season analytics page accepts. Declared once so +# the eight routes cannot drift apart; FastAPI still validates each parameter. +TeamIdQuery = Annotated[ + int | None, + Query(gt=0, description="MLB team id that has been imported locally."), +] +SeasonQuery = Annotated[ + int | None, + Query(gt=0, description="Season that has been imported for the team."), +] +RollingWindowQuery = Annotated[ + RollingWindowParam, + Query(description="Games in the trailing rolling average."), +] + PLOTLY_BUNDLE_PATH = "/vendor/plotly.min.js" # Club and league marks are fetched by the browser from MLB's public logo # host, keyed by the same team ids the application already stores. They are @@ -204,84 +221,28 @@ def create_router(templates: Jinja2Templates, settings: Settings) -> APIRouter: def index( request: Request, session: Annotated[Session, Depends(get_db_session)], - team_id: Annotated[ - int | None, - Query(gt=0, description="MLB team id that has been imported locally."), - ] = None, - season: Annotated[ - int | None, - Query(gt=0, description="Season that has been imported for the team."), - ] = None, - window: Annotated[ - RollingWindowParam, - Query(description="Games in the trailing rolling average."), - ] = DEFAULT_ROLLING_WINDOW, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render team hitting trends for one persisted team-season.""" - try: - available = list_available_team_seasons(session) - except DatabaseSchemaMissingError as exc: - return _render_schema_error(templates, request, settings, exc) - - teams = build_team_options(available) - context: dict[str, Any] = { - "app_name": settings.app_name, - "teams": teams, - "team_seasons_catalog": build_team_seasons_catalog(teams), - "window_options": ROLLING_WINDOW_OPTIONS, - "selected_window": window, - "selected_team": None, - "selected_season": None, - "import_command": IMPORT_COMMAND, - "plotly_bundle_path": PLOTLY_BUNDLE_PATH, - "mlb_logo_url": MLB_LOGO_URL, - "team_logo_url_prefix": TEAM_LOGO_URL_PREFIX, - "form_action": HITS_PATH, - "nav_links": build_nav_links( - current_path=HITS_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="index.html", context=context - ) - - selected_team = select_team(teams, team_id) - if selected_team is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No games are stored for team id {team_id}. " - "Pick a team that has been imported, or import that team." - ) - return templates.TemplateResponse( - request=request, name="index.html", context=context, status_code=404 - ) - - context["selected_team"] = selected_team - selected_season = select_season(selected_team, season) - if selected_season is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No {season} games are stored for {selected_team.team_name}. " - f"Stored seasons: " - f"{', '.join(str(value) for value in selected_team.seasons)}." - ) - return templates.TemplateResponse( - request=request, name="index.html", context=context, status_code=404 - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=HITS_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=HITS_PATH, + template_name="index.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season games = list_team_season( session, team_id=selected_team.team_id, season=selected_season ) @@ -311,90 +272,28 @@ def index( def strikeouts( request: Request, session: Annotated[Session, Depends(get_db_session)], - team_id: Annotated[ - int | None, - Query(gt=0, description="MLB team id that has been imported locally."), - ] = None, - season: Annotated[ - int | None, - Query(gt=0, description="Season that has been imported for the team."), - ] = None, - window: Annotated[ - RollingWindowParam, - Query(description="Games in the trailing rolling average."), - ] = DEFAULT_ROLLING_WINDOW, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render batting strikeout trends for one persisted team-season.""" - try: - available = list_available_team_seasons(session) - except DatabaseSchemaMissingError as exc: - return _render_schema_error(templates, request, settings, exc) - - teams = build_team_options(available) - context: dict[str, Any] = { - "app_name": settings.app_name, - "teams": teams, - "team_seasons_catalog": build_team_seasons_catalog(teams), - "window_options": ROLLING_WINDOW_OPTIONS, - "selected_window": window, - "selected_team": None, - "selected_season": None, - "import_command": IMPORT_COMMAND, - "plotly_bundle_path": PLOTLY_BUNDLE_PATH, - "mlb_logo_url": MLB_LOGO_URL, - "team_logo_url_prefix": TEAM_LOGO_URL_PREFIX, - "form_action": STRIKEOUTS_PATH, - "nav_links": build_nav_links( - current_path=STRIKEOUTS_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="strikeouts.html", context=context - ) - - selected_team = select_team(teams, team_id) - if selected_team is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No games are stored for team id {team_id}. " - "Pick a team that has been imported, or import that team." - ) - return templates.TemplateResponse( - request=request, - name="strikeouts.html", - context=context, - status_code=404, - ) - - context["selected_team"] = selected_team - selected_season = select_season(selected_team, season) - if selected_season is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No {season} games are stored for {selected_team.team_name}. " - f"Stored seasons: " - f"{', '.join(str(value) for value in selected_team.seasons)}." - ) - return templates.TemplateResponse( - request=request, - name="strikeouts.html", - context=context, - status_code=404, - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=STRIKEOUTS_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=STRIKEOUTS_PATH, + template_name="strikeouts.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season games = list_team_season( session, team_id=selected_team.team_id, season=selected_season ) @@ -459,84 +358,28 @@ def strikeouts( def runs( request: Request, session: Annotated[Session, Depends(get_db_session)], - team_id: Annotated[ - int | None, - Query(gt=0, description="MLB team id that has been imported locally."), - ] = None, - season: Annotated[ - int | None, - Query(gt=0, description="Season that has been imported for the team."), - ] = None, - window: Annotated[ - RollingWindowParam, - Query(description="Games in the trailing rolling average."), - ] = DEFAULT_ROLLING_WINDOW, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render run-scoring trends for one persisted team-season.""" - try: - available = list_available_team_seasons(session) - except DatabaseSchemaMissingError as exc: - return _render_schema_error(templates, request, settings, exc) - - teams = build_team_options(available) - context: dict[str, Any] = { - "app_name": settings.app_name, - "teams": teams, - "team_seasons_catalog": build_team_seasons_catalog(teams), - "window_options": ROLLING_WINDOW_OPTIONS, - "selected_window": window, - "selected_team": None, - "selected_season": None, - "import_command": IMPORT_COMMAND, - "plotly_bundle_path": PLOTLY_BUNDLE_PATH, - "mlb_logo_url": MLB_LOGO_URL, - "team_logo_url_prefix": TEAM_LOGO_URL_PREFIX, - "form_action": RUNS_PATH, - "nav_links": build_nav_links( - current_path=RUNS_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="runs.html", context=context - ) - - selected_team = select_team(teams, team_id) - if selected_team is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No games are stored for team id {team_id}. " - "Pick a team that has been imported, or import that team." - ) - return templates.TemplateResponse( - request=request, name="runs.html", context=context, status_code=404 - ) - - context["selected_team"] = selected_team - selected_season = select_season(selected_team, season) - if selected_season is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No {season} games are stored for {selected_team.team_name}. " - f"Stored seasons: " - f"{', '.join(str(value) for value in selected_team.seasons)}." - ) - return templates.TemplateResponse( - request=request, name="runs.html", context=context, status_code=404 - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=RUNS_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=RUNS_PATH, + template_name="runs.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season games = list_team_season( session, team_id=selected_team.team_id, season=selected_season ) @@ -564,90 +407,28 @@ def runs( def baserunners( request: Request, session: Annotated[Session, Depends(get_db_session)], - team_id: Annotated[ - int | None, - Query(gt=0, description="MLB team id that has been imported locally."), - ] = None, - season: Annotated[ - int | None, - Query(gt=0, description="Season that has been imported for the team."), - ] = None, - window: Annotated[ - RollingWindowParam, - Query(description="Games in the trailing rolling average."), - ] = DEFAULT_ROLLING_WINDOW, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render baserunners trends for one persisted team-season.""" - try: - available = list_available_team_seasons(session) - except DatabaseSchemaMissingError as exc: - return _render_schema_error(templates, request, settings, exc) - - teams = build_team_options(available) - context: dict[str, Any] = { - "app_name": settings.app_name, - "teams": teams, - "team_seasons_catalog": build_team_seasons_catalog(teams), - "window_options": ROLLING_WINDOW_OPTIONS, - "selected_window": window, - "selected_team": None, - "selected_season": None, - "import_command": IMPORT_COMMAND, - "plotly_bundle_path": PLOTLY_BUNDLE_PATH, - "mlb_logo_url": MLB_LOGO_URL, - "team_logo_url_prefix": TEAM_LOGO_URL_PREFIX, - "form_action": BASERUNNERS_PATH, - "nav_links": build_nav_links( - current_path=BASERUNNERS_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="baserunners.html", context=context - ) - - selected_team = select_team(teams, team_id) - if selected_team is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No games are stored for team id {team_id}. " - "Pick a team that has been imported, or import that team." - ) - return templates.TemplateResponse( - request=request, - name="baserunners.html", - context=context, - status_code=404, - ) - - context["selected_team"] = selected_team - selected_season = select_season(selected_team, season) - if selected_season is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No {season} games are stored for {selected_team.team_name}. " - f"Stored seasons: " - f"{', '.join(str(value) for value in selected_team.seasons)}." - ) - return templates.TemplateResponse( - request=request, - name="baserunners.html", - context=context, - status_code=404, - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=BASERUNNERS_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=BASERUNNERS_PATH, + template_name="baserunners.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season games = list_team_season( session, team_id=selected_team.team_id, season=selected_season ) @@ -712,90 +493,28 @@ def baserunners( def run_differential( request: Request, session: Annotated[Session, Depends(get_db_session)], - team_id: Annotated[ - int | None, - Query(gt=0, description="MLB team id that has been imported locally."), - ] = None, - season: Annotated[ - int | None, - Query(gt=0, description="Season that has been imported for the team."), - ] = None, - window: Annotated[ - RollingWindowParam, - Query(description="Games in the trailing rolling average."), - ] = DEFAULT_ROLLING_WINDOW, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render run differential and Pythagorean record for one team-season.""" - try: - available = list_available_team_seasons(session) - except DatabaseSchemaMissingError as exc: - return _render_schema_error(templates, request, settings, exc) - - teams = build_team_options(available) - context: dict[str, Any] = { - "app_name": settings.app_name, - "teams": teams, - "team_seasons_catalog": build_team_seasons_catalog(teams), - "window_options": ROLLING_WINDOW_OPTIONS, - "selected_window": window, - "selected_team": None, - "selected_season": None, - "import_command": IMPORT_COMMAND, - "plotly_bundle_path": PLOTLY_BUNDLE_PATH, - "mlb_logo_url": MLB_LOGO_URL, - "team_logo_url_prefix": TEAM_LOGO_URL_PREFIX, - "form_action": RUN_DIFFERENTIAL_PATH, - "nav_links": build_nav_links( - current_path=RUN_DIFFERENTIAL_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="run_differential.html", context=context - ) - - selected_team = select_team(teams, team_id) - if selected_team is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No games are stored for team id {team_id}. " - "Pick a team that has been imported, or import that team." - ) - return templates.TemplateResponse( - request=request, - name="run_differential.html", - context=context, - status_code=404, - ) - - context["selected_team"] = selected_team - selected_season = select_season(selected_team, season) - if selected_season is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No {season} games are stored for {selected_team.team_name}. " - f"Stored seasons: " - f"{', '.join(str(value) for value in selected_team.seasons)}." - ) - return templates.TemplateResponse( - request=request, - name="run_differential.html", - context=context, - status_code=404, - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=RUN_DIFFERENTIAL_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=RUN_DIFFERENTIAL_PATH, + template_name="run_differential.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season run_results = list_team_season_run_results( session, team_id=selected_team.team_id, season=selected_season ) @@ -852,90 +571,28 @@ def run_differential( def hits_allowed( request: Request, session: Annotated[Session, Depends(get_db_session)], - team_id: Annotated[ - int | None, - Query(gt=0, description="MLB team id that has been imported locally."), - ] = None, - season: Annotated[ - int | None, - Query(gt=0, description="Season that has been imported for the team."), - ] = None, - window: Annotated[ - RollingWindowParam, - Query(description="Games in the trailing rolling average."), - ] = DEFAULT_ROLLING_WINDOW, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render hits-allowed trends for one persisted team-season.""" - try: - available = list_available_team_seasons(session) - except DatabaseSchemaMissingError as exc: - return _render_schema_error(templates, request, settings, exc) - - teams = build_team_options(available) - context: dict[str, Any] = { - "app_name": settings.app_name, - "teams": teams, - "team_seasons_catalog": build_team_seasons_catalog(teams), - "window_options": ROLLING_WINDOW_OPTIONS, - "selected_window": window, - "selected_team": None, - "selected_season": None, - "import_command": IMPORT_COMMAND, - "plotly_bundle_path": PLOTLY_BUNDLE_PATH, - "mlb_logo_url": MLB_LOGO_URL, - "team_logo_url_prefix": TEAM_LOGO_URL_PREFIX, - "form_action": HITS_ALLOWED_PATH, - "nav_links": build_nav_links( - current_path=HITS_ALLOWED_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="hits_allowed.html", context=context - ) - - selected_team = select_team(teams, team_id) - if selected_team is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No games are stored for team id {team_id}. " - "Pick a team that has been imported, or import that team." - ) - return templates.TemplateResponse( - request=request, - name="hits_allowed.html", - context=context, - status_code=404, - ) - - context["selected_team"] = selected_team - selected_season = select_season(selected_team, season) - if selected_season is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No {season} games are stored for {selected_team.team_name}. " - f"Stored seasons: " - f"{', '.join(str(value) for value in selected_team.seasons)}." - ) - return templates.TemplateResponse( - request=request, - name="hits_allowed.html", - context=context, - status_code=404, - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=HITS_ALLOWED_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=HITS_ALLOWED_PATH, + template_name="hits_allowed.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season games = list_team_season_pitching( session, team_id=selected_team.team_id, season=selected_season ) @@ -987,90 +644,28 @@ def hits_allowed( def pitching( request: Request, session: Annotated[Session, Depends(get_db_session)], - team_id: Annotated[ - int | None, - Query(gt=0, description="MLB team id that has been imported locally."), - ] = None, - season: Annotated[ - int | None, - Query(gt=0, description="Season that has been imported for the team."), - ] = None, - window: Annotated[ - RollingWindowParam, - Query(description="Games in the trailing rolling average."), - ] = DEFAULT_ROLLING_WINDOW, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render pitching trends for one persisted team-season.""" - try: - available = list_available_team_seasons(session) - except DatabaseSchemaMissingError as exc: - return _render_schema_error(templates, request, settings, exc) - - teams = build_team_options(available) - context: dict[str, Any] = { - "app_name": settings.app_name, - "teams": teams, - "team_seasons_catalog": build_team_seasons_catalog(teams), - "window_options": ROLLING_WINDOW_OPTIONS, - "selected_window": window, - "selected_team": None, - "selected_season": None, - "import_command": IMPORT_COMMAND, - "plotly_bundle_path": PLOTLY_BUNDLE_PATH, - "mlb_logo_url": MLB_LOGO_URL, - "team_logo_url_prefix": TEAM_LOGO_URL_PREFIX, - "form_action": PITCHING_PATH, - "nav_links": build_nav_links( - current_path=PITCHING_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="pitching.html", context=context - ) - - selected_team = select_team(teams, team_id) - if selected_team is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No games are stored for team id {team_id}. " - "Pick a team that has been imported, or import that team." - ) - return templates.TemplateResponse( - request=request, - name="pitching.html", - context=context, - status_code=404, - ) - - context["selected_team"] = selected_team - selected_season = select_season(selected_team, season) - if selected_season is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No {season} games are stored for {selected_team.team_name}. " - f"Stored seasons: " - f"{', '.join(str(value) for value in selected_team.seasons)}." - ) - return templates.TemplateResponse( - request=request, - name="pitching.html", - context=context, - status_code=404, - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=PITCHING_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=PITCHING_PATH, + template_name="pitching.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season games = list_team_season_pitching( session, team_id=selected_team.team_id, season=selected_season ) @@ -1122,90 +717,28 @@ def pitching( def hitting_comparison( request: Request, session: Annotated[Session, Depends(get_db_session)], - team_id: Annotated[ - int | None, - Query(gt=0, description="MLB team id that has been imported locally."), - ] = None, - season: Annotated[ - int | None, - Query(gt=0, description="Season that has been imported for the team."), - ] = None, - window: Annotated[ - RollingWindowParam, - Query(description="Games in the trailing rolling average."), - ] = DEFAULT_ROLLING_WINDOW, + team_id: TeamIdQuery = None, + season: SeasonQuery = None, + window: RollingWindowQuery = DEFAULT_ROLLING_WINDOW, ) -> Response: """Render normalized rolling Hits/Game and batting K/Game trends.""" - try: - available = list_available_team_seasons(session) - except DatabaseSchemaMissingError as exc: - return _render_schema_error(templates, request, settings, exc) - - teams = build_team_options(available) - context: dict[str, Any] = { - "app_name": settings.app_name, - "teams": teams, - "team_seasons_catalog": build_team_seasons_catalog(teams), - "window_options": ROLLING_WINDOW_OPTIONS, - "selected_window": window, - "selected_team": None, - "selected_season": None, - "import_command": IMPORT_COMMAND, - "plotly_bundle_path": PLOTLY_BUNDLE_PATH, - "mlb_logo_url": MLB_LOGO_URL, - "team_logo_url_prefix": TEAM_LOGO_URL_PREFIX, - "form_action": COMPARISON_PATH, - "nav_links": build_nav_links( - current_path=COMPARISON_PATH, - team_id=team_id, - season=season, - window=window, - ), - } - - if not teams: - context["state"] = "empty" - return templates.TemplateResponse( - request=request, name="comparison.html", context=context - ) - - selected_team = select_team(teams, team_id) - if selected_team is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No games are stored for team id {team_id}. " - "Pick a team that has been imported, or import that team." - ) - return templates.TemplateResponse( - request=request, - name="comparison.html", - context=context, - status_code=404, - ) - - context["selected_team"] = selected_team - selected_season = select_season(selected_team, season) - if selected_season is None: - context["state"] = "not_found" - context["not_found_message"] = ( - f"No {season} games are stored for {selected_team.team_name}. " - f"Stored seasons: " - f"{', '.join(str(value) for value in selected_team.seasons)}." - ) - return templates.TemplateResponse( - request=request, - name="comparison.html", - context=context, - status_code=404, - ) - - context["selected_season"] = selected_season - context["nav_links"] = build_nav_links( - current_path=COMPARISON_PATH, - team_id=selected_team.team_id, - season=selected_season, + prepared = _prepare_team_season_page( + templates, + settings, + request, + session, + path=COMPARISON_PATH, + template_name="comparison.html", + team_id=team_id, + season=season, window=window, ) + if isinstance(prepared, Response): + return prepared + + context = prepared.context + selected_team = prepared.team + selected_season = prepared.season games = list_team_season( session, team_id=selected_team.team_id, season=selected_season ) @@ -1490,6 +1023,110 @@ def _load_league_baserunners_comparison( return compare_team_baserunners_to_league(analysis, league) +@dataclass(frozen=True) +class PreparedTeamSeasonPage: + """A team-season selection that resolved to stored data. + + ``context`` holds the shared template values, with the navigation already + rebuilt from the resolved team and season. The route adds its own records, + analysis, and state to it. Nothing metric-specific is loaded here. + """ + + context: dict[str, Any] + team: TeamOption + season: int + + +def _prepare_team_season_page( + templates: Jinja2Templates, + settings: Settings, + request: Request, + session: Session, + *, + path: str, + template_name: str, + team_id: int | None, + season: int | None, + window: RollingWindow, +) -> PreparedTeamSeasonPage | Response: + """Resolve the team-season every analytics page needs before its own data. + + Returns the rendered page instead when there is nothing to analyse: the + schema is missing (503), nothing is stored yet, or the requested team or + season is not stored (404). Those terminal pages keep the *requested* + values in the navigation, so the reader's link is carried forward as they + typed it. Only a selection that resolves has its navigation rebuilt from + the *resolved* values, which is how defaults become shareable links. + """ + try: + available = list_available_team_seasons(session) + except DatabaseSchemaMissingError as exc: + return _render_schema_error(templates, request, settings, exc) + + teams = build_team_options(available) + context: dict[str, Any] = { + "app_name": settings.app_name, + "teams": teams, + "team_seasons_catalog": build_team_seasons_catalog(teams), + "window_options": ROLLING_WINDOW_OPTIONS, + "selected_window": window, + "selected_team": None, + "selected_season": None, + "import_command": IMPORT_COMMAND, + "plotly_bundle_path": PLOTLY_BUNDLE_PATH, + "mlb_logo_url": MLB_LOGO_URL, + "team_logo_url_prefix": TEAM_LOGO_URL_PREFIX, + "form_action": path, + "nav_links": build_nav_links( + current_path=path, + team_id=team_id, + season=season, + window=window, + ), + } + + if not teams: + context["state"] = "empty" + return templates.TemplateResponse( + request=request, name=template_name, context=context + ) + + selected_team = select_team(teams, team_id) + if selected_team is None: + context["state"] = "not_found" + context["not_found_message"] = ( + f"No games are stored for team id {team_id}. " + "Pick a team that has been imported, or import that team." + ) + return templates.TemplateResponse( + request=request, name=template_name, context=context, status_code=404 + ) + + context["selected_team"] = selected_team + selected_season = select_season(selected_team, season) + if selected_season is None: + context["state"] = "not_found" + context["not_found_message"] = ( + f"No {season} games are stored for {selected_team.team_name}. " + f"Stored seasons: " + f"{', '.join(str(value) for value in selected_team.seasons)}." + ) + return templates.TemplateResponse( + request=request, name=template_name, context=context, status_code=404 + ) + + context["selected_season"] = selected_season + context["nav_links"] = build_nav_links( + current_path=path, + team_id=selected_team.team_id, + season=selected_season, + window=window, + ) + return PreparedTeamSeasonPage( + context=context, team=selected_team, season=selected_season + ) + + def _render_comparison_unavailable( templates: Jinja2Templates, request: Request, diff --git a/docs/architecture-deep-dive.md b/docs/architecture-deep-dive.md index 0b96d16..f028a6c 100644 --- a/docs/architecture-deep-dive.md +++ b/docs/architecture-deep-dive.md @@ -234,23 +234,47 @@ scattered inline: ## 5. Web routes — `app/web/routes.py` -Four page routes (`/`, `/strikeouts`, `/runs`, `/comparison`) share one -skeleton, repeated per route rather than factored into a shared helper -(again, a deliberate legibility tradeoff — each route's docstring and -error branches read as one linear story): +When this was written, four page routes (`/`, `/strikeouts`, `/runs`, +`/comparison`) shared one skeleton, repeated per route rather than +factored into a shared helper (a deliberate legibility tradeoff — each +route's docstring and error branches read as one linear story). + +**Update (2026-09, issue #44):** by eight analytics routes (adding +`/baserunners`, `/run-differential`, `/pitching`, `/hits-allowed`) the +copied prefix had become the part most likely to drift, so it was +extracted. The split is at the point where a route first needs its own +records: ``` +── shared: _prepare_team_season_page(...) ──────────────────────────────── +TeamIdQuery / SeasonQuery / RollingWindowQuery → FastAPI validation (422) list_available_team_seasons(session) → DatabaseSchemaMissingError → 503 page telling you to run alembic build_team_options(available) → empty? render "no data yet" state select_team(teams, team_id) → not found? → 404 page naming stored teams select_season(selected_team, season) → not found? → 404 page naming stored seasons -list_team_season(session, ...) → rows for the chosen team-season -build_team_*_analysis(games, window) → pure analytics call +rebuild nav links from resolved values → PreparedTeamSeasonPage(context, team, season) +── explicit in each route ──────────────────────────────────────────────── +list_team_season(session, ...) → rows for the chosen team-season (or pitching / run results) +build_team_*_analysis(games, window) → pure analytics call; route-specific 409 / unavailable states _load_league_*_comparison(session, …) → None unless coverage is COMPLETE build_team_*_figure(analysis, league) → Plotly figure render + return TemplateResponse ``` +The helper returns either the prepared selection or an already-rendered +terminal page, so each route reads `prepared = ...; if isinstance(prepared, +Response): return prepared` and then continues with its own story. It +deliberately takes no callbacks or metric configuration: record loading, +missing-data handling, league gating, and cards stay in the route, because +those are exactly where the pages differ (run differential's opponent +pairing, pitching's separate table, comparison's layered unavailability). + +One subtlety the helper preserves: terminal pages (empty, unknown team, +unknown season) build navigation from the *requested* query values, and +only a resolved selection rebuilds it from the *resolved* values. That is +why the navigation is built twice. `tests/test_web_page_scaffolding.py` +pins this shared contract across all eight routes. + Every branch point returns a real, styled page (via `app/web/templates/error.html` or the page's own template with a `state` context flag: `empty` / `not_found` / `missing_strikeouts` / `unavailable` diff --git a/tests/test_web_page_scaffolding.py b/tests/test_web_page_scaffolding.py new file mode 100644 index 0000000..4531624 --- /dev/null +++ b/tests/test_web_page_scaffolding.py @@ -0,0 +1,457 @@ +"""Characterization tests for the team/season page lifecycle every analytics +route shares before it loads any metric-specific records. + +Each analytics page resolves a team, a season, and a rolling window from the +query string, handles the empty-database and not-found states, and carries the +reader's selection through the form and the navigation. That behavior is the +same on every page, so it is pinned here once per route. What each page does +after the selection is resolved (loading its own records, missing-data states, +league context, charts, cards) is covered by that route's own test module. + +The key contract: terminal states (empty, unknown team, unknown season) keep +the *requested* query values in the navigation, while a resolved selection +rebuilds the navigation from the *resolved* values. + +Every test builds a freshly migrated SQLite database, so each test makes all +the assertions for one route and one state rather than one assertion apiece. +""" + +import html +import re +from collections.abc import Callable, Generator, Iterator +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlencode + +import pytest +import requests +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.database.engine import build_engine, build_session_factory +from app.database.repositories import upsert_team_season, upsert_team_season_pitching +from app.main import create_app +from app.schemas.games import TeamGameBattingLine +from app.web.dependencies import get_db_session +from tests.factories import ( + MARINERS_ID, + MARINERS_NAME, + TWINS_ID, + TWINS_NAME, + make_batting_line, + make_pitching_season, + make_season, +) + +BROWSER_HEADERS = {"accept": "text/html,application/xhtml+xml"} +JSON_HEADERS = {"accept": "application/json"} +GAMES = 20 + +# --- Page definitions --------------------------------------------------------- + + +@dataclass(frozen=True) +class AnalyticsPage: + path: str + heading: str + nav_label: str + + +PAGES = ( + AnalyticsPage("/", "Team Hitting Trends", "Hits"), + AnalyticsPage("/strikeouts", "Team Batting Strikeout Trends", "Batting Strikeouts"), + AnalyticsPage("/runs", "Team Run Scoring Trends", "Runs"), + AnalyticsPage("/baserunners", "Team Baserunners Trends", "Baserunners"), + AnalyticsPage("/run-differential", "Team Run Differential", "Run Differential"), + AnalyticsPage("/pitching", "Team Pitching Trends", "Pitching"), + AnalyticsPage("/hits-allowed", "Team Hits Allowed Trends", "Hits Allowed"), + AnalyticsPage("/comparison", "Team Hitting Trends Comparison", "Comparison"), +) +PAGE_PATHS = tuple(page.path for page in PAGES) + +# What each page shows once the seeded selection reaches its own data. The +# comparison page has no COMPLETE league coverage in this fixture, so its +# route-specific "unavailable" state is the expected outcome there. +SEEDED_OUTCOMES = { + "/": "Seattle Mariners — Hits per Game", + "/strikeouts": "Seattle Mariners — Batting Strikeouts per Game", + "/runs": "Seattle Mariners — Runs Scored per Game", + "/baserunners": "Seattle Mariners — Baserunners per Game", + "/run-differential": "Seattle Mariners — Run Differential per Game", + "/pitching": "Seattle Mariners — Pitches per Game", + "/hits-allowed": "Seattle Mariners — Hits Allowed per Game", + "/comparison": "Normalized comparison unavailable", +} + +# Values FastAPI must reject before any route code runs, with the query +# parameter the 422 detail must name. +INVALID_QUERIES = ( + ("team_id=0", "team_id"), + ("team_id=-3", "team_id"), + ("team_id=banana", "team_id"), + ("season=0", "season"), + ("season=-2025", "season"), + ("season=20.5", "season"), + ("window=7", "window"), + ("window=banana", "window"), +) + + +def page_for(path: str) -> AnalyticsPage: + return next(page for page in PAGES if page.path == path) + + +# --- Navigation assertion helpers --------------------------------------------- + +_NAV_LINK_PATTERN = re.compile( + r'(?P' +) + + +@dataclass(frozen=True) +class RenderedNavLink: + label: str + href: str + is_current: bool + + +def rendered_nav_links(body: str) -> list[RenderedNavLink]: + links = [ + RenderedNavLink( + label=match.group("label"), + href=html.unescape(match.group("href")), + is_current=match.group("current") is not None, + ) + for match in _NAV_LINK_PATTERN.finditer(body) + ] + assert len(links) == len(PAGES), "the page did not render the full navigation" + return links + + +def assert_navigation(body: str, *, current: AnalyticsPage, query: str) -> None: + """Every link carries ``query``, and only ``current`` is marked current.""" + suffix = f"?{query}" if query else "" + links = rendered_nav_links(body) + assert [link.href for link in links] == [f"{page.path}{suffix}" for page in PAGES] + assert [link.label for link in links if link.is_current] == [current.nav_label] + assert body.count('aria-current="page"') == 1 + + +def expected_query(team_id: int | None, season: int | None, window: int) -> str: + selection: dict[str, int] = {} + if team_id is not None: + selection["team_id"] = team_id + if season is not None: + selection["season"] = season + selection["window"] = window + return urlencode(selection) + + +# --- Fixtures ----------------------------------------------------------------- + + +@pytest.fixture +def session_factory(migrated_db_path: Path) -> Generator[Callable[[], Session]]: + engine = build_engine(f"sqlite:///{migrated_db_path}") + factory = build_session_factory(engine) + try: + yield factory + finally: + engine.dispose() + + +@pytest.fixture +def client(session_factory: Callable[[], Session]) -> TestClient: + app = create_app() + + def override_session() -> Iterator[Session]: + session = session_factory() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db_session] = override_session + return TestClient(app) + + +@pytest.fixture +def unmigrated_client(tmp_path: Path) -> Generator[TestClient]: + engine = build_engine(f"sqlite:///{tmp_path / 'unmigrated.db'}") + factory = build_session_factory(engine) + app = create_app() + + def override_session() -> Iterator[Session]: + session = factory() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db_session] = override_session + try: + yield TestClient(app) + finally: + engine.dispose() + + +def opponent_rows(lines: list[TeamGameBattingLine]) -> list[TeamGameBattingLine]: + """The Twins' side of each Seattle game, as a league-wide import stores it.""" + return [ + make_batting_line( + game_pk=line.game_pk, + game_date=line.game_date, + season=line.season, + team_id=TWINS_ID, + team_name=TWINS_NAME, + opponent_id=MARINERS_ID, + opponent_name=MARINERS_NAME, + home_away="away" if line.home_away == "home" else "home", + runs=3, + strikeouts=9, + base_on_balls=2, + hit_by_pitch=0, + ) + for line in lines + ] + + +@pytest.fixture +def seeded(session_factory: Callable[[], Session]) -> None: + """Store enough for every page to reach its selected-team data. + + Seattle holds 2024 and 2025. Each season has batting lines with every + optional column known, pitching lines, and the Twins' rows for the same + games so run differential can pair them. The Twins are a second stored + team, so the default-team rule has a real choice to make. + """ + mariners_2025 = make_season( + hits=[8] * GAMES, + strikeouts=[7] * GAMES, + base_on_balls=[3] * GAMES, + hit_by_pitch=[1] * GAMES, + runs=[4] * GAMES, + ) + mariners_2024 = make_season( + hits=[6] * GAMES, + season=2024, + strikeouts=[8] * GAMES, + base_on_balls=[2] * GAMES, + hit_by_pitch=[0] * GAMES, + ) + session = session_factory() + try: + for mariners in (mariners_2025, mariners_2024): + upsert_team_season(session, lines=mariners) + upsert_team_season(session, lines=opponent_rows(mariners)) + upsert_team_season_pitching( + session, lines=make_pitching_season([3] * GAMES, season=2025) + ) + upsert_team_season_pitching( + session, lines=make_pitching_season([3] * GAMES, season=2024) + ) + session.commit() + finally: + session.close() + + +# --- Empty database (terminal state: requested values) ------------------------ + + +@pytest.mark.parametrize("path", PAGE_PATHS) +def test_empty_database_renders_the_empty_state_with_requested_values( + client: TestClient, path: str +) -> None: + page = page_for(path) + + response = client.get(path) + assert response.status_code == 200 + body = response.text + assert f"

{page.heading}

" in body + assert "No team data has been imported yet" in body + assert "--team-id 136 --season 2025" in body + # No stored team means no selector form to fill in. + assert ' None: + """Web requests read persisted data only; every MLB entry point fails loudly.""" + + def fail(*args: object, **kwargs: object) -> None: + raise AssertionError("The web layer must not reach the MLB Stats API") + + monkeypatch.setattr(requests.Session, "request", fail) + monkeypatch.setattr("mlbstatsapi.Mlb.__init__", fail) + monkeypatch.setattr("mlbstatsapi.AsyncMlb.__init__", fail) + monkeypatch.setattr("app.services.team_game_logs.get_team_game_batting_lines", fail) + monkeypatch.setattr("app.services.league_teams.discover_mlb_teams", fail) + monkeypatch.setattr( + "app.services.league_season_ingestion.ingest_league_season", fail + ) + + response = client.get( + f"{path}?team_id=136&season=2025&window=15", headers=BROWSER_HEADERS + ) + assert response.status_code == 200 + assert SEEDED_OUTCOMES[path] in response.text