Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f2b388e7a |
@@ -0,0 +1,16 @@
|
|||||||
|
.git
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
env
|
||||||
|
ENV
|
||||||
|
.env
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
htmlcov
|
||||||
|
coverage.xml
|
||||||
@@ -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
|
||||||
+25
@@ -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"]
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
"""move bill text summaries into a child table.
|
|
||||||
|
|
||||||
Revision ID: 4b2e1c9d8f70
|
|
||||||
Revises: b9360b0b0c22
|
|
||||||
Create Date: 2026-05-03 00:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
from pipelines.orm import DataScienceDevBase
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "4b2e1c9d8f70"
|
|
||||||
down_revision: str | None = "b9360b0b0c22"
|
|
||||||
branch_labels: str | Sequence[str] | None = None
|
|
||||||
depends_on: str | Sequence[str] | None = None
|
|
||||||
|
|
||||||
schema = DataScienceDevBase.schema_name
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade."""
|
|
||||||
op.create_table(
|
|
||||||
"bill_text_summary",
|
|
||||||
sa.Column("bill_text_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("summary", sa.String(), nullable=False),
|
|
||||||
sa.Column("summarization_model", sa.String(), nullable=True),
|
|
||||||
sa.Column("summarization_user_prompt_version", sa.String(), nullable=True),
|
|
||||||
sa.Column("summarization_system_prompt_version", sa.String(), nullable=True),
|
|
||||||
sa.Column("id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column(
|
|
||||||
"created",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.text("now()"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"updated",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.text("now()"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
["bill_text_id"],
|
|
||||||
[f"{schema}.bill_text.id"],
|
|
||||||
name=op.f("fk_bill_text_summary_bill_text_id_bill_text"),
|
|
||||||
ondelete="CASCADE",
|
|
||||||
),
|
|
||||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_bill_text_summary")),
|
|
||||||
schema=schema,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_bill_text_summary_bill_text_id",
|
|
||||||
"bill_text_summary",
|
|
||||||
["bill_text_id"],
|
|
||||||
unique=False,
|
|
||||||
schema=schema,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_bill_text_summary_bill_text_id_created",
|
|
||||||
"bill_text_summary",
|
|
||||||
["bill_text_id", "created"],
|
|
||||||
unique=False,
|
|
||||||
schema=schema,
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"bill_text",
|
|
||||||
sa.Column("primary_summary_id", sa.Integer(), nullable=True),
|
|
||||||
schema=schema,
|
|
||||||
)
|
|
||||||
op.create_foreign_key(
|
|
||||||
op.f("fk_bill_text_primary_summary_id_bill_text_summary"),
|
|
||||||
"bill_text",
|
|
||||||
"bill_text_summary",
|
|
||||||
["primary_summary_id"],
|
|
||||||
["id"],
|
|
||||||
source_schema=schema,
|
|
||||||
referent_schema=schema,
|
|
||||||
ondelete="SET NULL",
|
|
||||||
)
|
|
||||||
|
|
||||||
op.execute(
|
|
||||||
sa.text(
|
|
||||||
f"""
|
|
||||||
INSERT INTO {schema}.bill_text_summary (
|
|
||||||
bill_text_id,
|
|
||||||
summary,
|
|
||||||
summarization_model,
|
|
||||||
summarization_user_prompt_version,
|
|
||||||
summarization_system_prompt_version,
|
|
||||||
created,
|
|
||||||
updated
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
bill_text.id,
|
|
||||||
bill_text.summary,
|
|
||||||
bill_text.summarization_model,
|
|
||||||
bill_text.summarization_user_prompt_version,
|
|
||||||
bill_text.summarization_system_prompt_version,
|
|
||||||
COALESCE(bill_text.updated, bill_text.created, now()),
|
|
||||||
COALESCE(bill_text.updated, bill_text.created, now())
|
|
||||||
FROM {schema}.bill_text
|
|
||||||
WHERE bill_text.summary IS NOT NULL
|
|
||||||
AND btrim(bill_text.summary) <> ''
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
op.drop_column("bill_text", "summary", schema=schema)
|
|
||||||
op.drop_column("bill_text", "summarization_model", schema=schema)
|
|
||||||
op.drop_column("bill_text", "summarization_user_prompt_version", schema=schema)
|
|
||||||
op.drop_column("bill_text", "summarization_system_prompt_version", schema=schema)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade."""
|
|
||||||
op.add_column(
|
|
||||||
"bill_text",
|
|
||||||
sa.Column("summarization_system_prompt_version", sa.String(), nullable=True),
|
|
||||||
schema=schema,
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"bill_text",
|
|
||||||
sa.Column("summarization_user_prompt_version", sa.String(), nullable=True),
|
|
||||||
schema=schema,
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"bill_text",
|
|
||||||
sa.Column("summarization_model", sa.String(), nullable=True),
|
|
||||||
schema=schema,
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"bill_text",
|
|
||||||
sa.Column("summary", sa.String(), nullable=True),
|
|
||||||
schema=schema,
|
|
||||||
)
|
|
||||||
|
|
||||||
op.execute(
|
|
||||||
sa.text(
|
|
||||||
f"""
|
|
||||||
WITH ranked AS (
|
|
||||||
SELECT
|
|
||||||
bts.*,
|
|
||||||
row_number() OVER (
|
|
||||||
PARTITION BY bts.bill_text_id
|
|
||||||
ORDER BY bts.created DESC, bts.id DESC
|
|
||||||
) AS rn
|
|
||||||
FROM {schema}.bill_text_summary AS bts
|
|
||||||
),
|
|
||||||
chosen AS (
|
|
||||||
SELECT
|
|
||||||
bill_text.id AS bill_text_id,
|
|
||||||
COALESCE(ps.summary, ls.summary) AS summary,
|
|
||||||
COALESCE(
|
|
||||||
ps.summarization_model,
|
|
||||||
ls.summarization_model
|
|
||||||
) AS summarization_model,
|
|
||||||
COALESCE(
|
|
||||||
ps.summarization_user_prompt_version,
|
|
||||||
ls.summarization_user_prompt_version
|
|
||||||
) AS summarization_user_prompt_version,
|
|
||||||
COALESCE(
|
|
||||||
ps.summarization_system_prompt_version,
|
|
||||||
ls.summarization_system_prompt_version
|
|
||||||
) AS summarization_system_prompt_version
|
|
||||||
FROM {schema}.bill_text
|
|
||||||
LEFT JOIN {schema}.bill_text_summary AS ps
|
|
||||||
ON ps.id = bill_text.primary_summary_id
|
|
||||||
LEFT JOIN ranked AS ls
|
|
||||||
ON ls.bill_text_id = bill_text.id
|
|
||||||
AND ls.rn = 1
|
|
||||||
)
|
|
||||||
UPDATE {schema}.bill_text
|
|
||||||
SET
|
|
||||||
summary = chosen.summary,
|
|
||||||
summarization_model = chosen.summarization_model,
|
|
||||||
summarization_user_prompt_version = chosen.summarization_user_prompt_version,
|
|
||||||
summarization_system_prompt_version = chosen.summarization_system_prompt_version
|
|
||||||
FROM chosen
|
|
||||||
WHERE chosen.bill_text_id = bill_text.id
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
op.drop_constraint(
|
|
||||||
op.f("fk_bill_text_primary_summary_id_bill_text_summary"),
|
|
||||||
"bill_text",
|
|
||||||
schema=schema,
|
|
||||||
type_="foreignkey",
|
|
||||||
)
|
|
||||||
op.drop_column("bill_text", "primary_summary_id", schema=schema)
|
|
||||||
op.drop_index(
|
|
||||||
"ix_bill_text_summary_bill_text_id_created",
|
|
||||||
table_name="bill_text_summary",
|
|
||||||
schema=schema,
|
|
||||||
)
|
|
||||||
op.drop_index(
|
|
||||||
"ix_bill_text_summary_bill_text_id",
|
|
||||||
table_name="bill_text_summary",
|
|
||||||
schema=schema,
|
|
||||||
)
|
|
||||||
op.drop_table("bill_text_summary", schema=schema)
|
|
||||||
@@ -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:
|
||||||
@@ -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}"
|
||||||
@@ -5,10 +5,8 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from os import getenv
|
|
||||||
from subprocess import PIPE, Popen
|
from subprocess import PIPE, Popen
|
||||||
|
|
||||||
from apprise import Apprise
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -47,25 +45,6 @@ def bash_wrapper(command: str) -> tuple[str, int]:
|
|||||||
return output.decode(), process.returncode
|
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:
|
def utcnow() -> datetime:
|
||||||
"""Get the current UTC time."""
|
"""Get the current UTC time."""
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -19,7 +19,6 @@ from pipelines.orm.common import get_postgres_engine
|
|||||||
from pipelines.orm.data_science_dev.congress import (
|
from pipelines.orm.data_science_dev.congress import (
|
||||||
Bill,
|
Bill,
|
||||||
BillText,
|
BillText,
|
||||||
BillTextSummary,
|
|
||||||
BillTopic,
|
BillTopic,
|
||||||
BillTopicPosition,
|
BillTopicPosition,
|
||||||
SubjectType,
|
SubjectType,
|
||||||
@@ -73,19 +72,11 @@ class ExtractedBillTopic:
|
|||||||
def _select_bill_text_for_topic_extraction(bill: Bill) -> BillText | None:
|
def _select_bill_text_for_topic_extraction(bill: Bill) -> BillText | None:
|
||||||
"""Pick one summarized bill_text row from the already-loaded relationship."""
|
"""Pick one summarized bill_text row from the already-loaded relationship."""
|
||||||
for bill_text in bill.bill_texts:
|
for bill_text in bill.bill_texts:
|
||||||
summary_row = bill_text.default_summary()
|
if bill_text.summary and bill_text.summary.strip():
|
||||||
if summary_row and summary_row.summary.strip():
|
|
||||||
return bill_text
|
return bill_text
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _bill_text_has_summary_clause() -> ColumnElement[bool]:
|
|
||||||
"""Return a correlated EXISTS clause for bill texts with at least one summary."""
|
|
||||||
return exists(
|
|
||||||
select(BillTextSummary.id).where(BillTextSummary.bill_text_id == BillText.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_topic_label(value: str) -> str:
|
def normalize_topic_label(value: str) -> str:
|
||||||
"""Normalize a topic label for storage, comparison, and de-duping."""
|
"""Normalize a topic label for storage, comparison, and de-duping."""
|
||||||
normalized = value.strip().strip("\"'")
|
normalized = value.strip().strip("\"'")
|
||||||
@@ -332,7 +323,11 @@ def create_select_bills_for_topic_extraction(
|
|||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
) -> Select[tuple[Bill]]:
|
) -> Select[tuple[Bill]]:
|
||||||
"""Select bill rows that have summarized bill_text rows for topic extraction."""
|
"""Select bill rows that have summarized bill_text rows for topic extraction."""
|
||||||
summarized_text_filters: list[ColumnElement[bool]] = [_bill_text_has_summary_clause()]
|
has_summary = (BillText.summary.is_not(None), BillText.summary != "")
|
||||||
|
summarized_text_filters: list[ColumnElement[bool]] = [
|
||||||
|
BillText.bill_id == Bill.id,
|
||||||
|
*has_summary,
|
||||||
|
]
|
||||||
if with_votes_only:
|
if with_votes_only:
|
||||||
summarized_text_filters.append(
|
summarized_text_filters.append(
|
||||||
exists(
|
exists(
|
||||||
@@ -352,17 +347,11 @@ def create_select_bills_for_topic_extraction(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
summarized_text_exists = exists(
|
summarized_text_exists = exists(select(BillText.id).where(*summarized_text_filters))
|
||||||
select(BillText.id).where(BillText.bill_id == Bill.id, *summarized_text_filters)
|
|
||||||
)
|
|
||||||
bill_text_loader = selectinload(Bill.bill_texts.and_(*summarized_text_filters))
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Bill)
|
select(Bill)
|
||||||
.where(summarized_text_exists)
|
.where(summarized_text_exists)
|
||||||
.options(
|
.options(selectinload(Bill.bill_texts.and_(*summarized_text_filters[1:])))
|
||||||
bill_text_loader.selectinload(BillText.summaries),
|
|
||||||
bill_text_loader.selectinload(BillText.primary_summary),
|
|
||||||
)
|
|
||||||
.order_by(Bill.id)
|
.order_by(Bill.id)
|
||||||
)
|
)
|
||||||
if congress is not None:
|
if congress is not None:
|
||||||
@@ -374,7 +363,7 @@ def create_select_bills_for_topic_extraction(
|
|||||||
select(BillText.id).where(
|
select(BillText.id).where(
|
||||||
BillText.bill_id == Bill.id,
|
BillText.bill_id == Bill.id,
|
||||||
BillText.id.in_(bill_text_ids),
|
BillText.id.in_(bill_text_ids),
|
||||||
*summarized_text_filters,
|
*summarized_text_filters[1:],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
stmt = stmt.where(selected_text_exists)
|
stmt = stmt.where(selected_text_exists)
|
||||||
@@ -427,7 +416,8 @@ def collect_topic_extraction_diagnostics(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
summary_filters = [*bill_text_filters, _bill_text_has_summary_clause()]
|
has_summary = (BillText.summary.is_not(None), BillText.summary != "")
|
||||||
|
summary_filters = [*bill_text_filters, *has_summary]
|
||||||
|
|
||||||
bills_with_summaries = session.scalar(
|
bills_with_summaries = session.scalar(
|
||||||
select(func.count(func.distinct(Bill.id)))
|
select(func.count(func.distinct(Bill.id)))
|
||||||
@@ -617,11 +607,7 @@ def main(
|
|||||||
if bill_text is None:
|
if bill_text is None:
|
||||||
logger.warning("Skipping bill id=%s: no usable summary", bill.id)
|
logger.warning("Skipping bill id=%s: no usable summary", bill.id)
|
||||||
continue
|
continue
|
||||||
summary_row = bill_text.default_summary()
|
summary = bill_text.summary.strip()
|
||||||
if summary_row is None:
|
|
||||||
logger.warning("Skipping bill id=%s: no default summary", bill.id)
|
|
||||||
continue
|
|
||||||
summary = summary_row.summary.strip()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
extracted_topics = extract_topics_for_bill_text(
|
extracted_topics = extract_topics_for_bill_text(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import Annotated, Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import typer
|
import typer
|
||||||
from sqlalchemy import Select, exists, select
|
from sqlalchemy import Select, exists, or_, select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from tiktoken import get_encoding
|
from tiktoken import get_encoding
|
||||||
@@ -20,7 +20,6 @@ from pipelines.orm.common import get_postgres_engine
|
|||||||
from pipelines.orm.data_science_dev.congress import (
|
from pipelines.orm.data_science_dev.congress import (
|
||||||
Bill,
|
Bill,
|
||||||
BillText,
|
BillText,
|
||||||
BillTextSummary,
|
|
||||||
SubjectType,
|
SubjectType,
|
||||||
VoteClassification,
|
VoteClassification,
|
||||||
VoteRelationship,
|
VoteRelationship,
|
||||||
@@ -113,7 +112,7 @@ def summarize_bill_text(
|
|||||||
model: str,
|
model: str,
|
||||||
bill_text: BillText,
|
bill_text: BillText,
|
||||||
summarization_prompts: dict[str, str],
|
summarization_prompts: dict[str, str],
|
||||||
) -> str | None:
|
) -> str:
|
||||||
"""Generate and return a summary for one bill_text row."""
|
"""Generate and return a summary for one bill_text row."""
|
||||||
messages, user_prompt_tokens = build_bill_summary_messages(
|
messages, user_prompt_tokens = build_bill_summary_messages(
|
||||||
bill_text=bill_text,
|
bill_text=bill_text,
|
||||||
@@ -137,21 +136,15 @@ def summarize_bill_text(
|
|||||||
|
|
||||||
def store_bill_summary_result(
|
def store_bill_summary_result(
|
||||||
*,
|
*,
|
||||||
session: Session,
|
|
||||||
bill_text: BillText,
|
bill_text: BillText,
|
||||||
summary: str,
|
summary: str,
|
||||||
model: str,
|
model: str,
|
||||||
) -> BillTextSummary:
|
) -> None:
|
||||||
"""Store a generated summary and the prompt/model metadata that produced it."""
|
"""Store a generated summary and the prompt/model metadata that produced it."""
|
||||||
summary_row = BillTextSummary(
|
bill_text.summary = summary
|
||||||
bill_text=bill_text,
|
bill_text.summarization_model = model
|
||||||
summary=summary,
|
bill_text.summarization_system_prompt_version = "v1.2"
|
||||||
summarization_model=model,
|
bill_text.summarization_user_prompt_version = "v1"
|
||||||
summarization_system_prompt_version="v1.2",
|
|
||||||
summarization_user_prompt_version="v1",
|
|
||||||
)
|
|
||||||
session.add(summary_row)
|
|
||||||
return summary_row
|
|
||||||
|
|
||||||
|
|
||||||
def create_select_bill_texts_for_summarization(
|
def create_select_bill_texts_for_summarization(
|
||||||
@@ -161,7 +154,7 @@ def create_select_bill_texts_for_summarization(
|
|||||||
with_votes_only: bool = False,
|
with_votes_only: bool = False,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
) -> Select[tuple[BillText]]:
|
) -> Select:
|
||||||
"""Select bill_text rows that have source text and need summaries."""
|
"""Select bill_text rows that have source text and need summaries."""
|
||||||
stmt = (
|
stmt = (
|
||||||
select(BillText)
|
select(BillText)
|
||||||
@@ -196,13 +189,7 @@ def create_select_bill_texts_for_summarization(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not force:
|
if not force:
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(or_(BillText.summary.is_(None), BillText.summary == ""))
|
||||||
~exists(
|
|
||||||
select(BillTextSummary.id).where(
|
|
||||||
BillTextSummary.bill_text_id == BillText.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
stmt = stmt.limit(limit)
|
stmt = stmt.limit(limit)
|
||||||
return stmt
|
return stmt
|
||||||
@@ -300,7 +287,6 @@ def main(
|
|||||||
logger.warning("Skipping bill_text id=%s", bill_text.id)
|
logger.warning("Skipping bill_text id=%s", bill_text.id)
|
||||||
continue
|
continue
|
||||||
store_bill_summary_result(
|
store_bill_summary_result(
|
||||||
session=session,
|
|
||||||
bill_text=bill_text,
|
bill_text=bill_text,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
model=model,
|
model=model,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from pipelines.orm.data_science_dev.congress.bill import (
|
|||||||
BillActionRecordedVote,
|
BillActionRecordedVote,
|
||||||
BillRelation,
|
BillRelation,
|
||||||
BillText,
|
BillText,
|
||||||
BillTextSummary,
|
|
||||||
BillTopic,
|
BillTopic,
|
||||||
BillTopicPosition,
|
BillTopicPosition,
|
||||||
)
|
)
|
||||||
@@ -55,7 +54,6 @@ __all__ = [
|
|||||||
"BillActionRecordedVote",
|
"BillActionRecordedVote",
|
||||||
"BillRelation",
|
"BillRelation",
|
||||||
"BillText",
|
"BillText",
|
||||||
"BillTextSummary",
|
|
||||||
"BillTopic",
|
"BillTopic",
|
||||||
"BillTopicPosition",
|
"BillTopicPosition",
|
||||||
"ClassificationMethod",
|
"ClassificationMethod",
|
||||||
|
|||||||
@@ -105,12 +105,13 @@ class BillText(DataScienceDevTableBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
bill_id: Mapped[int] = mapped_column(ForeignKey("main.bill.id", ondelete="CASCADE"))
|
bill_id: Mapped[int] = mapped_column(ForeignKey("main.bill.id", ondelete="CASCADE"))
|
||||||
primary_summary_id: Mapped[int | None] = mapped_column(
|
|
||||||
ForeignKey("main.bill_text_summary.id", ondelete="SET NULL")
|
|
||||||
)
|
|
||||||
version_code: Mapped[str]
|
version_code: Mapped[str]
|
||||||
version_name: Mapped[str | None]
|
version_name: Mapped[str | None]
|
||||||
text_content: Mapped[str | None]
|
text_content: Mapped[str | None]
|
||||||
|
summary: Mapped[str | None]
|
||||||
|
summarization_model: Mapped[str | None]
|
||||||
|
summarization_user_prompt_version: Mapped[str | None]
|
||||||
|
summarization_system_prompt_version: Mapped[str | None]
|
||||||
date: Mapped[date | None]
|
date: Mapped[date | None]
|
||||||
source_datetime_raw: Mapped[str | None]
|
source_datetime_raw: Mapped[str | None]
|
||||||
text_url_xml: Mapped[str | None]
|
text_url_xml: Mapped[str | None]
|
||||||
@@ -121,57 +122,6 @@ class BillText(DataScienceDevTableBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
bill: Mapped[Bill] = relationship("Bill", back_populates="bill_texts")
|
bill: Mapped[Bill] = relationship("Bill", back_populates="bill_texts")
|
||||||
summaries: Mapped[list[BillTextSummary]] = relationship(
|
|
||||||
"BillTextSummary",
|
|
||||||
back_populates="bill_text",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
foreign_keys="BillTextSummary.bill_text_id",
|
|
||||||
order_by=lambda: (
|
|
||||||
BillTextSummary.created.desc(),
|
|
||||||
BillTextSummary.id.desc(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
primary_summary: Mapped[BillTextSummary | None] = relationship(
|
|
||||||
"BillTextSummary",
|
|
||||||
foreign_keys=[primary_summary_id],
|
|
||||||
post_update=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def latest_summary(self) -> BillTextSummary | None:
|
|
||||||
"""Return the newest summary row for this bill text."""
|
|
||||||
return self.summaries[0] if self.summaries else None
|
|
||||||
|
|
||||||
def default_summary(self) -> BillTextSummary | None:
|
|
||||||
"""Return the primary summary when set, otherwise the newest summary."""
|
|
||||||
return self.primary_summary or self.latest_summary()
|
|
||||||
|
|
||||||
|
|
||||||
class BillTextSummary(DataScienceDevTableBase):
|
|
||||||
"""Stores one generated summary for a bill text version."""
|
|
||||||
|
|
||||||
__tablename__ = "bill_text_summary"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_bill_text_summary_bill_text_id", "bill_text_id"),
|
|
||||||
Index(
|
|
||||||
"ix_bill_text_summary_bill_text_id_created",
|
|
||||||
"bill_text_id",
|
|
||||||
"created",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
bill_text_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("main.bill_text.id", ondelete="CASCADE")
|
|
||||||
)
|
|
||||||
summary: Mapped[str]
|
|
||||||
summarization_model: Mapped[str | None]
|
|
||||||
summarization_user_prompt_version: Mapped[str | None]
|
|
||||||
summarization_system_prompt_version: Mapped[str | None]
|
|
||||||
|
|
||||||
bill_text: Mapped[BillText] = relationship(
|
|
||||||
"BillText",
|
|
||||||
back_populates="summaries",
|
|
||||||
foreign_keys=[bill_text_id],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BillAction(DataScienceDevTableBase):
|
class BillAction(DataScienceDevTableBase):
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from pipelines.orm.data_science_dev.congress import (
|
|||||||
BillActionRecordedVote,
|
BillActionRecordedVote,
|
||||||
BillRelation,
|
BillRelation,
|
||||||
BillText,
|
BillText,
|
||||||
BillTextSummary,
|
|
||||||
BillTopic,
|
BillTopic,
|
||||||
BillTopicPosition,
|
BillTopicPosition,
|
||||||
ClassificationMethod,
|
ClassificationMethod,
|
||||||
@@ -52,7 +51,6 @@ __all__ = [
|
|||||||
"BillActionRecordedVote",
|
"BillActionRecordedVote",
|
||||||
"BillRelation",
|
"BillRelation",
|
||||||
"BillText",
|
"BillText",
|
||||||
"BillTextSummary",
|
|
||||||
"BillTopic",
|
"BillTopic",
|
||||||
"BillTopicPosition",
|
"BillTopicPosition",
|
||||||
"ClassificationMethod",
|
"ClassificationMethod",
|
||||||
|
|||||||
@@ -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)
|
||||||
+125
-105
@@ -4,20 +4,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import hashlib
|
import logging
|
||||||
import hmac
|
|
||||||
from os import getenv
|
from os import getenv
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import secrets
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import parse_qs
|
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
|
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
|
||||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
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.db import session_scope, validate_database_connection
|
||||||
from pipelines.web.repository import Chamber, RankingResult
|
from pipelines.web.repository import Chamber, RankingResult
|
||||||
from pipelines.web.scoring import normalize_issues
|
from pipelines.web.scoring import normalize_issues
|
||||||
@@ -28,10 +25,7 @@ TEMPLATES_DIR = BASE_DIR / "templates"
|
|||||||
STATIC_DIR = BASE_DIR / "static"
|
STATIC_DIR = BASE_DIR / "static"
|
||||||
|
|
||||||
templates = Jinja2Templates(directory=TEMPLATES_DIR)
|
templates = Jinja2Templates(directory=TEMPLATES_DIR)
|
||||||
ADMIN_USERNAME = "admin"
|
logger = logging.getLogger(__name__)
|
||||||
ADMIN_PASSWORD = "admin"
|
|
||||||
SESSION_COOKIE = "nornsight_admin"
|
|
||||||
SESSION_SECRET = "nornsight-local-dev-session-secret"
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -62,72 +56,69 @@ def healthz() -> str:
|
|||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
@app.get("/login", response_class=HTMLResponse)
|
@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,
|
||||||
|
"home.html",
|
||||||
|
{
|
||||||
|
**_auth_context(current_user),
|
||||||
|
"auth_error": request.query_params.get("auth_error") == "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
def login(request: Request) -> Response:
|
def login(request: Request) -> Response:
|
||||||
"""Render the integrated login page."""
|
"""Start the WorkOS hosted login flow."""
|
||||||
next_path = _safe_next_path(request.query_params.get("next"))
|
next_path = auth.safe_next_path(request.query_params.get("next"))
|
||||||
if _authenticated_user(request) is not None:
|
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(next_path, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
return templates.TemplateResponse(
|
return RedirectResponse(
|
||||||
request,
|
auth.build_authorization_url(next_path),
|
||||||
"login.html",
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
{
|
|
||||||
"error": "",
|
|
||||||
"is_authenticated": False,
|
|
||||||
"show_primary_nav": False,
|
|
||||||
"next_path": next_path,
|
|
||||||
"username": "",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/login", response_class=HTMLResponse)
|
@app.get("/callback")
|
||||||
async def login_submit(request: Request) -> Response:
|
def callback(request: Request) -> Response:
|
||||||
"""Authenticate the hard-coded admin user and set a session cookie."""
|
"""Exchange the WorkOS code for a sealed session cookie."""
|
||||||
form = parse_qs((await request.body()).decode())
|
try:
|
||||||
username = form.get("username", [""])[0]
|
result = auth.exchange_code(request)
|
||||||
password = form.get("password", [""])[0]
|
except Exception:
|
||||||
next_path = _safe_next_path(form.get("next", [request.query_params.get("next")])[0])
|
logger.exception("WorkOS callback exchange failed.")
|
||||||
|
response = RedirectResponse("/?auth_error=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
_delete_auth_cookie(response)
|
||||||
|
return response
|
||||||
|
|
||||||
username_ok = secrets.compare_digest(username, ADMIN_USERNAME)
|
config = auth.get_auth_config()
|
||||||
password_ok = secrets.compare_digest(password, ADMIN_PASSWORD)
|
response = RedirectResponse(result.next_path, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
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)
|
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
SESSION_COOKIE,
|
config.session_cookie_name,
|
||||||
_sign_session(username),
|
result.sealed_session,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
|
secure=config.secure_cookies,
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@app.get("/logout")
|
@app.post("/logout")
|
||||||
def logout() -> Response:
|
def logout(request: Request) -> Response:
|
||||||
"""Clear the local admin session."""
|
"""End the WorkOS session and clear the local sealed session cookie."""
|
||||||
response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
|
response = RedirectResponse(auth.get_logout_url(request), status_code=status.HTTP_303_SEE_OTHER)
|
||||||
response.delete_cookie(SESSION_COOKIE)
|
_delete_auth_cookie(response)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def require_admin(request: Request) -> str:
|
def require_user(request: Request) -> auth.AuthSession:
|
||||||
"""Redirect unauthenticated users to the in-site login page."""
|
"""Redirect unauthenticated users to the WorkOS sign-in flow."""
|
||||||
username = _authenticated_user(request)
|
current_user = auth.get_current_session(request)
|
||||||
if username is not None:
|
if current_user is not None:
|
||||||
return username
|
return current_user
|
||||||
next_path = request.url.path
|
next_path = request.url.path
|
||||||
if request.url.query:
|
if request.url.query:
|
||||||
next_path = f"{next_path}?{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:
|
def require_admin(current_user: auth.AuthSession = Depends(require_user)) -> auth.AuthSession:
|
||||||
token = request.cookies.get(SESSION_COOKIE)
|
"""Restrict a route to WorkOS users with the admin role."""
|
||||||
if token is None:
|
if current_user.is_admin:
|
||||||
return None
|
return current_user
|
||||||
try:
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required.")
|
||||||
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 _sign_session(username: str) -> str:
|
@app.get("/dashboard", response_class=HTMLResponse)
|
||||||
return f"{username}:{_session_signature(username)}"
|
def dashboard(
|
||||||
|
request: Request, current_user: auth.AuthSession = Depends(require_user)
|
||||||
|
) -> Response:
|
||||||
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:
|
|
||||||
"""Render the full dashboard page."""
|
"""Render the full dashboard page."""
|
||||||
context = _dashboard_context(request)
|
context = {**_auth_context(current_user), **_dashboard_context(request)}
|
||||||
if request.headers.get("hx-request") == "true":
|
if request.headers.get("hx-request") == "true":
|
||||||
return templates.TemplateResponse(request, "partials/_dashboard.html", context)
|
return templates.TemplateResponse(request, "partials/_dashboard.html", context)
|
||||||
return templates.TemplateResponse(request, "dashboard.html", context)
|
return templates.TemplateResponse(request, "dashboard.html", context)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/partials/dashboard", response_class=HTMLResponse)
|
@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."""
|
"""Render the filter-dependent dashboard body."""
|
||||||
context = _dashboard_context(request)
|
context = _dashboard_context(request)
|
||||||
return templates.TemplateResponse(request, "partials/_dashboard.html", context)
|
return templates.TemplateResponse(request, "partials/_dashboard.html", context)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/partials/issues", response_class=HTMLResponse)
|
@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."""
|
"""Render only issue filters."""
|
||||||
context = _dashboard_context(request)
|
context = _dashboard_context(request)
|
||||||
return templates.TemplateResponse(request, "partials/_issue_filters.html", context)
|
return templates.TemplateResponse(request, "partials/_issue_filters.html", context)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/partials/rankings", response_class=HTMLResponse)
|
@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."""
|
"""Render only ranking panels."""
|
||||||
context = _dashboard_context(request)
|
context = _dashboard_context(request)
|
||||||
return templates.TemplateResponse(request, "partials/_rankings.html", context)
|
return templates.TemplateResponse(request, "partials/_rankings.html", context)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/partials/chart", response_class=HTMLResponse)
|
@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."""
|
"""Render only the SVG chart panel."""
|
||||||
context = _dashboard_context(request)
|
context = _dashboard_context(request)
|
||||||
return templates.TemplateResponse(request, "partials/_chart.html", context)
|
return templates.TemplateResponse(request, "partials/_chart.html", context)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/legislators", response_class=HTMLResponse)
|
@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."""
|
"""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)
|
return templates.TemplateResponse(request, "legislators.html", context)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/partials/legislator-suggestions", response_class=HTMLResponse)
|
@app.get("/partials/legislator-suggestions", response_class=HTMLResponse)
|
||||||
def legislator_suggestions_partial(
|
def legislator_suggestions_partial(
|
||||||
request: Request, _: str = Depends(require_admin)
|
request: Request, _: auth.AuthSession = Depends(require_user)
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Render legislator search suggestions for the HTMX typeahead."""
|
"""Render legislator search suggestions for the HTMX typeahead."""
|
||||||
query = request.query_params.get("q", "").strip()
|
query = request.query_params.get("q", "").strip()
|
||||||
@@ -238,12 +206,29 @@ def legislator_suggestions_partial(
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/compare", response_class=HTMLResponse)
|
@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."""
|
"""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)
|
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]:
|
def _dashboard_context(request: Request) -> dict[str, Any]:
|
||||||
state = _parse_state(request)
|
state = _parse_state(request)
|
||||||
base_context: dict[str, Any] = {
|
base_context: dict[str, Any] = {
|
||||||
@@ -263,6 +248,7 @@ def _dashboard_context(request: Request) -> dict[str, Any]:
|
|||||||
"has_scores": False,
|
"has_scores": False,
|
||||||
"empty_message": "",
|
"empty_message": "",
|
||||||
"build_url": _build_url,
|
"build_url": _build_url,
|
||||||
|
"build_dashboard_partial_url": _build_dashboard_partial_url,
|
||||||
"toggle_compare": _toggle_compare,
|
"toggle_compare": _toggle_compare,
|
||||||
}
|
}
|
||||||
with session_scope() as session:
|
with session_scope() as session:
|
||||||
@@ -520,10 +506,29 @@ def _build_url(
|
|||||||
for legislator_id in chosen_compare:
|
for legislator_id in chosen_compare:
|
||||||
params.append(("compare", str(legislator_id)))
|
params.append(("compare", str(legislator_id)))
|
||||||
if not params:
|
if not params:
|
||||||
return "/"
|
return "/dashboard"
|
||||||
from urllib.parse import urlencode
|
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]:
|
def _toggle_compare(compare: list[int], legislator_id: int) -> list[int]:
|
||||||
@@ -587,3 +592,18 @@ def _build_compare_url(
|
|||||||
if q:
|
if q:
|
||||||
params.append(("q", q))
|
params.append(("q", q))
|
||||||
return f"/compare?{urlencode(params, doseq=True)}" if params else "/compare"
|
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"))
|
||||||
|
|||||||
@@ -152,16 +152,35 @@ a {
|
|||||||
text-align: left;
|
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 {
|
.account-menu-panel .sign-out {
|
||||||
background: #0d5f53;
|
background: #0d5f53;
|
||||||
border-color: #16806f;
|
border-color: #16806f;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-menu-panel .sign-out,
|
||||||
.account-nav .sign-in {
|
.account-nav .sign-in {
|
||||||
background: #0d5f53;
|
background: #0d5f53;
|
||||||
border-color: #16806f;
|
border-color: #16806f;
|
||||||
|
border-radius: 7px;
|
||||||
color: white;
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
font: inherit;
|
||||||
|
min-width: 100%;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
@@ -170,6 +189,140 @@ a {
|
|||||||
padding: 1.25rem 1.5rem 2rem;
|
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 {
|
.login-shell {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1122,7 +1275,9 @@ h2 {
|
|||||||
.rankings-grid,
|
.rankings-grid,
|
||||||
.topic-panels,
|
.topic-panels,
|
||||||
.compare-card,
|
.compare-card,
|
||||||
.login-panel {
|
.login-panel,
|
||||||
|
.hero-panel,
|
||||||
|
.home-grid {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1212,4 +1367,9 @@ h2 {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding: 0 0 1rem;
|
padding: 0 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-card,
|
||||||
|
.home-card + .home-card {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Admin Settings{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<main class="shell">
|
||||||
|
<section class="page-heading stacked-heading">
|
||||||
|
<div>
|
||||||
|
<h1>Admin settings</h1>
|
||||||
|
<p>Admin-only operational controls for the Nornsight workspace.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-card">
|
||||||
|
<h2>WorkOS-managed access</h2>
|
||||||
|
<p>
|
||||||
|
Invitations, Google access, and role assignments are managed in the WorkOS dashboard.
|
||||||
|
This page confirms that app-level admin gating is active.
|
||||||
|
</p>
|
||||||
|
<dl class="admin-meta">
|
||||||
|
<div>
|
||||||
|
<dt>Workspace organization</dt>
|
||||||
|
<dd><code>{{ organization_id }}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Current administrator</dt>
|
||||||
|
<dd>{{ current_user_email }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div class="admin-actions">
|
||||||
|
<a href="/dashboard">Return to dashboard</a>
|
||||||
|
<a href="https://dashboard.workos.com/" rel="noreferrer" target="_blank">Open WorkOS dashboard</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -15,23 +15,33 @@
|
|||||||
</a>
|
</a>
|
||||||
{% if show_primary_nav|default(true) %}
|
{% if show_primary_nav|default(true) %}
|
||||||
<nav class="primary-nav" aria-label="Primary">
|
<nav class="primary-nav" aria-label="Primary">
|
||||||
<a href="/">Issues</a>
|
{% if is_authenticated|default(false) %}
|
||||||
|
<a href="/dashboard">Dashboard</a>
|
||||||
<a href="/legislators">Legislators</a>
|
<a href="/legislators">Legislators</a>
|
||||||
<a href="/compare">Compare</a>
|
<a href="/compare">Compare</a>
|
||||||
|
{% if is_admin|default(false) %}
|
||||||
|
<a href="/admin">Admin</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<a href="/">Overview</a>
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<nav class="account-nav" aria-label="Account">
|
<nav class="account-nav" aria-label="Account">
|
||||||
<a href="#" aria-disabled="true">Help</a>
|
<a href="#" aria-disabled="true">Help</a>
|
||||||
{% if is_authenticated|default(true) %}
|
{% if is_authenticated|default(false) %}
|
||||||
<details class="account-menu">
|
<details class="account-menu">
|
||||||
<summary>My account</summary>
|
<summary>{{ current_user_name or "My account" }}</summary>
|
||||||
<div class="account-menu-panel">
|
<div class="account-menu-panel">
|
||||||
|
<span class="account-email">{{ current_user_email }}</span>
|
||||||
<a href="#" aria-disabled="true">Account settings</a>
|
<a href="#" aria-disabled="true">Account settings</a>
|
||||||
<a class="sign-out" href="/logout">Sign out</a>
|
<form action="/logout" method="post">
|
||||||
|
<button class="sign-out" type="submit">Sign out</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="sign-in" href="/login">Sign in</a>
|
<a class="sign-in" href="/login?next=/dashboard">Sign in</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<p>US legislative accountability · precomputed legislator topic scores{% if latest_score_year %} through {{ latest_score_year }}{% endif %}</p>
|
<p>US legislative accountability · precomputed legislator topic scores{% if latest_score_year %} through {{ latest_score_year }}{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="heading-actions">
|
<div class="heading-actions">
|
||||||
|
<span>{{ current_user_email }}</span>
|
||||||
<a href="#" aria-disabled="true">Methodology</a>
|
<a href="#" aria-disabled="true">Methodology</a>
|
||||||
<a href="#" aria-disabled="true">Data sources</a>
|
<a href="#" aria-disabled="true">Data sources</a>
|
||||||
<span>Last updated: {{ last_updated.strftime("%b %Y") if last_updated else "Unavailable" }}</span>
|
<span>Last updated: {{ last_updated.strftime("%b %Y") if last_updated else "Unavailable" }}</span>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Nornsight | Legislative Accountability{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<main class="shell home-shell">
|
||||||
|
{% if auth_error %}
|
||||||
|
<div class="notice auth-notice">Authentication failed. Try signing in again.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="hero-panel">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<p class="eyebrow">Invite-only access</p>
|
||||||
|
<h1>Track legislative behavior with role-aware access and shared WorkOS sign-in.</h1>
|
||||||
|
<p class="hero-text">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
{% if is_authenticated %}
|
||||||
|
<a class="hero-primary" href="/dashboard">Open dashboard</a>
|
||||||
|
{% if is_admin %}
|
||||||
|
<a class="hero-secondary" href="/admin">Admin settings</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<a class="hero-primary" href="/login?next=/dashboard">Sign in</a>
|
||||||
|
<a class="hero-secondary" href="#access-model">How access works</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="hero-card">
|
||||||
|
<h2>Launch access model</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Public landing page at <code>/</code></li>
|
||||||
|
<li>Invite-only AuthKit login with Email + Password and Google</li>
|
||||||
|
<li><code>viewer</code> role for dashboard, legislators, and compare</li>
|
||||||
|
<li><code>admin</code> role for settings and account administration</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="access-model" class="home-grid">
|
||||||
|
<article class="home-card">
|
||||||
|
<h2>For invited users</h2>
|
||||||
|
<p>View the dashboard, inspect legislator profiles, and compare issue scoring without sharing a local password.</p>
|
||||||
|
</article>
|
||||||
|
<article class="home-card">
|
||||||
|
<h2>For admins</h2>
|
||||||
|
<p>Manage invitations and role assignments in WorkOS while the app enforces role-based route access.</p>
|
||||||
|
</article>
|
||||||
|
<article class="home-card">
|
||||||
|
<h2>For rollout</h2>
|
||||||
|
<p>Authentication is centralized, sessions are sealed, and the old hard-coded admin login is removed.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<header>
|
<header>
|
||||||
<h2>Score history{% if selected_issue_label %} — {{ selected_issue_label }}{% endif %}</h2>
|
<h2>Score history{% if selected_issue_label %} — {{ selected_issue_label }}{% endif %}</h2>
|
||||||
<a href="{{ build_url(request, compare=[]) }}"
|
<a href="{{ build_url(request, compare=[]) }}"
|
||||||
hx-get="/partials/dashboard{{ build_url(request, compare=[])|replace('/', '', 1) }}"
|
hx-get="{{ build_dashboard_partial_url(request, compare=[]) }}"
|
||||||
hx-target="#dashboard-body"
|
hx-target="#dashboard-body"
|
||||||
hx-push-url="{{ build_url(request, compare=[]) }}">Clear comparison</a>
|
hx-push-url="{{ build_url(request, compare=[]) }}">Clear comparison</a>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -3,17 +3,17 @@
|
|||||||
<div class="chamber-card">
|
<div class="chamber-card">
|
||||||
<a class="segment {{ 'active' if chamber == 'house' else '' }}"
|
<a class="segment {{ 'active' if chamber == 'house' else '' }}"
|
||||||
href="{{ build_url(request, chamber='house') }}"
|
href="{{ build_url(request, chamber='house') }}"
|
||||||
hx-get="/partials/dashboard{{ build_url(request, chamber='house')|replace('/', '', 1) }}"
|
hx-get="{{ build_dashboard_partial_url(request, chamber='house') }}"
|
||||||
hx-target="#dashboard-body"
|
hx-target="#dashboard-body"
|
||||||
hx-push-url="{{ build_url(request, chamber='house') }}">House</a>
|
hx-push-url="{{ build_url(request, chamber='house') }}">House</a>
|
||||||
<a class="segment {{ 'active' if chamber == 'senate' else '' }}"
|
<a class="segment {{ 'active' if chamber == 'senate' else '' }}"
|
||||||
href="{{ build_url(request, chamber='senate') }}"
|
href="{{ build_url(request, chamber='senate') }}"
|
||||||
hx-get="/partials/dashboard{{ build_url(request, chamber='senate')|replace('/', '', 1) }}"
|
hx-get="{{ build_dashboard_partial_url(request, chamber='senate') }}"
|
||||||
hx-target="#dashboard-body"
|
hx-target="#dashboard-body"
|
||||||
hx-push-url="{{ build_url(request, chamber='senate') }}">Senate</a>
|
hx-push-url="{{ build_url(request, chamber='senate') }}">Senate</a>
|
||||||
<a class="segment {{ 'active' if chamber == 'all' else '' }}"
|
<a class="segment {{ 'active' if chamber == 'all' else '' }}"
|
||||||
href="{{ build_url(request, chamber='all') }}"
|
href="{{ build_url(request, chamber='all') }}"
|
||||||
hx-get="/partials/dashboard{{ build_url(request, chamber='all')|replace('/', '', 1) }}"
|
hx-get="{{ build_dashboard_partial_url(request, chamber='all') }}"
|
||||||
hx-target="#dashboard-body"
|
hx-target="#dashboard-body"
|
||||||
hx-push-url="{{ build_url(request, chamber='all') }}">All</a>
|
hx-push-url="{{ build_url(request, chamber='all') }}">All</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
<h2>Issue filters</h2>
|
<h2>Issue filters</h2>
|
||||||
<form class="issue-form"
|
<form class="issue-form"
|
||||||
method="get"
|
method="get"
|
||||||
action="/"
|
action="/dashboard"
|
||||||
hx-get="/"
|
hx-get="/partials/dashboard"
|
||||||
hx-target="#dashboard-body"
|
hx-target="#dashboard-body"
|
||||||
hx-push-url="true">
|
hx-push-url="/dashboard">
|
||||||
<input type="hidden" name="chamber" value="{{ chamber }}">
|
<input type="hidden" name="chamber" value="{{ chamber }}">
|
||||||
{% if congress %}
|
{% if congress %}
|
||||||
<input type="hidden" name="congress" value="{{ congress }}">
|
<input type="hidden" name="congress" value="{{ congress }}">
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<span class="chip">
|
<span class="chip">
|
||||||
{{ issue }}
|
{{ issue }}
|
||||||
<a href="{{ build_url(request, issues=issues[:loop.index0] + issues[loop.index:]) }}"
|
<a href="{{ build_url(request, issues=issues[:loop.index0] + issues[loop.index:]) }}"
|
||||||
hx-get="/partials/dashboard{{ build_url(request, issues=issues[:loop.index0] + issues[loop.index:])|replace('/', '', 1) }}"
|
hx-get="{{ build_dashboard_partial_url(request, issues=issues[:loop.index0] + issues[loop.index:]) }}"
|
||||||
hx-target="#dashboard-body"
|
hx-target="#dashboard-body"
|
||||||
hx-push-url="{{ build_url(request, issues=issues[:loop.index0] + issues[loop.index:]) }}"
|
hx-push-url="{{ build_url(request, issues=issues[:loop.index0] + issues[loop.index:]) }}"
|
||||||
aria-label="Remove {{ issue }}">×</a>
|
aria-label="Remove {{ issue }}">×</a>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
{% for suggestion in suggestions %}
|
{% for suggestion in suggestions %}
|
||||||
{% if suggestion not in issues %}
|
{% if suggestion not in issues %}
|
||||||
<a href="{{ build_url(request, issues=issues + [suggestion]) }}"
|
<a href="{{ build_url(request, issues=issues + [suggestion]) }}"
|
||||||
hx-get="/partials/dashboard{{ build_url(request, issues=issues + [suggestion])|replace('/', '', 1) }}"
|
hx-get="{{ build_dashboard_partial_url(request, issues=issues + [suggestion]) }}"
|
||||||
hx-target="#dashboard-body"
|
hx-target="#dashboard-body"
|
||||||
hx-push-url="{{ build_url(request, issues=issues + [suggestion]) }}">{{ suggestion }}</a>
|
hx-push-url="{{ build_url(request, issues=issues + [suggestion]) }}">{{ suggestion }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
{% set next_compare = toggle_compare(compare, row.legislator_id) %}
|
{% set next_compare = toggle_compare(compare, row.legislator_id) %}
|
||||||
<li class="{{ 'selected' if row.legislator_id in compare else '' }}">
|
<li class="{{ 'selected' if row.legislator_id in compare else '' }}">
|
||||||
<a href="{{ build_url(request, compare=next_compare) }}"
|
<a href="{{ build_url(request, compare=next_compare) }}"
|
||||||
hx-get="/partials/dashboard{{ build_url(request, compare=next_compare)|replace('/', '', 1) }}"
|
hx-get="{{ build_dashboard_partial_url(request, compare=next_compare) }}"
|
||||||
hx-target="#dashboard-body"
|
hx-target="#dashboard-body"
|
||||||
hx-push-url="{{ build_url(request, compare=next_compare) }}">
|
hx-push-url="{{ build_url(request, compare=next_compare) }}">
|
||||||
<span class="rank">{{ loop.index }}</span>
|
<span class="rank">{{ loop.index }}</span>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
{% set next_compare = toggle_compare(compare, row.legislator_id) %}
|
{% set next_compare = toggle_compare(compare, row.legislator_id) %}
|
||||||
<li class="{{ 'selected' if row.legislator_id in compare else '' }}">
|
<li class="{{ 'selected' if row.legislator_id in compare else '' }}">
|
||||||
<a href="{{ build_url(request, compare=next_compare) }}"
|
<a href="{{ build_url(request, compare=next_compare) }}"
|
||||||
hx-get="/partials/dashboard{{ build_url(request, compare=next_compare)|replace('/', '', 1) }}"
|
hx-get="{{ build_dashboard_partial_url(request, compare=next_compare) }}"
|
||||||
hx-target="#dashboard-body"
|
hx-target="#dashboard-body"
|
||||||
hx-push-url="{{ build_url(request, compare=next_compare) }}">
|
hx-push-url="{{ build_url(request, compare=next_compare) }}">
|
||||||
<span class="rank">{{ loop.index }}</span>
|
<span class="rank">{{ loop.index }}</span>
|
||||||
|
|||||||
+8
-2
@@ -4,12 +4,15 @@ version = "0.1.0"
|
|||||||
description = "Data science pipeline tools and legislative dashboard."
|
description = "Data science pipeline tools and legislative dashboard."
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"alembic",
|
||||||
"fastapi",
|
"fastapi",
|
||||||
"httpx",
|
"httpx",
|
||||||
"uvicorn[standard]",
|
|
||||||
"jinja2",
|
"jinja2",
|
||||||
"sqlalchemy",
|
|
||||||
"psycopg",
|
"psycopg",
|
||||||
|
"sqlalchemy",
|
||||||
|
"typer",
|
||||||
|
"uvicorn[standard]",
|
||||||
|
"workos",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -20,3 +23,6 @@ test = [
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["."]
|
pythonpath = ["."]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["pipelines*"]
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
from pipelines.orm.data_science_dev.congress import BillText, BillTextSummary
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_summary_prefers_primary_summary() -> None:
|
|
||||||
primary_summary = BillTextSummary(id=1, bill_text_id=10, summary="primary")
|
|
||||||
latest_summary = BillTextSummary(id=2, bill_text_id=10, summary="latest")
|
|
||||||
bill_text = BillText(
|
|
||||||
id=10,
|
|
||||||
bill_id=5,
|
|
||||||
version_code="ih",
|
|
||||||
summaries=[latest_summary],
|
|
||||||
primary_summary=primary_summary,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert bill_text.default_summary() is primary_summary
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_summary_falls_back_to_latest_summary() -> None:
|
|
||||||
latest_summary = BillTextSummary(id=2, bill_text_id=10, summary="latest")
|
|
||||||
older_summary = BillTextSummary(id=1, bill_text_id=10, summary="older")
|
|
||||||
bill_text = BillText(
|
|
||||||
id=10,
|
|
||||||
bill_id=5,
|
|
||||||
version_code="ih",
|
|
||||||
summaries=[latest_summary, older_summary],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert bill_text.latest_summary() is latest_summary
|
|
||||||
assert bill_text.default_summary() is latest_summary
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_summary_is_none_without_summaries() -> None:
|
|
||||||
bill_text = BillText(id=10, bill_id=5, version_code="ih")
|
|
||||||
|
|
||||||
assert bill_text.latest_summary() is None
|
|
||||||
assert bill_text.default_summary() is None
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
from pipelines.jobs.extract_bill_topics import (
|
|
||||||
_select_bill_text_for_topic_extraction,
|
|
||||||
create_select_bills_for_topic_extraction,
|
|
||||||
)
|
|
||||||
from pipelines.orm.data_science_dev.congress import Bill, BillText, BillTextSummary
|
|
||||||
|
|
||||||
|
|
||||||
def _compile_sql(statement: object) -> str:
|
|
||||||
return str(
|
|
||||||
statement.compile(
|
|
||||||
dialect=postgresql.dialect(),
|
|
||||||
compile_kwargs={"literal_binds": True},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_select_bill_text_for_topic_extraction_uses_primary_summary() -> None:
|
|
||||||
primary_summary = BillTextSummary(id=1, bill_text_id=10, summary="primary")
|
|
||||||
newest_summary = BillTextSummary(id=2, bill_text_id=10, summary="newest")
|
|
||||||
bill_text = BillText(
|
|
||||||
id=10,
|
|
||||||
bill_id=5,
|
|
||||||
version_code="ih",
|
|
||||||
summaries=[newest_summary],
|
|
||||||
primary_summary=primary_summary,
|
|
||||||
)
|
|
||||||
bill = Bill(
|
|
||||||
id=5,
|
|
||||||
congress=119,
|
|
||||||
bill_type="hr",
|
|
||||||
number=1,
|
|
||||||
bill_texts=[bill_text],
|
|
||||||
)
|
|
||||||
|
|
||||||
selected = _select_bill_text_for_topic_extraction(bill)
|
|
||||||
|
|
||||||
assert selected is bill_text
|
|
||||||
assert selected.default_summary() is primary_summary
|
|
||||||
|
|
||||||
|
|
||||||
def test_select_bill_text_for_topic_extraction_uses_latest_summary_without_primary() -> None:
|
|
||||||
newest_summary = BillTextSummary(id=2, bill_text_id=10, summary="newest")
|
|
||||||
older_summary = BillTextSummary(id=1, bill_text_id=10, summary="older")
|
|
||||||
bill_text = BillText(
|
|
||||||
id=10,
|
|
||||||
bill_id=5,
|
|
||||||
version_code="ih",
|
|
||||||
summaries=[newest_summary, older_summary],
|
|
||||||
)
|
|
||||||
bill = Bill(
|
|
||||||
id=5,
|
|
||||||
congress=119,
|
|
||||||
bill_type="hr",
|
|
||||||
number=1,
|
|
||||||
bill_texts=[bill_text],
|
|
||||||
)
|
|
||||||
|
|
||||||
selected = _select_bill_text_for_topic_extraction(bill)
|
|
||||||
|
|
||||||
assert selected is bill_text
|
|
||||||
assert selected.default_summary() is newest_summary
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_select_bills_for_topic_extraction_uses_summary_exists_subquery() -> None:
|
|
||||||
sql = _compile_sql(create_select_bills_for_topic_extraction())
|
|
||||||
|
|
||||||
assert "bill_text_summary" in sql
|
|
||||||
assert "EXISTS" in sql
|
|
||||||
assert "bill_text.summary" not in sql
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
from pipelines.jobs.summarize_bills import (
|
|
||||||
create_select_bill_texts_for_summarization,
|
|
||||||
store_bill_summary_result,
|
|
||||||
)
|
|
||||||
from pipelines.orm.data_science_dev.congress import BillText, BillTextSummary
|
|
||||||
|
|
||||||
|
|
||||||
class FakeSession:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.added: list[object] = []
|
|
||||||
|
|
||||||
def add(self, value: object) -> None:
|
|
||||||
self.added.append(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _compile_sql(statement: object) -> str:
|
|
||||||
return str(
|
|
||||||
statement.compile(
|
|
||||||
dialect=postgresql.dialect(),
|
|
||||||
compile_kwargs={"literal_binds": True},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_bill_summary_result_creates_summary_row() -> None:
|
|
||||||
session = FakeSession()
|
|
||||||
bill_text = BillText(id=10, bill_id=5, version_code="ih")
|
|
||||||
|
|
||||||
summary_row = store_bill_summary_result(
|
|
||||||
session=session,
|
|
||||||
bill_text=bill_text,
|
|
||||||
summary="A summary",
|
|
||||||
model="gpt-5.4-mini",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert session.added == [summary_row]
|
|
||||||
assert isinstance(summary_row, BillTextSummary)
|
|
||||||
assert summary_row.bill_text is bill_text
|
|
||||||
assert summary_row.summary == "A summary"
|
|
||||||
assert summary_row.summarization_model == "gpt-5.4-mini"
|
|
||||||
assert summary_row.summarization_system_prompt_version == "v1.2"
|
|
||||||
assert summary_row.summarization_user_prompt_version == "v1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_select_bill_texts_for_summarization_excludes_existing_summaries() -> None:
|
|
||||||
sql = _compile_sql(create_select_bill_texts_for_summarization(force=False))
|
|
||||||
|
|
||||||
assert "bill_text_summary" in sql
|
|
||||||
assert "NOT (EXISTS" in sql or "NOT EXISTS" in sql
|
|
||||||
assert "bill_text.summary" not in sql
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_select_bill_texts_for_summarization_force_skips_summary_filter() -> None:
|
|
||||||
sql = _compile_sql(create_select_bill_texts_for_summarization(force=True))
|
|
||||||
|
|
||||||
assert "bill_text_summary" not in sql
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user