From 7f2b388e7a3bb441008f7ec9a15db87398879d86 Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Sat, 2 May 2026 20:57:09 -0400 Subject: [PATCH] setup workos --- .dockerignore | 16 + .env.example | 21 ++ Dockerfile | 25 ++ docker-compose.yml | 52 +++ docker/web-entrypoint.sh | 33 ++ pipelines/common.py | 21 -- pipelines/containers/web.py | 197 +++++++++++ pipelines/web/auth.py | 202 +++++++++++ pipelines/web/main.py | 224 ++++++------ pipelines/web/static/styles.css | 162 ++++++++- pipelines/web/templates/admin.html | 36 ++ pipelines/web/templates/base.html | 20 +- pipelines/web/templates/dashboard.html | 1 + pipelines/web/templates/home.html | 59 ++++ pipelines/web/templates/partials/_chart.html | 2 +- .../web/templates/partials/_dashboard.html | 6 +- .../templates/partials/_issue_filters.html | 10 +- .../web/templates/partials/_rankings.html | 4 +- pyproject.toml | 10 +- tests/test_web_routes.py | 327 ++++++++++++++++++ 20 files changed, 1286 insertions(+), 142 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/web-entrypoint.sh create mode 100644 pipelines/containers/web.py create mode 100644 pipelines/web/auth.py create mode 100644 pipelines/web/templates/admin.html create mode 100644 pipelines/web/templates/home.html create mode 100644 tests/test_web_routes.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d97b9eb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.pytest_cache +.ruff_cache +__pycache__ +*.pyc +*.pyo +*.pyd +.venv +venv +env +ENV +.env +dist +build +htmlcov +coverage.xml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0edaf0b --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Postgres used by the FastAPI app +DATA_SCIENCE_DEV_DB=your_existing_database +DATA_SCIENCE_DEV_HOST=your_existing_postgres_host +DATA_SCIENCE_DEV_PORT=5432 +DATA_SCIENCE_DEV_USER=your_existing_postgres_user +DATA_SCIENCE_DEV_PASSWORD=your_existing_postgres_password + +# WorkOS AuthKit +WORKOS_API_KEY=sk_test_your_workos_api_key +WORKOS_CLIENT_ID=client_your_workos_client_id +WORKOS_COOKIE_PASSWORD=replace_with_a_long_random_secret_at_least_32_chars +WORKOS_ORGANIZATION_ID=org_your_workspace_org_id +WORKOS_REDIRECT_URI=http://localhost:8000/callback +WORKOS_LOGOUT_REDIRECT_URI=http://localhost:8000/ +WORKOS_SESSION_COOKIE_NAME=workos_session + +# Optional local port overrides for Docker Compose +WEB_PUBLISHED_PORT=8000 + +# Only used if you explicitly start the optional local Postgres profile +POSTGRES_PUBLISHED_PORT=5432 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..066a5e8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libpq5 \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml /app/pyproject.toml +COPY __init__.py /app/__init__.py +COPY alembic /app/alembic +COPY database_cli.py /app/database_cli.py +COPY pipelines /app/pipelines +COPY docker /app/docker + +RUN pip install --no-cache-dir . + +RUN chmod +x /app/docker/web-entrypoint.sh + +EXPOSE 8000 + +CMD ["/app/docker/web-entrypoint.sh"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..29e3cae --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +services: + db: + image: postgres:16 + profiles: ["localdb"] + restart: unless-stopped + environment: + POSTGRES_DB: ${DATA_SCIENCE_DEV_DB:-nornsight} + POSTGRES_USER: ${DATA_SCIENCE_DEV_USER:-nornsight} + POSTGRES_PASSWORD: ${DATA_SCIENCE_DEV_PASSWORD:-nornsight} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${DATA_SCIENCE_DEV_USER:-nornsight} -d ${DATA_SCIENCE_DEV_DB:-nornsight}", + ] + interval: 5s + timeout: 5s + retries: 20 + start_period: 5s + ports: + - "${POSTGRES_PUBLISHED_PORT:-5432}:5432" + + web: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + dns: + - ${WEB_DNS_1:-1.1.1.1} + - ${WEB_DNS_2:-8.8.8.8} + environment: + DATA_SCIENCE_DEV_DB: ${DATA_SCIENCE_DEV_DB} + DATA_SCIENCE_DEV_HOST: ${DATA_SCIENCE_DEV_HOST} + DATA_SCIENCE_DEV_PORT: ${DATA_SCIENCE_DEV_PORT} + DATA_SCIENCE_DEV_USER: ${DATA_SCIENCE_DEV_USER} + DATA_SCIENCE_DEV_PASSWORD: ${DATA_SCIENCE_DEV_PASSWORD} + WORKOS_API_KEY: ${WORKOS_API_KEY} + WORKOS_CLIENT_ID: ${WORKOS_CLIENT_ID} + WORKOS_COOKIE_PASSWORD: ${WORKOS_COOKIE_PASSWORD} + WORKOS_ORGANIZATION_ID: ${WORKOS_ORGANIZATION_ID} + WORKOS_REDIRECT_URI: ${WORKOS_REDIRECT_URI:-http://localhost:8000/callback} + WORKOS_LOGOUT_REDIRECT_URI: ${WORKOS_LOGOUT_REDIRECT_URI:-http://localhost:8000/} + WORKOS_SESSION_COOKIE_NAME: ${WORKOS_SESSION_COOKIE_NAME:-workos_session} + UVICORN_HOST: 0.0.0.0 + UVICORN_PORT: 8000 + ports: + - "${WEB_PUBLISHED_PORT:-8000}:8000" + +volumes: + postgres_data: diff --git a/docker/web-entrypoint.sh b/docker/web-entrypoint.sh new file mode 100644 index 0000000..9fca8ee --- /dev/null +++ b/docker/web-entrypoint.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env sh +set -eu + +python - <<'PY' +import os +import time + +import psycopg + +db = os.environ["DATA_SCIENCE_DEV_DB"] +host = os.environ["DATA_SCIENCE_DEV_HOST"] +port = os.environ["DATA_SCIENCE_DEV_PORT"] +user = os.environ["DATA_SCIENCE_DEV_USER"] +password = os.environ.get("DATA_SCIENCE_DEV_PASSWORD", "") + +dsn = f"dbname={db} host={host} port={port} user={user} password={password}" + +for attempt in range(60): + try: + with psycopg.connect(dsn) as conn: + with conn.cursor() as cur: + cur.execute("CREATE SCHEMA IF NOT EXISTS main") + conn.commit() + break + except psycopg.OperationalError: + if attempt == 59: + raise + time.sleep(1) +PY + +python /app/database_cli.py data_science_dev upgrade head + +exec uvicorn pipelines.web.main:app --host "${UVICORN_HOST:-0.0.0.0}" --port "${UVICORN_PORT:-8000}" diff --git a/pipelines/common.py b/pipelines/common.py index 1e717dc..af0482b 100644 --- a/pipelines/common.py +++ b/pipelines/common.py @@ -5,10 +5,8 @@ from __future__ import annotations import logging import sys from datetime import UTC, datetime -from os import getenv from subprocess import PIPE, Popen -from apprise import Apprise logger = logging.getLogger(__name__) @@ -47,25 +45,6 @@ def bash_wrapper(command: str) -> tuple[str, int]: return output.decode(), process.returncode -def signal_alert(body: str, title: str = "") -> None: - """Send a signal alert. - - Args: - body (str): The body of the alert. - title (str, optional): The title of the alert. Defaults to "". - """ - apprise_client = Apprise() - - from_phone = getenv("SIGNAL_ALERT_FROM_PHONE") - to_phone = getenv("SIGNAL_ALERT_TO_PHONE") - if not from_phone or not to_phone: - logger.info("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set") - return - - apprise_client.add(f"signal://localhost:8989/{from_phone}/{to_phone}") - - apprise_client.notify(title=title, body=body) - def utcnow() -> datetime: """Get the current UTC time.""" diff --git a/pipelines/containers/web.py b/pipelines/containers/web.py new file mode 100644 index 0000000..762ad18 --- /dev/null +++ b/pipelines/containers/web.py @@ -0,0 +1,197 @@ +"""Docker container lifecycle management for the web app stack.""" + +from __future__ import annotations + +import logging +import os +import subprocess +from pathlib import Path +from typing import Annotated, Literal + +import typer + +logger = logging.getLogger(__name__) + +REPO_DIR = Path(__file__).resolve().parents[2] +COMPOSE_FILE = REPO_DIR / "docker-compose.yml" +EnvTarget = Literal["all", "web", "db"] +REQUIRED_WORKOS_ENV_VARS = ( + "WORKOS_API_KEY", + "WORKOS_CLIENT_ID", + "WORKOS_COOKIE_PASSWORD", + "WORKOS_ORGANIZATION_ID", +) + +app = typer.Typer(help="Web stack container management.") + + +def _compose_command(*args: str) -> list[str]: + """Build a docker compose command for the repo-local stack.""" + return ["docker", "compose", "-f", str(COMPOSE_FILE), *args] + + +def _run_compose( + *args: str, + capture_output: bool = False, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + """Run docker compose in the repository root.""" + result = subprocess.run( + _compose_command(*args), + cwd=REPO_DIR, + text=True, + capture_output=capture_output, + check=False, + ) + if check and result.returncode != 0: + detail = result.stderr.strip() if result.stderr else f"exit code {result.returncode}" + raise RuntimeError(f"docker compose {' '.join(args)} failed: {detail}") + return result + + +def _validate_workos_env() -> None: + """Ensure the web app has the WorkOS env vars it needs before startup.""" + missing = [name for name in REQUIRED_WORKOS_ENV_VARS if not os.getenv(name)] + if missing: + message = ( + "Missing required WorkOS environment variables: " + + ", ".join(missing) + + ". Populate .env before running the web stack." + ) + raise RuntimeError(message) + + cookie_password = os.getenv("WORKOS_COOKIE_PASSWORD", "") + if len(cookie_password) < 32: + raise RuntimeError("WORKOS_COOKIE_PASSWORD must be at least 32 characters long.") + + +def build_stack() -> None: + """Build the web app image.""" + logger.info("Building web image from %s", COMPOSE_FILE) + _run_compose("build", "web", capture_output=False) + logger.info("Web image built") + + +def _validate_database_env() -> None: + """Ensure the web app has the database env vars it needs before startup.""" + required = ( + "DATA_SCIENCE_DEV_DB", + "DATA_SCIENCE_DEV_HOST", + "DATA_SCIENCE_DEV_PORT", + "DATA_SCIENCE_DEV_USER", + ) + missing = [name for name in required if not os.getenv(name)] + if missing: + message = ( + "Missing required database environment variables: " + + ", ".join(missing) + + ". Populate .env before running the web stack." + ) + raise RuntimeError(message) + + +def start_stack( + *, build: bool = False, detach: bool = False, with_local_db: bool = False +) -> None: + """Start the web stack, using the existing DB by default.""" + _validate_workos_env() + _validate_database_env() + command = ["up"] + if build: + command.append("--build") + if detach: + command.append("-d") + if with_local_db: + command.extend(["--profile", "localdb", "db", "web"]) + else: + command.append("web") + logger.info( + "Starting web stack%s", + " with local Postgres" if with_local_db else " against existing Postgres", + ) + _run_compose(*command, capture_output=False) + + +def stop_stack(*, drop_volumes: bool = False) -> None: + """Stop and remove the web stack.""" + logger.info("Stopping web stack") + command = ["down"] + if drop_volumes: + command.append("--volumes") + _run_compose(*command, capture_output=False) + + +def logs_stack(*, target: EnvTarget = "all", follow: bool = False, tail: int = 100) -> None: + """Show docker compose logs for the web stack.""" + command = ["logs", "--tail", str(tail)] + if follow: + command.append("--follow") + if target != "all": + command.append(target) + _run_compose(*command, capture_output=False) + + +@app.command() +def build( + log_level: Annotated[str, typer.Option(help="Log level")] = "INFO", +) -> None: + """Build the web Docker image.""" + logging.basicConfig( + level=log_level, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + build_stack() + + +@app.command() +def run( + build: Annotated[ + bool, typer.Option(help="Rebuild the web image before starting the stack") + ] = False, + detach: Annotated[ + bool, typer.Option(help="Start the stack in the background") + ] = False, + with_local_db: Annotated[ + bool, typer.Option(help="Also start the optional local Postgres container") + ] = False, + log_level: Annotated[str, typer.Option(help="Log level")] = "INFO", +) -> None: + """Run the web + Postgres stack.""" + logging.basicConfig( + level=log_level, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + start_stack(build=build, detach=detach, with_local_db=with_local_db) + + +@app.command() +def stop( + drop_volumes: Annotated[ + bool, typer.Option(help="Also delete the Postgres volume") + ] = False, +) -> None: + """Stop and remove the web stack.""" + stop_stack(drop_volumes=drop_volumes) + + +@app.command() +def logs( + target: Annotated[ + EnvTarget, typer.Option(help="Which service logs to show") + ] = "all", + follow: Annotated[ + bool, typer.Option(help="Follow logs until interrupted") + ] = False, + tail: Annotated[int, typer.Option(help="How many recent lines to show")] = 100, +) -> None: + """Show recent logs from the web stack.""" + logs_stack(target=target, follow=follow, tail=tail) + + +def cli() -> None: + """Typer entry point.""" + app() + + +if __name__ == "__main__": + cli() diff --git a/pipelines/web/auth.py b/pipelines/web/auth.py new file mode 100644 index 0000000..092427c --- /dev/null +++ b/pipelines/web/auth.py @@ -0,0 +1,202 @@ +"""WorkOS AuthKit helpers for the FastAPI web app.""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +from os import getenv +from typing import Any + +from fastapi import Request +from workos import WorkOSClient +from workos.session import seal_session_from_auth_response + + +@dataclass(frozen=True) +class AuthConfig: + """Runtime configuration for WorkOS AuthKit.""" + + api_key: str + client_id: str + cookie_password: str + redirect_uri: str + logout_redirect_uri: str + session_cookie_name: str + organization_id: str + + @property + def secure_cookies(self) -> bool: + return self.redirect_uri.startswith("https://") + + +@dataclass(frozen=True) +class AuthSession: + """Normalized auth session passed through the app.""" + + user_id: str + email: str + first_name: str | None + last_name: str | None + role_slugs: set[str] + organization_id: str | None + raw_user: Any + raw_session: Any + + @property + def display_name(self) -> str: + parts = [part for part in (self.first_name, self.last_name) if part] + return " ".join(parts) if parts else self.email + + @property + def is_admin(self) -> bool: + return "admin" in self.role_slugs + + +@dataclass(frozen=True) +class CallbackResult: + """Result of exchanging a WorkOS callback code.""" + + sealed_session: str + next_path: str + + +def safe_next_path(value: str | None, default: str = "/dashboard") -> str: + """Allow only local relative redirect targets.""" + if value and value.startswith("/") and not value.startswith("//"): + return value + return default + + +def build_authorization_url(next_path: str) -> str: + """Build the WorkOS hosted login URL.""" + config = get_auth_config() + return get_workos_client().user_management.get_authorization_url( + provider="authkit", + redirect_uri=config.redirect_uri, + state=safe_next_path(next_path), + organization_id=config.organization_id, + ) + + +def exchange_code(request: Request) -> CallbackResult: + """Exchange a WorkOS callback code for a sealed session cookie value.""" + code = request.query_params.get("code") + if not code: + raise ValueError("Missing authentication code.") + + config = get_auth_config() + auth_response = get_workos_client().user_management.authenticate_with_code( + code=code, + ip_address=_request_ip(request), + user_agent=request.headers.get("user-agent"), + ) + sealed_session = seal_session_from_auth_response( + access_token=auth_response.access_token, + refresh_token=auth_response.refresh_token, + user=auth_response.user.to_dict(), + impersonator=auth_response.impersonator.to_dict() + if auth_response.impersonator is not None + else None, + cookie_password=config.cookie_password, + ) + + return CallbackResult( + sealed_session=sealed_session, + next_path=safe_next_path(request.query_params.get("state")), + ) + + +def get_current_session(request: Request) -> AuthSession | None: + """Load the current signed-in WorkOS session from the sealed cookie.""" + cookie_name = getenv("WORKOS_SESSION_COOKIE_NAME", "workos_session") + sealed_session = request.cookies.get(cookie_name) + if not sealed_session: + return None + + config = get_auth_config() + session = get_workos_client().user_management.load_sealed_session( + session_data=sealed_session, + cookie_password=config.cookie_password, + ) + auth_response = session.authenticate() + if not getattr(auth_response, "authenticated", False): + return None + + user = auth_response.user or {} + organization_id = getattr(auth_response, "organization_id", None) + if config.organization_id and organization_id != config.organization_id: + return None + role_slugs = set(getattr(auth_response, "roles", None) or []) + role = getattr(auth_response, "role", None) + if role: + role_slugs.add(role) + + return AuthSession( + user_id=_user_field(user, "id") or "", + email=_user_field(user, "email") or "", + first_name=_user_field(user, "first_name"), + last_name=_user_field(user, "last_name"), + role_slugs=role_slugs, + organization_id=organization_id, + raw_user=user, + raw_session=auth_response, + ) + + +def get_logout_url(request: Request) -> str: + """Return the WorkOS logout URL for the current sealed session.""" + config = get_auth_config() + sealed_session = request.cookies.get(config.session_cookie_name) + if not sealed_session: + return config.logout_redirect_uri + + session = get_workos_client().user_management.load_sealed_session( + session_data=sealed_session, + cookie_password=config.cookie_password, + ) + return session.get_logout_url(return_to=config.logout_redirect_uri) + + +@lru_cache(maxsize=1) +def get_auth_config() -> AuthConfig: + """Load and validate WorkOS environment configuration.""" + values = { + "WORKOS_API_KEY": getenv("WORKOS_API_KEY"), + "WORKOS_CLIENT_ID": getenv("WORKOS_CLIENT_ID"), + "WORKOS_COOKIE_PASSWORD": getenv("WORKOS_COOKIE_PASSWORD"), + "WORKOS_ORGANIZATION_ID": getenv("WORKOS_ORGANIZATION_ID"), + } + missing = [name for name, value in values.items() if not value] + if missing: + raise RuntimeError( + "Missing WorkOS configuration: " + ", ".join(sorted(missing)) + ) + + return AuthConfig( + api_key=values["WORKOS_API_KEY"] or "", + client_id=values["WORKOS_CLIENT_ID"] or "", + cookie_password=values["WORKOS_COOKIE_PASSWORD"] or "", + redirect_uri=getenv("WORKOS_REDIRECT_URI", "http://localhost:8000/callback"), + logout_redirect_uri=getenv("WORKOS_LOGOUT_REDIRECT_URI", "http://localhost:8000/"), + session_cookie_name=getenv("WORKOS_SESSION_COOKIE_NAME", "workos_session"), + organization_id=values["WORKOS_ORGANIZATION_ID"] or "", + ) + + +@lru_cache(maxsize=1) +def get_workos_client(): + """Create and cache the WorkOS SDK client.""" + config = get_auth_config() + return WorkOSClient(api_key=config.api_key, client_id=config.client_id) + + +def _request_ip(request: Request) -> str | None: + if request.client is None: + return None + return request.client.host + + +def _user_field(user: Any, key: str) -> Any: + if isinstance(user, dict): + return user.get(key) + return getattr(user, key, None) diff --git a/pipelines/web/main.py b/pipelines/web/main.py index 1944a54..8d51841 100644 --- a/pipelines/web/main.py +++ b/pipelines/web/main.py @@ -4,20 +4,17 @@ from __future__ import annotations from contextlib import asynccontextmanager from dataclasses import dataclass -import hashlib -import hmac +import logging from os import getenv from pathlib import Path -import secrets from typing import Any -from urllib.parse import parse_qs from fastapi import Depends, FastAPI, HTTPException, Request, Response, status from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from pipelines.web import repository +from pipelines.web import auth, repository from pipelines.web.db import session_scope, validate_database_connection from pipelines.web.repository import Chamber, RankingResult from pipelines.web.scoring import normalize_issues @@ -28,10 +25,7 @@ TEMPLATES_DIR = BASE_DIR / "templates" STATIC_DIR = BASE_DIR / "static" templates = Jinja2Templates(directory=TEMPLATES_DIR) -ADMIN_USERNAME = "admin" -ADMIN_PASSWORD = "admin" -SESSION_COOKIE = "nornsight_admin" -SESSION_SECRET = "nornsight-local-dev-session-secret" +logger = logging.getLogger(__name__) @asynccontextmanager @@ -62,72 +56,69 @@ def healthz() -> str: return "ok" -@app.get("/login", response_class=HTMLResponse) -def login(request: Request) -> Response: - """Render the integrated login page.""" - next_path = _safe_next_path(request.query_params.get("next")) - if _authenticated_user(request) is not None: - return RedirectResponse(next_path, status_code=status.HTTP_303_SEE_OTHER) +@app.get("/", response_class=HTMLResponse) +def home(request: Request) -> Response: + """Render the public home page.""" + current_user = auth.get_current_session(request) return templates.TemplateResponse( request, - "login.html", + "home.html", { - "error": "", - "is_authenticated": False, - "show_primary_nav": False, - "next_path": next_path, - "username": "", + **_auth_context(current_user), + "auth_error": request.query_params.get("auth_error") == "1", }, ) -@app.post("/login", response_class=HTMLResponse) -async def login_submit(request: Request) -> Response: - """Authenticate the hard-coded admin user and set a session cookie.""" - form = parse_qs((await request.body()).decode()) - username = form.get("username", [""])[0] - password = form.get("password", [""])[0] - next_path = _safe_next_path(form.get("next", [request.query_params.get("next")])[0]) +@app.get("/login") +def login(request: Request) -> Response: + """Start the WorkOS hosted login flow.""" + next_path = auth.safe_next_path(request.query_params.get("next")) + current_user = auth.get_current_session(request) + if current_user is not None: + return RedirectResponse(next_path, status_code=status.HTTP_303_SEE_OTHER) + return RedirectResponse( + auth.build_authorization_url(next_path), + status_code=status.HTTP_303_SEE_OTHER, + ) - username_ok = secrets.compare_digest(username, ADMIN_USERNAME) - password_ok = secrets.compare_digest(password, ADMIN_PASSWORD) - if not (username_ok and password_ok): - return templates.TemplateResponse( - request, - "login.html", - { - "error": "Invalid username or password.", - "is_authenticated": False, - "show_primary_nav": False, - "next_path": next_path, - "username": username, - }, - status_code=status.HTTP_401_UNAUTHORIZED, - ) - response = RedirectResponse(next_path, status_code=status.HTTP_303_SEE_OTHER) +@app.get("/callback") +def callback(request: Request) -> Response: + """Exchange the WorkOS code for a sealed session cookie.""" + try: + result = auth.exchange_code(request) + except Exception: + logger.exception("WorkOS callback exchange failed.") + response = RedirectResponse("/?auth_error=1", status_code=status.HTTP_303_SEE_OTHER) + _delete_auth_cookie(response) + return response + + config = auth.get_auth_config() + response = RedirectResponse(result.next_path, status_code=status.HTTP_303_SEE_OTHER) response.set_cookie( - SESSION_COOKIE, - _sign_session(username), + config.session_cookie_name, + result.sealed_session, httponly=True, samesite="lax", + secure=config.secure_cookies, ) return response -@app.get("/logout") -def logout() -> Response: - """Clear the local admin session.""" - response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER) - response.delete_cookie(SESSION_COOKIE) +@app.post("/logout") +def logout(request: Request) -> Response: + """End the WorkOS session and clear the local sealed session cookie.""" + response = RedirectResponse(auth.get_logout_url(request), status_code=status.HTTP_303_SEE_OTHER) + _delete_auth_cookie(response) return response -def require_admin(request: Request) -> str: - """Redirect unauthenticated users to the in-site login page.""" - username = _authenticated_user(request) - if username is not None: - return username +def require_user(request: Request) -> auth.AuthSession: + """Redirect unauthenticated users to the WorkOS sign-in flow.""" + current_user = auth.get_current_session(request) + if current_user is not None: + return current_user next_path = request.url.path if request.url.query: next_path = f"{next_path}?{request.url.query}" @@ -138,87 +129,64 @@ def require_admin(request: Request) -> str: ) -def _authenticated_user(request: Request) -> str | None: - token = request.cookies.get(SESSION_COOKIE) - if token is None: - return None - try: - username, signature = token.split(":", 1) - except ValueError: - return None - if username != ADMIN_USERNAME: - return None - expected = _session_signature(username) - if secrets.compare_digest(signature, expected): - return username - return None +def require_admin(current_user: auth.AuthSession = Depends(require_user)) -> auth.AuthSession: + """Restrict a route to WorkOS users with the admin role.""" + if current_user.is_admin: + return current_user + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required.") -def _sign_session(username: str) -> str: - return f"{username}:{_session_signature(username)}" - - -def _session_signature(username: str) -> str: - return hmac.new( - SESSION_SECRET.encode(), - username.encode(), - hashlib.sha256, - ).hexdigest() - - -def _safe_next_path(value: str | None) -> str: - if value and value.startswith("/") and not value.startswith("//"): - return value - return "/" - - -@app.get("/", response_class=HTMLResponse) -def dashboard(request: Request, _: str = Depends(require_admin)) -> Response: +@app.get("/dashboard", response_class=HTMLResponse) +def dashboard( + request: Request, current_user: auth.AuthSession = Depends(require_user) +) -> Response: """Render the full dashboard page.""" - context = _dashboard_context(request) + context = {**_auth_context(current_user), **_dashboard_context(request)} if request.headers.get("hx-request") == "true": return templates.TemplateResponse(request, "partials/_dashboard.html", context) return templates.TemplateResponse(request, "dashboard.html", context) @app.get("/partials/dashboard", response_class=HTMLResponse) -def dashboard_partial(request: Request, _: str = Depends(require_admin)) -> Response: +def dashboard_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response: """Render the filter-dependent dashboard body.""" context = _dashboard_context(request) return templates.TemplateResponse(request, "partials/_dashboard.html", context) @app.get("/partials/issues", response_class=HTMLResponse) -def issues_partial(request: Request, _: str = Depends(require_admin)) -> Response: +def issues_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response: """Render only issue filters.""" context = _dashboard_context(request) return templates.TemplateResponse(request, "partials/_issue_filters.html", context) @app.get("/partials/rankings", response_class=HTMLResponse) -def rankings_partial(request: Request, _: str = Depends(require_admin)) -> Response: +def rankings_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response: """Render only ranking panels.""" context = _dashboard_context(request) return templates.TemplateResponse(request, "partials/_rankings.html", context) @app.get("/partials/chart", response_class=HTMLResponse) -def chart_partial(request: Request, _: str = Depends(require_admin)) -> Response: +def chart_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response: """Render only the SVG chart panel.""" context = _dashboard_context(request) return templates.TemplateResponse(request, "partials/_chart.html", context) @app.get("/legislators", response_class=HTMLResponse) -def legislators(request: Request, _: str = Depends(require_admin)) -> Response: +def legislators( + request: Request, current_user: auth.AuthSession = Depends(require_user) +) -> Response: """Render the legislator profile/search page.""" - context = _legislators_context(request) + context = {**_auth_context(current_user), **_legislators_context(request)} return templates.TemplateResponse(request, "legislators.html", context) @app.get("/partials/legislator-suggestions", response_class=HTMLResponse) def legislator_suggestions_partial( - request: Request, _: str = Depends(require_admin) + request: Request, _: auth.AuthSession = Depends(require_user) ) -> Response: """Render legislator search suggestions for the HTMX typeahead.""" query = request.query_params.get("q", "").strip() @@ -238,12 +206,29 @@ def legislator_suggestions_partial( @app.get("/compare", response_class=HTMLResponse) -def compare(request: Request, _: str = Depends(require_admin)) -> Response: +def compare( + request: Request, current_user: auth.AuthSession = Depends(require_user) +) -> Response: """Render the legislator radar comparison page.""" - context = _compare_context(request) + context = {**_auth_context(current_user), **_compare_context(request)} return templates.TemplateResponse(request, "compare.html", context) +@app.get("/admin", response_class=HTMLResponse) +def admin_page( + request: Request, current_user: auth.AuthSession = Depends(require_admin) +) -> Response: + """Render the admin-only placeholder page.""" + return templates.TemplateResponse( + request, + "admin.html", + { + **_auth_context(current_user), + "organization_id": auth.get_auth_config().organization_id, + }, + ) + + def _dashboard_context(request: Request) -> dict[str, Any]: state = _parse_state(request) base_context: dict[str, Any] = { @@ -263,6 +248,7 @@ def _dashboard_context(request: Request) -> dict[str, Any]: "has_scores": False, "empty_message": "", "build_url": _build_url, + "build_dashboard_partial_url": _build_dashboard_partial_url, "toggle_compare": _toggle_compare, } with session_scope() as session: @@ -520,10 +506,29 @@ def _build_url( for legislator_id in chosen_compare: params.append(("compare", str(legislator_id))) if not params: - return "/" + return "/dashboard" from urllib.parse import urlencode - return f"/?{urlencode(params, doseq=True)}" + return f"/dashboard?{urlencode(params, doseq=True)}" + + +def _build_dashboard_partial_url( + request: Request, + *, + issues: list[str] | None = None, + chamber: str | None = None, + congress: int | None = None, + compare: list[int] | None = None, +) -> str: + """Return the HTMX endpoint matching the current dashboard query state.""" + dashboard_url = _build_url( + request, + issues=issues, + chamber=chamber, + congress=congress, + compare=compare, + ) + return dashboard_url.replace("/dashboard", "/partials/dashboard", 1) def _toggle_compare(compare: list[int], legislator_id: int) -> list[int]: @@ -587,3 +592,18 @@ def _build_compare_url( if q: params.append(("q", q)) return f"/compare?{urlencode(params, doseq=True)}" if params else "/compare" + + +def _auth_context(current_user: auth.AuthSession | None) -> dict[str, Any]: + """Shared template context for auth-aware navigation.""" + return { + "is_authenticated": current_user is not None, + "is_admin": current_user.is_admin if current_user is not None else False, + "current_user_name": current_user.display_name if current_user is not None else "", + "current_user_email": current_user.email if current_user is not None else "", + } + + +def _delete_auth_cookie(response: Response) -> None: + """Delete the sealed WorkOS session cookie.""" + response.delete_cookie(getenv("WORKOS_SESSION_COOKIE_NAME", "workos_session")) diff --git a/pipelines/web/static/styles.css b/pipelines/web/static/styles.css index a41d6f5..3df6488 100644 --- a/pipelines/web/static/styles.css +++ b/pipelines/web/static/styles.css @@ -152,16 +152,35 @@ a { text-align: left; } +.account-menu-panel form { + margin: 0.2rem 0 0; +} + +.account-email { + color: var(--muted); + display: block; + font-size: 0.84rem; + padding: 0.3rem 0.1rem 0.55rem; +} + .account-menu-panel .sign-out { background: #0d5f53; border-color: #16806f; color: white; } +.account-menu-panel .sign-out, .account-nav .sign-in { background: #0d5f53; border-color: #16806f; + border-radius: 7px; color: white; + cursor: pointer; + display: block; + font: inherit; + min-width: 100%; + padding: 0.55rem 0.8rem; + text-align: left; } .shell { @@ -170,6 +189,140 @@ a { padding: 1.25rem 1.5rem 2rem; } +.home-shell { + padding-top: 2rem; +} + +.hero-panel, +.home-grid, +.admin-meta, +.admin-actions { + display: grid; +} + +.hero-panel { + gap: 1.4rem; + grid-template-columns: minmax(0, 1.5fr) minmax(18rem, 0.85fr); +} + +.hero-panel, +.home-card, +.admin-card { + background: color-mix(in srgb, var(--panel) 88%, transparent); + border: 1px solid var(--line); + border-radius: 18px; +} + +.hero-copy, +.hero-card, +.home-card, +.admin-card { + padding: 1.5rem 1.6rem; +} + +.hero-copy h1 { + font-size: clamp(2.3rem, 4vw, 4.15rem); + letter-spacing: -0.04em; + line-height: 0.96; + margin: 0.2rem 0 0.9rem; + max-width: 12ch; +} + +.hero-text { + color: #c0d3cd; + font-size: 1.02rem; + line-height: 1.65; + margin: 0; + max-width: 60ch; +} + +.hero-actions, +.admin-actions { + gap: 0.75rem; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + margin-top: 1.35rem; +} + +.hero-primary, +.hero-secondary, +.admin-actions a { + border-radius: 999px; + font-weight: 760; + min-height: 2.8rem; + padding: 0.78rem 1.15rem; +} + +.hero-primary { + background: linear-gradient(120deg, #0d5f53, #2fbd9f); + color: white; +} + +.hero-secondary, +.admin-actions a { + border: 1px solid #1d554c; + color: #d0dfdb; +} + +.hero-card h2, +.home-card h2, +.admin-card h2 { + margin-bottom: 0.65rem; +} + +.hero-card ul { + color: #c0d3cd; + line-height: 1.6; + margin: 0; + padding-left: 1.1rem; +} + +.home-grid { + gap: 1rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 1.25rem; +} + +.home-card p, +.admin-card p, +.admin-meta dt { + color: var(--muted); +} + +.auth-notice { + margin-bottom: 1rem; +} + +.admin-card { + margin-top: 1.25rem; +} + +.admin-meta { + gap: 0.85rem; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + margin: 1.2rem 0 1.1rem; +} + +.admin-meta div { + background: rgba(12, 38, 33, 0.78); + border: 1px solid rgba(44, 123, 109, 0.28); + border-radius: 10px; + padding: 0.9rem 1rem; +} + +.admin-meta dt { + font-size: 0.84rem; + margin-bottom: 0.35rem; + text-transform: uppercase; +} + +.admin-meta dd { + margin: 0; +} + .login-shell { align-items: center; display: flex; @@ -1122,7 +1275,9 @@ h2 { .rankings-grid, .topic-panels, .compare-card, - .login-panel { + .login-panel, + .hero-panel, + .home-grid { display: block; } @@ -1212,4 +1367,9 @@ h2 { margin-bottom: 1rem; padding: 0 0 1rem; } + + .hero-card, + .home-card + .home-card { + margin-top: 1rem; + } } diff --git a/pipelines/web/templates/admin.html b/pipelines/web/templates/admin.html new file mode 100644 index 0000000..19fcefa --- /dev/null +++ b/pipelines/web/templates/admin.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}Admin Settings{% endblock %} + +{% block body %} +
+
+
+

Admin settings

+

Admin-only operational controls for the Nornsight workspace.

+
+
+ +
+

WorkOS-managed access

+

+ Invitations, Google access, and role assignments are managed in the WorkOS dashboard. + This page confirms that app-level admin gating is active. +

+
+
+
Workspace organization
+
{{ organization_id }}
+
+
+
Current administrator
+
{{ current_user_email }}
+
+
+ +
+
+{% endblock %} diff --git a/pipelines/web/templates/base.html b/pipelines/web/templates/base.html index 719b180..d6e0a70 100644 --- a/pipelines/web/templates/base.html +++ b/pipelines/web/templates/base.html @@ -15,23 +15,33 @@ {% if show_primary_nav|default(true) %} {% endif %} diff --git a/pipelines/web/templates/dashboard.html b/pipelines/web/templates/dashboard.html index c454cfd..1dca960 100644 --- a/pipelines/web/templates/dashboard.html +++ b/pipelines/web/templates/dashboard.html @@ -10,6 +10,7 @@

US legislative accountability · precomputed legislator topic scores{% if latest_score_year %} through {{ latest_score_year }}{% endif %}

+ {{ current_user_email }} Methodology Data sources Last updated: {{ last_updated.strftime("%b %Y") if last_updated else "Unavailable" }} diff --git a/pipelines/web/templates/home.html b/pipelines/web/templates/home.html new file mode 100644 index 0000000..1403937 --- /dev/null +++ b/pipelines/web/templates/home.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block title %}Nornsight | Legislative Accountability{% endblock %} + +{% block body %} +
+ {% if auth_error %} +
Authentication failed. Try signing in again.
+ {% endif %} + +
+
+

Invite-only access

+

Track legislative behavior with role-aware access and shared WorkOS sign-in.

+

+ Nornsight turns roll-call data into issue-level accountability views for your invited team. + Use the public home page as the front door, then move signed-in users into the dashboard, + legislator search, and comparison tools. +

+
+ {% if is_authenticated %} + Open dashboard + {% if is_admin %} + Admin settings + {% endif %} + {% else %} + Sign in + How access works + {% endif %} +
+
+ + +
+ +
+
+

For invited users

+

View the dashboard, inspect legislator profiles, and compare issue scoring without sharing a local password.

+
+
+

For admins

+

Manage invitations and role assignments in WorkOS while the app enforces role-based route access.

+
+
+

For rollout

+

Authentication is centralized, sessions are sealed, and the old hard-coded admin login is removed.

+
+
+
+{% endblock %} diff --git a/pipelines/web/templates/partials/_chart.html b/pipelines/web/templates/partials/_chart.html index 7328789..485aec0 100644 --- a/pipelines/web/templates/partials/_chart.html +++ b/pipelines/web/templates/partials/_chart.html @@ -2,7 +2,7 @@

Score history{% if selected_issue_label %} — {{ selected_issue_label }}{% endif %}

Clear comparison
diff --git a/pipelines/web/templates/partials/_dashboard.html b/pipelines/web/templates/partials/_dashboard.html index bc7358b..71e5e19 100644 --- a/pipelines/web/templates/partials/_dashboard.html +++ b/pipelines/web/templates/partials/_dashboard.html @@ -3,17 +3,17 @@ diff --git a/pipelines/web/templates/partials/_issue_filters.html b/pipelines/web/templates/partials/_issue_filters.html index 037b617..6dfe0f4 100644 --- a/pipelines/web/templates/partials/_issue_filters.html +++ b/pipelines/web/templates/partials/_issue_filters.html @@ -2,10 +2,10 @@

Issue filters

+ hx-push-url="/dashboard"> {% if congress %} @@ -17,7 +17,7 @@ {{ issue }} × @@ -36,7 +36,7 @@ {% for suggestion in suggestions %} {% if suggestion not in issues %} {{ suggestion }} {% endif %} diff --git a/pipelines/web/templates/partials/_rankings.html b/pipelines/web/templates/partials/_rankings.html index ef6b857..da42e54 100644 --- a/pipelines/web/templates/partials/_rankings.html +++ b/pipelines/web/templates/partials/_rankings.html @@ -10,7 +10,7 @@ {% set next_compare = toggle_compare(compare, row.legislator_id) %}
  • {{ loop.index }} @@ -40,7 +40,7 @@ {% set next_compare = toggle_compare(compare, row.legislator_id) %}
  • {{ loop.index }} diff --git a/pyproject.toml b/pyproject.toml index 4bfb941..05413f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,15 @@ version = "0.1.0" description = "Data science pipeline tools and legislative dashboard." requires-python = ">=3.12" dependencies = [ + "alembic", "fastapi", "httpx", - "uvicorn[standard]", "jinja2", - "sqlalchemy", "psycopg", + "sqlalchemy", + "typer", + "uvicorn[standard]", + "workos", ] [project.optional-dependencies] @@ -20,3 +23,6 @@ test = [ [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] + +[tool.setuptools.packages.find] +include = ["pipelines*"] diff --git a/tests/test_web_routes.py b/tests/test_web_routes.py new file mode 100644 index 0000000..b15f49b --- /dev/null +++ b/tests/test_web_routes.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +from datetime import date + +import pytest +from fastapi.testclient import TestClient + +from pipelines.web import auth, main +from pipelines.web.repository import ( + ChartSeries, + LegislatorOption, + RadarSeries, + RankingResult, + RankingRow, + TimePoint, +) + + +def test_healthz() -> None: + client = TestClient(main.app) + response = client.get("/healthz") + assert response.status_code == 200 + assert response.text == "ok" + + +def test_public_home_page_renders() -> None: + client = TestClient(main.app) + response = client.get("/") + assert response.status_code == 200 + assert "Invite-only access" in response.text + assert "Sign in" in response.text + + +def test_dashboard_redirects_to_login() -> None: + client = TestClient(main.app) + response = client.get("/dashboard?issues=Health", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"].endswith( + "/login?next=%2Fdashboard%3Fissues%3DHealth" + ) + + +def test_other_protected_routes_redirect_when_unauthenticated() -> None: + client = TestClient(main.app) + for path in ["/legislators", "/compare", "/admin"]: + response = client.get(path, follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"].endswith(f"/login?next={path.replace('/', '%2F', 1)}") + + +def test_login_redirects_to_workos(monkeypatch) -> None: + monkeypatch.setattr(main.auth, "get_current_session", lambda request: None) + monkeypatch.setattr( + main.auth, + "build_authorization_url", + lambda next_path: f"https://auth.example/login?state={next_path}", + ) + + client = TestClient(main.app) + response = client.get("/login?next=/compare", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "https://auth.example/login?state=/compare" + + +def test_login_redirects_authenticated_user(monkeypatch) -> None: + monkeypatch.setattr(main.auth, "get_current_session", lambda request: _viewer_session()) + + client = TestClient(main.app) + response = client.get("/login?next=/compare", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/compare" + + +def test_callback_sets_session_cookie(monkeypatch) -> None: + monkeypatch.setattr( + main.auth, + "exchange_code", + lambda request: auth.CallbackResult( + sealed_session="sealed-session-value", next_path="/dashboard" + ), + ) + monkeypatch.setattr(main.auth, "get_auth_config", _fake_auth_config) + + client = TestClient(main.app) + response = client.get("/callback?code=abc&state=/dashboard", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/dashboard" + assert "workos_session=sealed-session-value" in response.headers["set-cookie"] + + +def test_callback_failure_redirects_home_and_clears_cookie(monkeypatch) -> None: + def raise_exchange_error(request): + raise RuntimeError("bad code") + + monkeypatch.setattr(main.auth, "exchange_code", raise_exchange_error) + + client = TestClient(main.app) + response = client.get("/callback?code=bad", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/?auth_error=1" + assert "workos_session=" in response.headers["set-cookie"] + + +def test_logout_redirects_to_workos_and_clears_cookie(monkeypatch) -> None: + monkeypatch.setattr( + main.auth, + "get_logout_url", + lambda request: "https://auth.example/logout", + ) + + client = TestClient(main.app) + response = client.post("/logout", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "https://auth.example/logout" + assert "workos_session=" in response.headers["set-cookie"] + + +def test_dashboard_route_renders_with_stubbed_repository(monkeypatch) -> None: + _patch_authenticated_dashboard(monkeypatch, current_user=_viewer_session()) + + client = TestClient(main.app) + response = client.get("/dashboard?issues=Health&chamber=senate") + assert response.status_code == 200 + assert "Legislative accountability" in response.text + assert "Most supportive" in response.text + assert "viewer@nornsight.test" in response.text + assert "/admin" not in response.text + assert '/partials/dashboard?issues=Health&chamber=house' in response.text + assert "/partials/dashboarddashboard?" not in response.text + + +def test_admin_route_forbids_viewer(monkeypatch) -> None: + monkeypatch.setattr(main.auth, "get_current_session", lambda request: _viewer_session()) + + client = TestClient(main.app) + response = client.get("/admin") + assert response.status_code == 403 + assert response.json()["detail"] == "Admin access required." + + +def test_admin_route_renders_for_admin(monkeypatch) -> None: + monkeypatch.setattr(main.auth, "get_current_session", lambda request: _admin_session()) + monkeypatch.setattr(main.auth, "get_auth_config", _fake_auth_config) + + client = TestClient(main.app) + response = client.get("/admin") + assert response.status_code == 200 + assert "Admin settings" in response.text + assert "admin@nornsight.test" in response.text + assert "org_test_123" in response.text + + +def test_compare_page_renders_for_authenticated_user(monkeypatch) -> None: + monkeypatch.setattr(main.auth, "get_current_session", lambda request: _viewer_session()) + _patch_compare_page_data(monkeypatch) + + client = TestClient(main.app) + response = client.get("/compare") + assert response.status_code == 200 + assert "Compare legislators" in response.text + assert "Sanders, B." in response.text + + +def _viewer_session() -> auth.AuthSession: + return auth.AuthSession( + user_id="user_viewer", + email="viewer@nornsight.test", + first_name="Viewer", + last_name="User", + role_slugs={"viewer"}, + organization_id="org_test_123", + raw_user=None, + raw_session=None, + ) + + +def _admin_session() -> auth.AuthSession: + return auth.AuthSession( + user_id="user_admin", + email="admin@nornsight.test", + first_name="Admin", + last_name="User", + role_slugs={"admin", "viewer"}, + organization_id="org_test_123", + raw_user=None, + raw_session=None, + ) + + +def _fake_auth_config() -> auth.AuthConfig: + return auth.AuthConfig( + api_key="sk_test", + client_id="client_test", + cookie_password="x" * 32, + redirect_uri="http://localhost:8000/callback", + logout_redirect_uri="http://localhost:8000/", + session_cookie_name="workos_session", + organization_id="org_test_123", + ) + + +def _patch_authenticated_dashboard(monkeypatch, *, current_user: auth.AuthSession) -> None: + monkeypatch.setattr(main.auth, "get_current_session", lambda request: current_user) + + class DummySession: + pass + + class DummyScope: + def __enter__(self): + return DummySession() + + def __exit__(self, exc_type, exc, tb): + return False + + rankings = RankingResult( + supportive=[ + RankingRow( + legislator_id=1, + display_name="Sanders, B.", + party="I", + state="VT", + chamber="senate", + score=78.0, + supportive=7, + opposed=2, + ) + ], + opposed=[ + RankingRow( + legislator_id=2, + display_name="Cruz, T.", + party="R", + state="TX", + chamber="senate", + score=22.0, + supportive=2, + opposed=7, + ) + ], + ) + history = [ + ChartSeries( + legislator_id=1, + label="Sanders, B.", + party="I", + state="VT", + points=[TimePoint(year=2024, score=74.0), TimePoint(year=2025, score=78.0)], + ) + ] + + monkeypatch.setattr(main, "session_scope", lambda: DummyScope()) + monkeypatch.setattr(main.repository, "latest_congress", lambda session: 119) + monkeypatch.setattr(main.repository, "has_scores", lambda session: True) + monkeypatch.setattr(main.repository, "latest_score_year", lambda session: 2026) + monkeypatch.setattr( + main.repository, "latest_vote_date", lambda session, congress: date(2026, 1, 15) + ) + monkeypatch.setattr( + main.repository, + "issue_suggestions", + lambda session, congress=None, limit=12: ["Health", "Taxation"], + ) + monkeypatch.setattr( + main.repository, + "get_rankings", + lambda session, *, issues, chamber, congress: rankings, + ) + monkeypatch.setattr( + main.repository, + "get_score_history", + lambda session, *, issues, chamber, congress, legislator_ids: history, + ) + + +def _patch_compare_page_data(monkeypatch) -> None: + class DummySession: + pass + + class DummyScope: + def __enter__(self): + return DummySession() + + def __exit__(self, exc_type, exc, tb): + return False + + legislator = LegislatorOption( + legislator_id=1, + display_name="Sanders, B.", + party="I", + state="VT", + chamber="senate", + ) + topics = ["Health", "Taxation", "Energy"] + series = [ + RadarSeries( + legislator=legislator, + average_score=77.0, + scores_by_topic={"Health": 82.0, "Taxation": 71.0, "Energy": 78.0}, + ) + ] + + monkeypatch.setattr(main, "session_scope", lambda: DummyScope()) + monkeypatch.setattr( + main.repository, + "get_compare_defaults", + lambda session: ([1], topics), + ) + monkeypatch.setattr( + main.repository, + "get_legislator_options", + lambda session, selected_legislators: [legislator], + ) + monkeypatch.setattr( + main.repository, + "get_compare_radar_series", + lambda session, *, legislator_ids, topics: series, + ) + monkeypatch.setattr( + main.repository, + "search_legislators", + lambda session, query=None, limit=12: [legislator], + ) + monkeypatch.setattr( + main.repository, + "issue_suggestions", + lambda session, congress=None, limit=12: topics, + ) -- 2.54.0